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')