diff --git a/app/components/AffinityDocsPopover.tsx b/app/components/AffinityDocsPopover.tsx new file mode 100644 index 000000000..5d4ec1101 --- /dev/null +++ b/app/components/AffinityDocsPopover.tsx @@ -0,0 +1,29 @@ +/* + * 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 { policyHelpText } from '~/forms/affinity-util' +import { TipIcon } from '~/ui/lib/TipIcon' +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 attribute determines whether instances can still start when a unique sled is not available." + links={[docLinks.affinity]} + /> +) + +export const AffinityPolicyHeader = () => ( + <> + Policy{policyHelpText} + +) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx new file mode 100644 index 000000000..32408f32a --- /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 instanceList = ({ project }: PP.Project) => + apiq('instanceList', { 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 } }) + +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 policyHelpText = + "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 new file mode 100644 index 000000000..119888f92 --- /dev/null +++ b/app/forms/anti-affinity-group-create.tsx @@ -0,0 +1,81 @@ +/* + * 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 } from 'react-router' + +import { queryClient, useApiMutation, type AntiAffinityGroupCreate } 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 { 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 = { + name: '', + description: '', + policy: 'allow', +} + +export default function CreateAntiAffinityGroupForm() { + const { project } = useProjectSelector() + + const navigate = useNavigate() + + 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 + }, + }) + + const form = useForm({ defaultValues }) + const control = form.control + + return ( + navigate(pb.affinity({ project }))} + onSubmit={(values) => + createAntiAffinityGroup.mutate({ + query: { project }, + 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 new file mode 100644 index 000000000..e098c05f6 --- /dev/null +++ b/app/forms/anti-affinity-group-edit.tsx @@ -0,0 +1,84 @@ +/* + * 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 * as R from 'remeda' + +import { + 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 { pb } from '~/util/path-builder' + +import { antiAffinityGroupView } from './affinity-util' + +export const handle = titleCrumb('New anti-affinity group') + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project, antiAffinityGroup } = getAntiAffinityGroupSelector(params) + await queryClient.prefetchQuery(antiAffinityGroupView({ project, antiAffinityGroup })) + return null +} + +export default function EditAntiAffintyGroupForm() { + const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + + const navigate = useNavigate() + + const editAntiAffinityGroup = useApiMutation('antiAffinityGroupUpdate', { + 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: group } = usePrefetchedQuery( + antiAffinityGroupView({ project, antiAffinityGroup }) + ) + + const defaultValues: AntiAffinityGroupUpdate = R.pick(group, ['name', 'description']) + const form = useForm({ defaultValues }) + + return ( + navigate(pb.antiAffinityGroup({ project, antiAffinityGroup }))} + onSubmit={(values) => { + editAntiAffinityGroup.mutate({ + path: { antiAffinityGroup }, + query: { project }, + body: values, + }) + }} + loading={editAntiAffinityGroup.isPending} + submitError={editAntiAffinityGroup.error} + submitLabel="Edit group" + > + + + + ) +} 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 000000000..64a6c8b5c --- /dev/null +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -0,0 +1,71 @@ +/* + * 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 { useId } from 'react' +import { useForm } from 'react-hook-form' + +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' +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({ instances, onDismiss }: Props) { + const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + + const form = useForm({ defaultValues }) + const formId = useId() + + const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { + onSuccess(_data, variables) { + onDismiss() + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('antiAffinityGroupView') + addToast(<>Instance {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore + }, + }) + + const onSubmit = form.handleSubmit(({ instance }) => { + addMember({ + path: { antiAffinityGroup, instance }, + query: { project }, + }) + }) + + return ( + + + +

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

+
+ + +
+
+ +
+ ) +} diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index cbb5fb9d3..5381ee959 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 @@ -106,7 +106,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Snapshots - Images + Images VPCs @@ -115,10 +115,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Affinity + Affinity Groups - Access + Access diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index ceb19ab0e..8c9c72452 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -6,13 +6,11 @@ * Copyright Oxide Computer Company */ -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 { Outlet, type LoaderFunctionArgs } from 'react-router' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, @@ -21,9 +19,11 @@ import { } from '@oxide/api' import { Affinity24Icon } from '@oxide/design-system/icons/react' +import { AffinityDocsPopover, AffinityPolicyHeader } from '~/components/AffinityDocsPopover' import { HL } from '~/components/HL' +import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affinity-util' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' -import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -31,29 +31,19 @@ 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 { intersperse } from '~/util/array' -import { ALL_ISH } from '~/util/consts' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' 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) 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, @@ -64,43 +54,32 @@ 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 */} - -) - -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('description', Columns.description), - colHelper.accessor(() => {}, { + colHelper.display({ header: 'type', cell: () => anti-affinity, }), - colHelper.accessor('policy', { - cell: (info) => , - }), colHelper.accessor('name', { - header: 'members', + header: 'instances', cell: (info) => , }), + colHelper.accessor('policy', { + header: AffinityPolicyHeader, + cell: (info) => , + }), colHelper.accessor('timeCreated', Columns.timeCreated), ] 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) { @@ -115,26 +94,24 @@ export default function AffinityPage() { const makeActions = useCallback( (antiAffinityGroup: AntiAffinityGroup): MenuAction[] => [ + { + label: 'Edit', + to: pb.antiAffinityGroupEdit({ + project, + antiAffinityGroup: antiAffinityGroup.name, + }), + }, { label: 'Delete', - 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}? -

- ), - errorTitle: `Error removing ${antiAffinityGroup.name}`, - }) - }, + onActivate: confirmDelete({ + doDelete: () => + deleteGroup({ + path: { antiAffinityGroup: antiAffinityGroup.name }, + query: { project }, + }), + label: antiAffinityGroup.name, + resourceKind: 'anti-affinity group', + }), }, ], [project, deleteGroup] @@ -155,14 +132,25 @@ export default function AffinityPage() { const table = useReactTable({ columns, - data: data.items, + data: antiAffinityGroups, getCoreRowModel: getCoreRowModel(), }) return ( <> - - {data.items.length ? : } + + }>Affinity Groups + + + + New group + + {antiAffinityGroups.length ? ( +
+ ) : ( + + )} + ) } @@ -177,31 +165,16 @@ export const AntiAffinityGroupEmptyState = () => ( ) -// TODO: Use the prefetched query export const AffinityGroupMembersCell = ({ antiAffinityGroup, }: { antiAffinityGroup: string }) => { const { project } = useProjectSelector() - const { data: members } = useQuery(memberList({ antiAffinityGroup, project })) - + 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} } diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 4e259aaeb..92c3a908a 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -7,41 +7,49 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback } from 'react' -import type { LoaderFunctionArgs } from 'react-router' +import { useCallback, useState } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, type AntiAffinityGroupMember, } from '~/api' +import { AffinityDocsPopover, AffinityPolicyHeader } from '~/components/AffinityDocsPopover' import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { + antiAffinityGroupMemberList, + antiAffinityGroupView, + instanceList, +} from '~/forms/affinity-util' +import AddAntiAffinityGroupMemberForm from '~/forms/anti-affinity-group-member-add' import { makeCrumb } from '~/hooks/use-crumbs' 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' 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' +import { Button } from '~/ui/lib/Button' import { CardBlock } from '~/ui/lib/CardBlock' 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 { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -import { AffinityPageHeader } from './AffinityPage' export const handle = makeCrumb( (p) => p.antiAffinityGroup!, @@ -50,20 +58,12 @@ 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 }, - }) - 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(antiAffinityGroupView({ antiAffinityGroup, project })), + queryClient.prefetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), + queryClient.prefetchQuery(instanceList({ project })), ]) return null } @@ -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" /> @@ -84,9 +84,17 @@ 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 { 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 { mutateAsync: removeMember } = useApiMutation( 'antiAffinityGroupMemberInstanceDelete', { @@ -98,14 +106,21 @@ 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) + const makeActions = useCallback( (antiAffinityGroupMember: AntiAffinityGroupMember): MenuAction[] => [ - { - label: 'Copy instance ID', - onActivate() { - navigator.clipboard.writeText(antiAffinityGroupMember.value.id) - }, - }, { label: 'Remove from group', onActivate() { @@ -138,10 +153,11 @@ export default function AntiAffinityPage() { const columns = useColsWithActions( [ colHelper.accessor('value.name', { - header: 'Name', - cell: makeLinkCell((instance) => pb.instance({ project, instance })), + header: 'name', + cell: makeLinkCell((instance) => pb.instanceSettings({ project, instance })), }), colHelper.accessor('value.runState', Columns.instanceState), + colHelper.accessor('value.id', Columns.id), ], makeActions ) @@ -152,16 +168,55 @@ export default function AntiAffinityPage() { getCoreRowModel: getCoreRowModel(), }) + const disabledReason = () => { + // 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' + } + if (!instances.items.length) { + return 'No instances available' + } + if (!availableInstances.length) { + return 'All instances are already in this group' + } + return undefined + } + return ( <> - + + }>{name} +
+ + + + Edit + + + deleteGroup({ + path: { antiAffinityGroup: group.name }, + query: { project }, + }), + label: group.name, + resourceKind: 'anti-affinity group', + })} + className="destructive" + /> + +
+
anti-affinity - - {policy} + }> + {membersCount} @@ -172,11 +227,27 @@ export default function AntiAffinityPage() { + > + + {membersCount ?
: } + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} + ) } diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index 95c4649fc..6d4c237a7 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -17,11 +17,12 @@ import { } from '@oxide/api' 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' @@ -39,7 +40,8 @@ const colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('description', Columns.description), colHelper.accessor('policy', { - cell: (info) => {info.getValue()}, + header: AffinityPolicyHeader, + cell: (info) => , }), ] diff --git a/app/routes.tsx b/app/routes.tsx index d3bd20531..965944f83 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -500,7 +500,16 @@ export const routes = createRoutesFromElements( path="access" lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} /> - + import('./pages/project/affinity/AffinityPage').then(convert)} + handle={{ crumb: 'Affinity Groups' }} + > + import('./forms/anti-affinity-group-create').then(convert)} + /> + + import('./pages/project/affinity/AffinityPage.tsx').then(convert)} @@ -510,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/table/columns/common.tsx b/app/table/columns/common.tsx index f2d049683..21c368f3c 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -11,6 +11,7 @@ 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' // the full type of the info arg is CellContext from RT, but in these @@ -21,6 +22,18 @@ function dateCell(info: Info) { return } +function idCell(info: Info) { + const text = info.getValue() + return ( +
+ {text} +
+ +
+
+ ) +} + function instanceStateCell(info: Info) { return } @@ -40,6 +53,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 }, diff --git a/app/ui/lib/Combobox.tsx b/app/ui/lib/Combobox.tsx index 436992169..45859241f 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} diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index 9162cb4ea..8101ea8b3 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/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 922c3b6c1..d08eaa234 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -12,10 +12,24 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", "path": "/projects/p/affinity", }, ], + "affinityNew (/projects/p/affinity-new)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Affinity Groups", + "path": "/projects/p/", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", @@ -26,7 +40,25 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", + "path": "/projects/p/affinity", + }, + { + "label": "aag", + "path": "/projects/p/affinity/aag", + }, + ], + "antiAffinityGroupEdit (/projects/p/affinity/aag/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Affinity Groups", "path": "/projects/p/affinity", }, { diff --git a/app/util/links.ts b/app/util/links.ts index 3d780d18c..1113b1488 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', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index a7949dcca..c8992564f 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", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 30e5659cf..11ddc6ab3 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -97,8 +97,11 @@ export const pb = { `${pb.floatingIps(params)}/${params.floatingIp}/edit`, affinity: (params: PP.Project) => `${projectBase(params)}/affinity`, + 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', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 6f26058b2..2e55a4f8b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1641,6 +1641,35 @@ 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) + return antiAffinityGroup + }, antiAffinityGroupList: ({ query }) => { const project = lookup.project({ ...query }) const antiAffinityGroups = db.antiAffinityGroups.filter( @@ -1667,6 +1696,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 }) @@ -1711,10 +1769,7 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - antiAffinityGroupCreate: NotImplemented, - antiAffinityGroupMemberInstanceAdd: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, - antiAffinityGroupUpdate: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, certificateList: NotImplemented, diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts new file mode 100644 index 000000000..36c7bb3f5 --- /dev/null +++ b/test/e2e/anti-affinity.e2e.ts @@ -0,0 +1,167 @@ +/* + * 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, closeToast, 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', + instances: '2', + }) + + // 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 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 }) => { + 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 + 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() + + 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 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() +}) + +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() +})