diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx new file mode 100644 index 0000000000..95c4649fcb --- /dev/null +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -0,0 +1,95 @@ +/* + * 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 { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { + apiq, + usePrefetchedQuery, + type AffinityGroup, + type AntiAffinityGroup, +} from '@oxide/api' +import { Affinity24Icon } from '@oxide/design-system/icons/react' + +import { useInstanceSelector } from '~/hooks/use-params' +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' +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) => + apiq('instanceAntiAffinityGroupList', { + path: { instance }, + query: { project, limit: ALL_ISH }, + }) + +const colHelper = createColumnHelper() +const staticCols = [ + colHelper.accessor('description', Columns.description), + colHelper.accessor('policy', { + cell: (info) => {info.getValue()}, + }), +] + +export function AntiAffinityCard() { + const instanceSelector = useInstanceSelector() + const { project } = instanceSelector + + const { data: antiAffinityGroups } = usePrefetchedQuery( + antiAffinityGroupList(instanceSelector) + ) + + const antiAffinityCols = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((antiAffinityGroup) => + pb.antiAffinityGroup({ project, antiAffinityGroup }) + ), + }), + ...staticCols, + ], + [project] + ) + + // Create tables for both types of groups + const antiAffinityTable = useReactTable({ + columns: antiAffinityCols, + data: antiAffinityGroups.items, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + + + + {antiAffinityGroups.items.length > 0 ? ( + + ) : ( + + } + title="No anti-affinity groups" + body="This instance is not a member of any anti-affinity groups" + /> + + )} + + + ) +} diff --git a/app/pages/project/instances/AutoRestartCard.tsx b/app/pages/project/instances/AutoRestartCard.tsx new file mode 100644 index 0000000000..8052f33cb8 --- /dev/null +++ b/app/pages/project/instances/AutoRestartCard.tsx @@ -0,0 +1,165 @@ +/* + * 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 { formatDistanceToNow } from 'date-fns' +import { type ReactNode } from 'react' +import { useForm } from 'react-hook-form' +import { match } from 'ts-pattern' + +import { + apiQueryClient, + instanceAutoRestartingSoon, + useApiMutation, + usePrefetchedApiQuery, +} from '~/api' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { Button } from '~/ui/lib/Button' +import { CardBlock, LearnMore } from '~/ui/lib/CardBlock' +import { type ListboxItem } from '~/ui/lib/Listbox' +import { TipIcon } from '~/ui/lib/TipIcon' +import { toLocaleDateTimeString } from '~/util/date' +import { links } from '~/util/links' + +type FormPolicy = 'default' | 'never' | 'best_effort' + +const restartPolicyItems: ListboxItem[] = [ + { value: 'default', label: 'Default' }, + { value: 'never', label: 'Never' }, + { value: 'best_effort', label: 'Best effort' }, +] + +type FormValues = { + autoRestartPolicy: FormPolicy +} + +export function AutoRestartCard() { + const instanceSelector = useInstanceSelector() + + const { data: instance } = usePrefetchedApiQuery('instanceView', { + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + }) + + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess() { + apiQueryClient.invalidateQueries('instanceView') + addToast({ content: 'Instance auto-restart policy updated' }) + }, + onError(err) { + addToast({ + title: 'Could not update auto-restart policy', + content: err.message, + variant: 'error', + }) + }, + }) + + const autoRestartPolicy = instance.autoRestartPolicy || 'default' + const defaultValues: FormValues = { autoRestartPolicy } + + const form = useForm({ defaultValues }) + + // note there are no instance state-based restrictions on updating auto + // restart, so there is no instanceCan helper for it + // https://github.com/oxidecomputer/omicron/blob/0c6ab099e/nexus/db-queries/src/db/datastore/instance.rs#L1050-L1058 + const disableSubmit = form.watch('autoRestartPolicy') === autoRestartPolicy + + const onSubmit = form.handleSubmit((values) => { + instanceUpdate.mutate({ + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + body: { + ncpus: instance.ncpus, + memory: instance.memory, + bootDisk: instance.bootDiskId, + autoRestartPolicy: match(values.autoRestartPolicy) + .with('default', () => undefined) + .with('never', () => 'never' as const) + .with('best_effort', () => 'best_effort' as const) + .exhaustive(), + }, + }) + }) + + return ( +
+ + + + + + {instance.autoRestartCooldownExpiration ? ( + <> + {toLocaleDateTimeString(instance.autoRestartCooldownExpiration)}{' '} + {instance.runState === 'failed' && instance.autoRestartEnabled && ( + + ( + {instanceAutoRestartingSoon(instance) + ? 'restarting soon' + : formatDistanceToNow(instance.autoRestartCooldownExpiration)} + ) + + )} + + ) : ( + N/A + )} + + + {instance.timeLastAutoRestarted ? ( + toLocaleDateTimeString(instance.timeLastAutoRestarted) + ) : ( + N/A + )} + + + + + + + + + ) +} + +type FormMetaProps = { + label: string + tip?: string + children: ReactNode +} + +const FormMeta = ({ label, tip, children }: FormMetaProps) => ( +
+
+
{label}
+ {tip && {tip}} +
+ {children} +
+) diff --git a/app/pages/project/instances/SettingsTab.tsx b/app/pages/project/instances/SettingsTab.tsx index cd5968ce54..97de07931a 100644 --- a/app/pages/project/instances/SettingsTab.tsx +++ b/app/pages/project/instances/SettingsTab.tsx @@ -6,162 +6,28 @@ * Copyright Oxide Computer Company */ -import { formatDistanceToNow } from 'date-fns' -import { type ReactNode } from 'react' -import { useForm } from 'react-hook-form' -import { match } from 'ts-pattern' +import { type LoaderFunctionArgs } from 'react-router' -import { - apiQueryClient, - instanceAutoRestartingSoon, - useApiMutation, - usePrefetchedApiQuery, -} from '~/api' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { useInstanceSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { Button } from '~/ui/lib/Button' -import { CardBlock, LearnMore } from '~/ui/lib/CardBlock' -import { type ListboxItem } from '~/ui/lib/Listbox' -import { TipIcon } from '~/ui/lib/TipIcon' -import { toLocaleDateTimeString } from '~/util/date' -import { links } from '~/util/links' +import { queryClient } from '@oxide/api' -type FormPolicy = 'default' | 'never' | 'best_effort' +import { getInstanceSelector } from '~/hooks/use-params' -const restartPolicyItems: ListboxItem[] = [ - { value: 'default', label: 'Default' }, - { value: 'never', label: 'Never' }, - { value: 'best_effort', label: 'Best effort' }, -] - -type FormValues = { - autoRestartPolicy: FormPolicy -} +import { AntiAffinityCard, antiAffinityGroupList } from './AntiAffinityCard' +import { AutoRestartCard } from './AutoRestartCard' export const handle = { crumb: 'Settings' } -export default function SettingsTab() { - const instanceSelector = useInstanceSelector() - - const { data: instance } = usePrefetchedApiQuery('instanceView', { - path: { instance: instanceSelector.instance }, - query: { project: instanceSelector.project }, - }) - - const instanceUpdate = useApiMutation('instanceUpdate', { - onSuccess() { - apiQueryClient.invalidateQueries('instanceView') - addToast({ content: 'Instance auto-restart policy updated' }) - }, - onError(err) { - addToast({ - title: 'Could not update auto-restart policy', - content: err.message, - variant: 'error', - }) - }, - }) - - const autoRestartPolicy = instance.autoRestartPolicy || 'default' - const defaultValues: FormValues = { autoRestartPolicy } - - const form = useForm({ defaultValues }) - - // note there are no instance state-based restrictions on updating auto - // restart, so there is no instanceCan helper for it - // https://github.com/oxidecomputer/omicron/blob/0c6ab099e/nexus/db-queries/src/db/datastore/instance.rs#L1050-L1058 - const disableSubmit = form.watch('autoRestartPolicy') === autoRestartPolicy - - const onSubmit = form.handleSubmit((values) => { - instanceUpdate.mutate({ - path: { instance: instanceSelector.instance }, - query: { project: instanceSelector.project }, - body: { - ncpus: instance.ncpus, - memory: instance.memory, - bootDisk: instance.bootDiskId, - autoRestartPolicy: match(values.autoRestartPolicy) - .with('default', () => undefined) - .with('never', () => 'never' as const) - .with('best_effort', () => 'best_effort' as const) - .exhaustive(), - }, - }) - }) +export async function clientLoader({ params }: LoaderFunctionArgs) { + const instanceSelector = getInstanceSelector(params) + await queryClient.prefetchQuery(antiAffinityGroupList(instanceSelector)) + return null +} +export default function SettingsTab() { return ( -
- - - - - - {instance.autoRestartCooldownExpiration ? ( - <> - {toLocaleDateTimeString(instance.autoRestartCooldownExpiration)}{' '} - {instance.runState === 'failed' && instance.autoRestartEnabled && ( - - ( - {instanceAutoRestartingSoon(instance) - ? 'restarting soon' - : formatDistanceToNow(instance.autoRestartCooldownExpiration)} - ) - - )} - - ) : ( - N/A - )} - - - {instance.timeLastAutoRestarted ? ( - toLocaleDateTimeString(instance.timeLastAutoRestarted) - ) : ( - N/A - )} - - - - - - - - +
+ + +
) } - -type FormMetaProps = { - label: string - tip?: string - children: ReactNode -} - -const FormMeta = ({ label, tip, children }: FormMetaProps) => ( -
-
-
{label}
- {tip && {tip}} -
- {children} -
-) diff --git a/app/table/cells/DescriptionCell.tsx b/app/table/cells/DescriptionCell.tsx index cdcc6365be..448354abed 100644 --- a/app/table/cells/DescriptionCell.tsx +++ b/app/table/cells/DescriptionCell.tsx @@ -9,5 +9,7 @@ import { EmptyCell } from '~/table/cells/EmptyCell' import { Truncate } from '~/ui/lib/Truncate' -export const DescriptionCell = ({ text }: { text?: string }) => - text ? : +export type Props = { text?: string; maxLength?: number } + +export const DescriptionCell = ({ text, maxLength = 48 }: Props) => + text ? : diff --git a/mock-api/affinity-group.ts b/mock-api/affinity-group.ts index 6c2ee7955a..63627c27df 100644 --- a/mock-api/affinity-group.ts +++ b/mock-api/affinity-group.ts @@ -74,7 +74,8 @@ export const affinityGroupMemberLists: DbAffinityGroupMember[] = [ export const romulusRemus: Json = { id: 'c874bfbe-c896-48b1-b6f1-9a3dfb7fb7c9', name: 'romulus-remus', - description: 'Keep these two apart', + description: + 'Keep these two apart. and a bunch more words in the description. long long very long', failure_domain: 'sled', policy: 'fail', project_id: project.id, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 04be2d35f1..c83832978c 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1663,6 +1663,28 @@ export const handlers = makeHandlers({ ) return 204 }, + instanceAntiAffinityGroupList: ({ path, query }) => { + const instance = lookup.instance({ ...path, ...query }) + const antiAffinityGroups = db.antiAffinityGroups.filter((group) => + db.antiAffinityGroupMemberLists.some( + (member) => + member.anti_affinity_group_id === group.id && + member.anti_affinity_group_member.id === instance.id + ) + ) + return paginated(query, antiAffinityGroups) + }, + instanceAffinityGroupList: ({ path, query }) => { + const instance = lookup.instance({ ...path, ...query }) + const affinityGroups = db.affinityGroups.filter((group) => + db.affinityGroupMemberLists.some( + (member) => + member.affinity_group_id === group.id && + member.affinity_group_member.id === instance.id + ) + ) + return paginated(query, affinityGroups) + }, // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, @@ -1680,8 +1702,6 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - instanceAffinityGroupList: NotImplemented, - instanceAntiAffinityGroupList: NotImplemented, instanceSerialConsole: NotImplemented, instanceSerialConsoleStream: NotImplemented, instanceSshPublicKeyList: NotImplemented,