Skip to content
Draft
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
7 changes: 4 additions & 3 deletions app/components/ToastStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
*
* 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'
import { Toast } from '~/ui/lib/Toast'

export function ToastStack() {
const toasts = useToastStore((state) => state.toasts)
const prefersReducedMotion = useReducedMotion()

return (
<div
Expand All @@ -23,9 +24,9 @@
{toasts.map((toast) => (
<m.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
initial={prefersReducedMotion ? false : { opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
exit={prefersReducedMotion ? false : { opacity: 0, y: 20, scale: 0.95 }}

Check failure on line 29 in app/components/ToastStack.tsx

View workflow job for this annotation

GitHub Actions / ci

Type 'false | { opacity: number; y: number; scale: number; }' is not assignable to type 'VariantLabels | TargetAndTransition | undefined'.
transition={{ type: 'spring', duration: 0.2, bounce: 0 }}
>
<Toast
Expand Down
16 changes: 16 additions & 0 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
import { FileField } from '~/components/form/fields/FileField'
import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField'
import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField'
import { NumberField } from '~/components/form/fields/NumberField'
Expand Down Expand Up @@ -83,6 +84,7 @@ import { TipIcon } from '~/ui/lib/TipIcon'
import { Tooltip } from '~/ui/lib/Tooltip'
import { Wrap } from '~/ui/util/wrap'
import { ALL_ISH } from '~/util/consts'
import { cpuPlatformItems, type FormCpuPlatform } from '~/util/cpu-platform'
import { readBlobAsBase64 } from '~/util/file'
import { ipHasVersion } from '~/util/ip'
import { docLinks, links } from '~/util/links'
Expand Down Expand Up @@ -148,6 +150,9 @@ export type InstanceCreateInput = Assign<

// Selected floating IPs to attach on create.
floatingIps: NameOrId[]

// CPU platform preference
cpuPlatform: FormCpuPlatform
}
>

Expand Down Expand Up @@ -218,6 +223,7 @@ const baseDefaultValues: InstanceCreateInput = {
ephemeralIpPool: '',
assignEphemeralIp: false,
floatingIps: [],
cpuPlatform: 'none',
}

export async function clientLoader({ params }: LoaderFunctionArgs) {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -730,6 +737,15 @@ export default function CreateInstanceForm() {
/>
<FormDivider />
<Form.Heading id="advanced">Advanced</Form.Heading>
<ListboxField
control={control}
name="cpuPlatform"
label="CPU platform"
description="If a CPU platform is specified, the instance will only be placed on compatible hosts."
items={cpuPlatformItems}
className="max-w-lg"
disabled={isSubmitting}
/>
<FileField
id="user-data-input"
description={<UserDataDescription />}
Expand Down
98 changes: 98 additions & 0 deletions app/pages/project/instances/CpuPlatformCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={onSubmit}>
<CardBlock>
<CardBlock.Header title="CPU platform" />
<CardBlock.Body>
<ListboxField
control={form.control}
name="cpuPlatform"
label="Required CPU"
description="If a CPU platform is specified, the instance will only be placed on compatible hosts."
items={cpuPlatformItems}
className="max-w-lg"
required
/>
</CardBlock.Body>
<CardBlock.Footer>
<LearnMore doc={docLinks.cpuPlatform} />
<Button size="sm" type="submit" disabled={disableSubmit}>
Save
</Button>
</CardBlock.Footer>
</CardBlock>
</form>
)
}
2 changes: 2 additions & 0 deletions app/pages/project/instances/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }

Expand All @@ -30,6 +31,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function SettingsTab() {
return (
<div className="space-y-6">
<CpuPlatformCard />
<AntiAffinityCard />
<AutoRestartCard />
</div>
Expand Down
17 changes: 17 additions & 0 deletions app/util/cpu-platform.ts
Original file line number Diff line number Diff line change
@@ -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<FormCpuPlatform>[] = [
{ value: 'none', label: 'No requirement' },
{ value: 'amd_milan', label: 'AMD Milan' },
{ value: 'amd_turin', label: 'AMD Turin' },
]
5 changes: 5 additions & 0 deletions app/util/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions mock-api/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const base = {
auto_restart_enabled: true,
ncpus: 2,
memory: 4 * GiB,
cpu_platform: null,
}

export const instance: Json<Instance> = {
Expand Down
9 changes: 8 additions & 1 deletion mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,14 @@ export const handlers = makeHandlers({
const newInstance: Json<Api.Instance> = {
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(),
Expand Down
55 changes: 40 additions & 15 deletions test/e2e/instance-auto-restart.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand All @@ -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()
})
Loading
Loading