diff --git a/OMICRON_VERSION b/OMICRON_VERSION index e91b1c476..c5fa224a3 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -985304a607b16191bdc35e0ea87fa522d925b514 +66cbaf0d1b6d69fe170c61931acad909d177129b diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 7e88135aa..593d34b53 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -2505,6 +2505,8 @@ This policy determines whether the instance should be automatically restarted by cpuPlatform?: InstanceCpuPlatform | null /** Human-readable free-form text about a resource */ description: string + /** When true, this instance has opted in to jumbo frames (8500 byte MTU) on its primary network interface. The effective MTU also depends on the fleet-wide jumbo-frames opt-in; if that is disabled, the primary interface uses the default MTU regardless of this value. Changes only take effect on the next instance restart. */ + enableJumboFrames: boolean /** RFC1035-compliant hostname for the instance */ hostname: string /** Unique, immutable, system-controlled identifier for each resource */ @@ -2685,6 +2687,8 @@ Disk attachments of type "create" will be created, while those of type "attach" The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. When boot_disk is specified it will count against the disk attachment limit. */ disks?: InstanceDiskAttachment[] + /** Enable jumbo frames (8500 byte MTU) on the instance's primary OPTE interface. Requires the fleet-wide jumbo-frames opt-in to be enabled by an operator; otherwise this field must be `false`. Changes only take effect on the next instance restart. */ + enableJumboFrames?: boolean /** The external IP addresses provided to this instance. By default, all instances have outbound connectivity, but no inbound connectivity. These external addresses can be used to provide a fixed, known IP address for making inbound connections to the instance. */ @@ -2855,6 +2859,8 @@ An instance that does not have a boot disk set will use the boot options specifi bootDisk: NameOrId | null /** The CPU platform to be used for this instance. If this is `null`, the instance requires no particular CPU platform; when it is started the instance will have the most general CPU platform supported by the sled it is initially placed on. */ cpuPlatform: InstanceCpuPlatform | null + /** Update the per-instance jumbo-frames opt-in. Setting this to `true` requires the fleet-wide jumbo-frames opt-in to be enabled. Changes only take effect on the next instance restart. */ + enableJumboFrames?: boolean | null /** The amount of RAM (in bytes) to be allocated to the instance */ memory: ByteCount /** Multicast groups this instance should join. @@ -5012,6 +5018,22 @@ export type SwitchResultsPage = { nextPage?: string | null } +/** + * Fleet-wide networking settings. Only fleet admins may view or modify these settings. + */ +export type SystemNetworkingSettings = { + /** When true, end users may opt in to jumbo frames (8500 byte MTU) on the primary interface of an instance. When false, instance-level opt-in is ignored and OPTE ports are created with the default MTU. */ + externalJumboFramesOptInEnabled: boolean +} + +/** + * Parameters for updating the fleet-wide networking settings. + */ +export type SystemNetworkingSettingsUpdate = { + /** Toggle the fleet-wide external jumbo-frames opt-in. Omit to leave the current value unchanged. */ + externalJumboFramesOptInEnabled?: boolean | null +} + /** * View of a system software target release */ @@ -7513,7 +7535,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026052000.0.0' + apiVersion = '2026060100.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -11092,6 +11114,30 @@ export class Api { ...params, }) }, + /** + * Fetch fleet-wide networking settings + */ + systemNetworkingSettingsView: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/networking/settings`, + method: 'GET', + ...params, + }) + }, + /** + * Update fleet-wide networking settings + */ + systemNetworkingSettingsUpdate: ( + { body }: { body: SystemNetworkingSettingsUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/networking/settings`, + method: 'PUT', + body, + ...params, + }) + }, /** * List switch port settings */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 3648028c0..36f07d037 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -985304a607b16191bdc35e0ea87fa522d925b514 +66cbaf0d1b6d69fe170c61931acad909d177129b diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index d888fea05..cae57d8e4 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1531,6 +1531,17 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/networking/settings` */ + systemNetworkingSettingsView: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/networking/settings` */ + systemNetworkingSettingsUpdate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/networking/switch-port-settings` */ networkingSwitchPortSettingsList: (params: { query: Api.NetworkingSwitchPortSettingsListQueryParams @@ -3464,6 +3475,18 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.get( + '/v1/system/networking/settings', + handler(handlers['systemNetworkingSettingsView'], null, null) + ), + http.put( + '/v1/system/networking/settings', + handler( + handlers['systemNetworkingSettingsUpdate'], + null, + schema.SystemNetworkingSettingsUpdate + ) + ), http.get( '/v1/system/networking/switch-port-settings', handler( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 1985c8d2a..9402bb7e9 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -2349,6 +2349,7 @@ export const Instance = z.preprocess( bootDiskId: z.uuid().nullable().optional(), cpuPlatform: InstanceCpuPlatform.nullable().optional(), description: z.string(), + enableJumboFrames: SafeBoolean, hostname: z.string(), id: z.uuid(), memory: ByteCount, @@ -2500,6 +2501,7 @@ export const InstanceCreate = z.preprocess( cpuPlatform: InstanceCpuPlatform.nullable().default(null), description: z.string(), disks: InstanceDiskAttachment.array().default([]), + enableJumboFrames: SafeBoolean.default(false), externalIps: ExternalIpCreate.array().default([]), hostname: Hostname, memory: ByteCount, @@ -2644,6 +2646,7 @@ export const InstanceUpdate = z.preprocess( autoRestartPolicy: InstanceAutoRestartPolicy.nullable(), bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), + enableJumboFrames: SafeBoolean.nullable().default(null), memory: ByteCount, multicastGroups: MulticastGroupJoinSpec.array().nullable().default(null), ncpus: InstanceCpuCount, @@ -4546,6 +4549,22 @@ export const SwitchResultsPage = z.preprocess( z.object({ items: Switch.array(), nextPage: z.string().nullable().optional() }) ) +/** + * Fleet-wide networking settings. Only fleet admins may view or modify these settings. + */ +export const SystemNetworkingSettings = z.preprocess( + processResponseBody, + z.object({ externalJumboFramesOptInEnabled: SafeBoolean }) +) + +/** + * Parameters for updating the fleet-wide networking settings. + */ +export const SystemNetworkingSettingsUpdate = z.preprocess( + processResponseBody, + z.object({ externalJumboFramesOptInEnabled: SafeBoolean.nullable().default(null) }) +) + /** * View of a system software target release */ @@ -7817,6 +7836,22 @@ export const NetworkingLoopbackAddressDeleteParams = z.preprocess( }) ) +export const SystemNetworkingSettingsViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + +export const SystemNetworkingSettingsUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + export const NetworkingSwitchPortSettingsListParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/pages/project/instances/JumboFramesCard.tsx b/app/pages/project/instances/JumboFramesCard.tsx new file mode 100644 index 000000000..34e5d2cd6 --- /dev/null +++ b/app/pages/project/instances/JumboFramesCard.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 { useForm } from 'react-hook-form' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' +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 { docLinks } from '~/util/links' + +type FormValues = { + enableJumboFrames: boolean +} + +export function JumboFramesCard() { + const instanceSelector = useInstanceSelector() + + const { data: instance } = usePrefetchedQuery( + q(api.instanceView, { + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + }) + ) + + const instanceUpdate = useApiMutation(api.instanceUpdate, { + onSuccess(instance) { + const verb = instance.enableJumboFrames ? 'enabled' : 'disabled' + queryClient.invalidateEndpoint('instanceView') + addToast({ content: `Jumbo frames ${verb} for this instance` }) + }, + onError(err) { + addToast({ + title: 'Could not update jumbo frames setting', + content: err.message, + variant: 'error', + }) + }, + }) + + const defaultValues: FormValues = { enableJumboFrames: instance.enableJumboFrames } + const form = useForm({ defaultValues }) + + const disableSubmit = form.watch('enableJumboFrames') === instance.enableJumboFrames + + const onSubmit = form.handleSubmit((values) => { + instanceUpdate.mutate({ + path: { instance: instanceSelector.instance }, + query: { project: instanceSelector.project }, + body: { + enableJumboFrames: values.enableJumboFrames, + autoRestartPolicy: instance.autoRestartPolicy || null, + ncpus: instance.ncpus, + memory: instance.memory, + cpuPlatform: instance.cpuPlatform || null, + bootDisk: instance.bootDiskId || null, + }, + }) + }) + + return ( +
+ + + + + Enable jumbo frames + + {/* fleet opt-in is fleet-admin-only to read, so we don't gate the + checkbox on it; the API rejects the update if it's disabled */} +

+ Can only be used if jumbo frames are enabled system-wide by the operator. Takes + effect on next restart. +

+
+ + + + +
+
+ ) +} diff --git a/app/pages/project/instances/SettingsTab.tsx b/app/pages/project/instances/SettingsTab.tsx index 4f80a321a..6aca66a19 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 { JumboFramesCard } from './JumboFramesCard' export const handle = { crumb: 'Settings' } @@ -32,6 +33,7 @@ export default function SettingsTab() {
+
) } diff --git a/app/util/links.ts b/app/util/links.ts index 7c9fcfbf5..6f3c045b8 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -84,6 +84,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/managing-instances#_update_instances', linkText: 'Instance Auto-Restart', }, + jumboFrames: { + href: 'https://docs.oxide.computer/guides/configuring-guest-networking#jumbo-frames', + linkText: 'Jumbo Frames', + }, 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 db7102845..5381ee33f 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -19,6 +19,7 @@ const base = { time_modified: new Date().toISOString(), time_run_state_updated: new Date().toISOString(), auto_restart_enabled: true, + enable_jumbo_frames: false, ncpus: 2, memory: 4 * GiB, } diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 9986205ed..6b3d524cd 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -644,6 +644,7 @@ const initDb = { sshKeys: [...mock.sshKeys], tufRepos: [...mock.tufRepos], updateStatus: mock.updateStatus, + systemNetworkingSettings: { external_jumbo_frames_opt_in_enabled: false }, users: [...mock.users], vpcFirewallRules: [...mock.firewallRules], vpcRouters: [...mock.vpcRouters], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index d5515b895..fc8c6e9ff 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -58,6 +58,7 @@ import { internalError, invalidRequest, ipRangeLen, + jumboFramesOptIn, NotImplemented, paginated, randomHex, @@ -545,7 +546,7 @@ export const handlers = makeHandlers({ const instances = db.instances.filter((i) => i.project_id === project.id) return paginated(query, instances) }, - instanceCreate({ body, query }) { + instanceCreate({ body, query, cookies }) { const project = lookup.project(query) if (body.name === 'no-default-pool') { @@ -803,6 +804,14 @@ export const handlers = makeHandlers({ } }) + // Requiring jumbo frames on a new instance requires the fleet-wide opt-in. + // https://github.com/oxidecomputer/omicron/blob/9c8d3c3/nexus/src/app/instance.rs#L709-L721 + if (body.enable_jumbo_frames && !jumboFramesOptIn(cookies)) { + throw invalidRequest( + 'enable_jumbo_frames may only be set on an instance when the fleet-wide jumbo-frames opt-in is enabled by a fleet administrator' + ) + } + const newInstance: Json = { id: instanceId, project_id: project.id, @@ -812,6 +821,7 @@ export const handlers = makeHandlers({ time_run_state_updated: new Date().toISOString(), boot_disk_id: bootDiskId, auto_restart_enabled: true, + enable_jumbo_frames: body.enable_jumbo_frames ?? false, } if (body.start) { @@ -829,7 +839,7 @@ export const handlers = makeHandlers({ return json(newInstance, { status: 201 }) }, instanceView: ({ path, query }) => lookup.instance({ ...path, ...query }), - instanceUpdate({ path, query, body }) { + instanceUpdate({ path, query, body, cookies }) { const instance = lookup.instance({ ...path, ...query }) if (instance.name === 'instance-update-error') { @@ -891,6 +901,19 @@ export const handlers = makeHandlers({ instance.auto_restart_policy = body.auto_restart_policy instance.cpu_platform = body.cpu_platform + // Opting in to jumbo frames requires the fleet-wide opt-in. Opting out + // (false) or omitting (null) is always allowed. + // https://github.com/oxidecomputer/omicron/blob/9c8d3c3/nexus/src/app/instance.rs#L579-L591 + if (body.enable_jumbo_frames === true && !jumboFramesOptIn(cookies)) { + throw invalidRequest( + 'enable_jumbo_frames may only be set to true when the fleet-wide jumbo-frames opt-in is enabled by a fleet administrator' + ) + } + // null/omitted leaves the per-instance jumbo frames opt-in unchanged + if (typeof body.enable_jumbo_frames === 'boolean') { + instance.enable_jumbo_frames = body.enable_jumbo_frames + } + // We depart here from nexus in that nexus does both of the following // calculations at view time (when converting model to view). We can't // do that/don't need because our mock DB stores and returns the view @@ -1298,6 +1321,19 @@ export const handlers = makeHandlers({ return 204 }, + systemNetworkingSettingsView: ({ cookies }) => { + requireFleetViewer(cookies) + return { external_jumbo_frames_opt_in_enabled: jumboFramesOptIn(cookies) } + }, + systemNetworkingSettingsUpdate: ({ body, cookies }) => { + requireFleetAdmin(cookies) + // omit leaves the current value unchanged + if (typeof body.external_jumbo_frames_opt_in_enabled === 'boolean') { + db.systemNetworkingSettings.external_jumbo_frames_opt_in_enabled = + body.external_jumbo_frames_opt_in_enabled + } + return db.systemNetworkingSettings + }, systemIpPoolSiloUpdate: ({ path, body, cookies }) => { requireFleetAdmin(cookies) const ipPoolSilo = lookup.ipPoolSiloLink(path) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index f3f88a2f1..691ca42a2 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -348,6 +348,20 @@ export function currentUser(cookies: Record): Json { return user } +/** + * The fleet-wide jumbo-frames opt-in is a single fleet-level flag, so we can't + * vary it per silo and there's no operator UI to flip it. To exercise both the + * enabled and disabled paths in e2e without page.route, one designated test + * user sees the opt-in as enabled. Everyone else sees the real db value. + */ +const JUMBO_OPT_IN_USER = 'Hans Jonas' +export function jumboFramesOptIn(cookies: Record): boolean { + return ( + db.systemNetworkingSettings.external_jumbo_frames_opt_in_enabled || + currentUser(cookies).display_name === JUMBO_OPT_IN_USER + ) +} + /** * Given a role A, get a list of the roles (including A) that confer *at least* * the powers of A. diff --git a/package-lock.json b/package-lock.json index e331302f1..52502e032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.14.0", + "@oxide/openapi-gen-ts": "~0.14.1", "@playwright/test": "^1.58.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -1368,9 +1368,9 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.0.tgz", - "integrity": "sha512-L7n/3Ox8UTgDwdDvqCr+PekXcTboq5HQhdEawZWD8ct9QkycCciZoClLWApz/B5T9eiQjZq/5nqyE5JJqqw6nw==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.1.tgz", + "integrity": "sha512-czWxYrFjk3jQFYTkd5FWdTbPFmF+KegCEMWTH5FeMxgDgHEk1EySQdpxGxinBnC2gTRXc+6H/hlS0CjXZwnroA==", "dev": true, "license": "MPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 120620f7a..1dab649df 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ }, "devDependencies": { "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.14.0", + "@oxide/openapi-gen-ts": "~0.14.1", "@playwright/test": "^1.58.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", diff --git a/test/e2e/instance-auto-restart.e2e.ts b/test/e2e/instance-auto-restart.e2e.ts index 37a171154..b7d20e4ab 100644 --- a/test/e2e/instance-auto-restart.e2e.ts +++ b/test/e2e/instance-auto-restart.e2e.ts @@ -5,10 +5,20 @@ * * Copyright Oxide Computer Company */ +import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { expectToast } from './utils' +// The settings tab has several cards each with their own Save button, so scope +// to the auto-restart form via its unique card title. +function autoRestartSave(page: Page) { + return page + .locator('form') + .filter({ hasText: 'Auto-restart' }) + .getByRole('button', { name: 'Save' }) +} + test('Auto restart policy on failed instance', async ({ page }) => { await page.goto('/projects/mock-project/instances/you-fail') @@ -27,7 +37,7 @@ 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' }) + const save = autoRestartSave(page) await expect(save).toBeDisabled() const policyListbox = page.getByRole('button', { name: 'Policy' }) @@ -57,7 +67,7 @@ test('Auto restart policy on running instance', async ({ page }) => { await expect(page.getByText('Last auto-restartedN/A')).toBeVisible() // await expect(page.getByRole('button', { name: 'Policy' })) - const save = page.getByRole('button', { name: 'Save' }) + const save = autoRestartSave(page) await expect(save).toBeDisabled() const policyListbox = page.getByRole('button', { name: 'Policy' }) @@ -94,7 +104,7 @@ test('Auto restart popover, restarting soon', async ({ page }) => { const policyListbox = page.getByRole('button', { name: 'Policy' }) await expect(policyListbox).toContainText('Default') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(autoRestartSave(page)).toBeDisabled() }) test('Auto restart popover, policy never', async ({ page }) => { @@ -116,7 +126,7 @@ test('Auto restart popover, policy never', async ({ page }) => { const policyListbox = page.getByRole('button', { name: 'Policy' }) await expect(policyListbox).toContainText('Never') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(autoRestartSave(page)).toBeDisabled() }) test('Auto restart popover, cooled, policy never, cooled', async ({ page }) => { @@ -139,5 +149,5 @@ test('Auto restart popover, cooled, policy never, cooled', async ({ page }) => { const policyListbox = page.getByRole('button', { name: 'Policy' }) await expect(policyListbox).toContainText('Never') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(autoRestartSave(page)).toBeDisabled() }) diff --git a/test/e2e/instance-jumbo-frames.e2e.ts b/test/e2e/instance-jumbo-frames.e2e.ts new file mode 100644 index 000000000..7819ce24d --- /dev/null +++ b/test/e2e/instance-jumbo-frames.e2e.ts @@ -0,0 +1,60 @@ +/* + * 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 { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +import { expectToast, getPageAsUser } from './utils' + +// The jumbo-frames opt-in is fleet-wide and there's no operator UI to flip it, +// so the mock keys it off the logged-in user: the default user sees it disabled, +// 'Hans Jonas' sees it enabled. See jumboFramesOptIn in mock-api/msw/util.ts. + +// The Settings tab has several cards each with their own Save button, so scope +// to the jumbo frames form via its unique checkbox. +function jumboForm(page: Page) { + const checkbox = page.getByRole('checkbox', { name: 'Enable jumbo frames' }) + const form = page.locator('form').filter({ has: checkbox }) + return { checkbox, save: form.getByRole('button', { name: 'Save' }) } +} + +test('Jumbo frames update rejected when fleet opt-in disabled', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + await page.getByRole('tab', { name: 'settings' }).click() + + const { checkbox, save } = jumboForm(page) + await expect(checkbox).not.toBeChecked() + await expect(save).toBeDisabled() + + await checkbox.check() + await expect(save).toBeEnabled() + await save.click() + + await expectToast(page, 'Could not update jumbo frames setting') +}) + +test('Jumbo frames can be toggled when fleet opt-in enabled', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Hans Jonas') + await page.goto('/projects/mock-project/instances/db1') + await page.getByRole('tab', { name: 'settings' }).click() + + const { checkbox, save } = jumboForm(page) + await expect(checkbox).not.toBeChecked() + await expect(save).toBeDisabled() + + await checkbox.check() + await save.click() + await expectToast(page, 'Jumbo frames enabled for this instance') + await expect(checkbox).toBeChecked() + await expect(save).toBeDisabled() + + // and back off again + await checkbox.uncheck() + await save.click() + await expectToast(page, 'Jumbo frames disabled for this instance') + await expect(checkbox).not.toBeChecked() +})