diff --git a/app/components/ToastStack.tsx b/app/components/ToastStack.tsx index d2f94ebc3..f503b85d0 100644 --- a/app/components/ToastStack.tsx +++ b/app/components/ToastStack.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { AnimatePresence } from 'motion/react' +import { AnimatePresence, useReducedMotion } from 'motion/react' import * as m from 'motion/react-m' import { removeToast, useToastStore } from '~/stores/toast' @@ -13,6 +13,7 @@ import { Toast } from '~/ui/lib/Toast' export function ToastStack() { const toasts = useToastStore((state) => state.toasts) + const prefersReducedMotion = useReducedMotion() return (
( @@ -218,6 +223,7 @@ const baseDefaultValues: InstanceCreateInput = { ephemeralIpPool: '', assignEphemeralIp: false, floatingIps: [], + cpuPlatform: 'none', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -470,6 +476,7 @@ export default function CreateInstanceForm() { description: values.description, memory: instance.memory * GiB, ncpus: instance.ncpus, + cpuPlatform: values.cpuPlatform === 'none' ? null : values.cpuPlatform, disks: values.otherDisks.map( (d): InstanceDiskAttachment => d.action === 'attach' @@ -730,6 +737,15 @@ export default function CreateInstanceForm() { /> Advanced + } diff --git a/app/pages/project/instances/CpuPlatformCard.tsx b/app/pages/project/instances/CpuPlatformCard.tsx new file mode 100644 index 000000000..ed19ac112 --- /dev/null +++ b/app/pages/project/instances/CpuPlatformCard.tsx @@ -0,0 +1,98 @@ +/* + * 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 { match } from 'ts-pattern' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } 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 { cpuPlatformItems, type FormCpuPlatform } from '~/util/cpu-platform' +import { docLinks } from '~/util/links' + +type FormValues = { + cpuPlatform: FormCpuPlatform +} + +export function CpuPlatformCard() { + const instanceSelector = useInstanceSelector() + + const { data: instance } = usePrefetchedQuery( + q(api.instanceView, { + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + }) + ) + + const instanceUpdate = useApiMutation(api.instanceUpdate, { + onSuccess() { + queryClient.invalidateEndpoint('instanceView') + addToast({ content: 'CPU platform preference updated' }) + }, + onError(err) { + addToast({ + title: 'Could not update CPU platform preference', + content: err.message, + variant: 'error', + }) + }, + }) + + const cpuPlatform: FormCpuPlatform = instance.cpuPlatform || 'none' + const defaultValues: FormValues = { cpuPlatform } + + const form = useForm({ defaultValues }) + + const disableSubmit = form.watch('cpuPlatform') === cpuPlatform + + const onSubmit = form.handleSubmit((values) => { + instanceUpdate.mutate({ + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + body: { + cpuPlatform: match(values.cpuPlatform) + .with('none', () => null) + .with('amd_milan', () => 'amd_milan' as const) + .with('amd_turin', () => 'amd_turin' as const) + .exhaustive(), + ncpus: instance.ncpus, + memory: instance.memory, + autoRestartPolicy: instance.autoRestartPolicy || null, + bootDisk: instance.bootDiskId || null, + }, + }) + }) + + return ( +
+ + + + + + + + + + +
+ ) +} diff --git a/app/pages/project/instances/SettingsTab.tsx b/app/pages/project/instances/SettingsTab.tsx index 4f80a321a..1cb2c3fcd 100644 --- a/app/pages/project/instances/SettingsTab.tsx +++ b/app/pages/project/instances/SettingsTab.tsx @@ -15,6 +15,7 @@ import { getInstanceSelector } from '~/hooks/use-params' import { AntiAffinityCard, instanceAntiAffinityGroups } from './AntiAffinityCard' import { AutoRestartCard } from './AutoRestartCard' +import { CpuPlatformCard } from './CpuPlatformCard' export const handle = { crumb: 'Settings' } @@ -30,6 +31,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export default function SettingsTab() { return (
+
diff --git a/app/util/cpu-platform.ts b/app/util/cpu-platform.ts new file mode 100644 index 000000000..77d465588 --- /dev/null +++ b/app/util/cpu-platform.ts @@ -0,0 +1,17 @@ +/* + * 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 type { InstanceCpuPlatform } from '~/api' +import type { ListboxItem } from '~/ui/lib/Listbox' + +export type FormCpuPlatform = InstanceCpuPlatform | 'none' + +export const cpuPlatformItems: ListboxItem[] = [ + { value: 'none', label: 'No requirement' }, + { value: 'amd_milan', label: 'AMD Milan' }, + { value: 'amd_turin', label: 'AMD Turin' }, +] diff --git a/app/util/links.ts b/app/util/links.ts index 41fc043c8..08365a54d 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -76,6 +76,11 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/managing-instances#_update_instances', linkText: 'Instance Auto-Restart', }, + cpuPlatform: { + // TODO: Add a proper CPU Platform section to https://docs.oxide.computer/guides/deploying-workloads + href: 'https://docs.oxide.computer/api/instance_create', + linkText: 'CPU Platforms', + }, instanceActions: { href: 'https://docs.oxide.computer/guides/managing-instances', linkText: 'Instance Actions', diff --git a/mock-api/instance.ts b/mock-api/instance.ts index 8ddfbcaa7..c4d58abf9 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -21,6 +21,7 @@ const base = { auto_restart_enabled: true, ncpus: 2, memory: 4 * GiB, + cpu_platform: null, } export const instance: Json = { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 042e85d9d..1be23ad19 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -711,7 +711,14 @@ export const handlers = makeHandlers({ const newInstance: Json = { id: instanceId, project_id: project.id, - ...R.pick(body, ['name', 'description', 'hostname', 'memory', 'ncpus']), + ...R.pick(body, [ + 'name', + 'description', + 'hostname', + 'memory', + 'ncpus', + 'cpu_platform', + ]), ...getTimestamps(), run_state: 'creating', time_run_state_updated: new Date().toISOString(), diff --git a/test/e2e/instance-auto-restart.e2e.ts b/test/e2e/instance-auto-restart.e2e.ts index 37a171154..6a631b088 100644 --- a/test/e2e/instance-auto-restart.e2e.ts +++ b/test/e2e/instance-auto-restart.e2e.ts @@ -27,13 +27,17 @@ test('Auto restart policy on failed instance', async ({ page }) => { await expect(page.getByText(/Cooldown expiration.+, 202\d.+\(5 minutes\)/)).toBeVisible() await expect(page.getByText(/Last auto-restarted.+, 202\d/)).toBeVisible() - const save = page.getByRole('button', { name: 'Save' }) - await expect(save).toBeDisabled() + // Scope to the Auto-restart card to avoid ambiguity with other Save buttons + const autoRestartSection = page.locator('form', { + has: page.getByRole('heading', { name: 'Auto-restart' }), + }) + const save = autoRestartSection.getByRole('button', { name: 'Save' }) + const policyListbox = autoRestartSection.getByRole('button', { name: 'Policy' }) - const policyListbox = page.getByRole('button', { name: 'Policy' }) + await expect(save).toBeDisabled() await expect(policyListbox).toContainText('Default') - await page.getByRole('button', { name: 'Policy' }).click() + await policyListbox.click() await page.getByRole('option', { name: 'Never' }).click() await save.click() @@ -56,14 +60,17 @@ test('Auto restart policy on running instance', async ({ page }) => { await expect(page.getByText('Cooldown expirationN/A')).toBeVisible() await expect(page.getByText('Last auto-restartedN/A')).toBeVisible() - // await expect(page.getByRole('button', { name: 'Policy' })) - const save = page.getByRole('button', { name: 'Save' }) - await expect(save).toBeDisabled() + // Scope to the Auto-restart card to avoid ambiguity with other Save buttons + const autoRestartSection = page.locator('form', { + has: page.getByRole('heading', { name: 'Auto-restart' }), + }) + const save = autoRestartSection.getByRole('button', { name: 'Save' }) + const policyListbox = autoRestartSection.getByRole('button', { name: 'Policy' }) - const policyListbox = page.getByRole('button', { name: 'Policy' }) + await expect(save).toBeDisabled() await expect(policyListbox).toContainText('Default') - await page.getByRole('button', { name: 'Policy' }).click() + await policyListbox.click() await page.getByRole('option', { name: 'Never' }).click() await save.click() @@ -92,9 +99,15 @@ test('Auto restart popover, restarting soon', async ({ page }) => { ).toBeVisible() await expect(page.getByText(/Last auto-restarted.+, 202\d/)).toBeVisible() - const policyListbox = page.getByRole('button', { name: 'Policy' }) + // Scope to the Auto-restart card to avoid ambiguity with other Save buttons + const autoRestartSection = page.locator('form', { + has: page.getByRole('heading', { name: 'Auto-restart' }), + }) + const policyListbox = autoRestartSection.getByRole('button', { name: 'Policy' }) + const save = autoRestartSection.getByRole('button', { name: 'Save' }) + await expect(policyListbox).toContainText('Default') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(save).toBeDisabled() }) test('Auto restart popover, policy never', async ({ page }) => { @@ -114,9 +127,15 @@ test('Auto restart popover, policy never', async ({ page }) => { await expect(page.getByText(/Cooldown expiration.+, 202\d.+/)).toBeVisible() await expect(page.getByText(/Last auto-restarted.+, 202\d/)).toBeVisible() - const policyListbox = page.getByRole('button', { name: 'Policy' }) + // Scope to the Auto-restart card to avoid ambiguity with other Save buttons + const autoRestartSection = page.locator('form', { + has: page.getByRole('heading', { name: 'Auto-restart' }), + }) + const policyListbox = autoRestartSection.getByRole('button', { name: 'Policy' }) + const save = autoRestartSection.getByRole('button', { name: 'Save' }) + await expect(policyListbox).toContainText('Never') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(save).toBeDisabled() }) test('Auto restart popover, cooled, policy never, cooled', async ({ page }) => { @@ -137,7 +156,13 @@ test('Auto restart popover, cooled, policy never, cooled', async ({ page }) => { await expect(page.getByText('restarting soon')).toBeHidden() await expect(page.getByText(/Last auto-restarted.+, 202\d/)).toBeVisible() - const policyListbox = page.getByRole('button', { name: 'Policy' }) + // Scope to the Auto-restart card to avoid ambiguity with other Save buttons + const autoRestartSection = page.locator('form', { + has: page.getByRole('heading', { name: 'Auto-restart' }), + }) + const policyListbox = autoRestartSection.getByRole('button', { name: 'Policy' }) + const save = autoRestartSection.getByRole('button', { name: 'Save' }) + await expect(policyListbox).toContainText('Never') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(save).toBeDisabled() }) diff --git a/test/e2e/instance-cpu-platform.e2e.ts b/test/e2e/instance-cpu-platform.e2e.ts new file mode 100644 index 000000000..204ed655a --- /dev/null +++ b/test/e2e/instance-cpu-platform.e2e.ts @@ -0,0 +1,59 @@ +/* + * 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 { expectToast } from './utils' + +test('can update CPU platform preference', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + + // go to settings tab + await page.getByRole('tab', { name: 'settings' }).click() + + // Scope to the CPU platform card to avoid ambiguity with other Save buttons + const cpuPlatformSection = page.locator('form', { + has: page.getByRole('heading', { name: 'CPU platform' }), + }) + const save = cpuPlatformSection.getByRole('button', { name: 'Save' }) + const platformListbox = cpuPlatformSection.getByRole('button', { name: 'Required CPU' }) + + await expect(save).toBeDisabled() + + // verify initial state is "No requirement" + await expect(platformListbox).toContainText('No requirement') + + // change to AMD Milan + await platformListbox.click() + await page.getByRole('option', { name: 'AMD Milan' }).click() + await expect(save).toBeEnabled() + await save.click() + + await expectToast(page, 'CPU platform preference updated') + await expect(platformListbox).toContainText('AMD Milan') + await expect(save).toBeDisabled() + + // change to AMD Turin + await platformListbox.click() + await page.getByRole('option', { name: 'AMD Turin' }).click() + await expect(save).toBeEnabled() + await save.click() + + await expectToast(page, 'CPU platform preference updated') + await expect(platformListbox).toContainText('AMD Turin') + await expect(save).toBeDisabled() + + // change back to No requirement + await platformListbox.click() + await page.getByRole('option', { name: 'No requirement' }).click() + await expect(save).toBeEnabled() + await save.click() + + await expectToast(page, 'CPU platform preference updated') + await expect(platformListbox).toContainText('No requirement') + await expect(save).toBeDisabled() +}) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ebdbcb632..282dca06b 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -686,6 +686,38 @@ test('Validate CPU and RAM', async ({ page }) => { await expect(memMsg).toBeVisible() }) +test('can create instance with CPU platform preference', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'cpu-platform-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + + // Select CPU platform + const cpuPlatformDropdown = page.getByRole('button', { name: 'CPU platform' }) + await expect(cpuPlatformDropdown).toContainText('No requirement') + await cpuPlatformDropdown.click() + await page.getByRole('option', { name: 'AMD Milan' }).click() + await expect(cpuPlatformDropdown).toContainText('AMD Milan') + + await page.getByRole('button', { name: 'Create instance' }).click() + await closeToast(page) + + // Wait for navigation to storage tab + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + + // Navigate to settings tab to verify CPU platform was set + await page.getByRole('tab', { name: 'settings' }).click() + + // Scope to the CPU platform card to verify the platform was set + const cpuPlatformSection = page.locator('form', { + has: page.getByRole('heading', { name: 'CPU platform' }), + }) + const settingsCpuPlatformDropdown = cpuPlatformSection.getByRole('button', { + name: 'Required CPU', + }) + await expect(settingsCpuPlatformDropdown).toContainText('AMD Milan') +}) + test('create instance with IPv6-only networking', async ({ page }) => { await page.goto('/projects/mock-project/instances-new')