diff --git a/app/components/form/fields/NumberField.tsx b/app/components/form/fields/NumberField.tsx index 37f65c8335..9ec04177d2 100644 --- a/app/components/form/fields/NumberField.tsx +++ b/app/components/form/fields/NumberField.tsx @@ -45,7 +45,7 @@ export function NumberField< )} {/* passing the generated id is very important for a11y */} - + ) } @@ -80,7 +80,18 @@ export const NumberFieldInner = < const { field, fieldState: { error }, - } = useController({ name, control, rules: { required, validate } }) + } = useController({ + name, + control, + rules: { + required, + // it seems we need special logic to enforce required on NaN + validate(value, values) { + if (required && Number.isNaN(value)) return `${label} is required` + return validate?.(value, values) + }, + }, + }) return ( <> diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index f1a5fc5a00..8e34dd0727 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -47,10 +47,6 @@ const defaultValues: SiloCreateFormValues = { }, } -function validateQuota(value: number) { - if (value < 0) return 'Must be at least 0' -} - export function CreateSiloSideModalForm() { const navigate = useNavigate() const queryClient = useApiQueryClient() @@ -124,7 +120,6 @@ export function CreateSiloSideModalForm() { name="quotas.cpus" required units="vCPUs" - validate={validateQuota} /> { const { silo } = getSiloSelector(params) await Promise.all([ apiQueryClient.prefetchQuery('siloView', { path: { silo } }), + apiQueryClient.prefetchQuery('siloUtilizationView', { path: { silo } }), apiQueryClient.prefetchQuery('siloIdentityProviderList', { query: { silo, limit: PAGE_SIZE }, }), @@ -85,6 +87,7 @@ export function SiloPage() { Identity Providers IP Pools + Quotas Fleet roles @@ -93,6 +96,9 @@ export function SiloPage() { + + + {/* TODO: better empty state explaining that no roles are mapped so nothing will happen */} {roleMapPairs.length === 0 ? ( diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx new file mode 100644 index 0000000000..14df8fbb74 --- /dev/null +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -0,0 +1,172 @@ +/* + * 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 { useState } from 'react' +import { useForm } from 'react-hook-form' + +import { + apiQueryClient, + useApiMutation, + usePrefetchedApiQuery, + type SiloQuotasUpdate, +} from '~/api' +import { NumberField } from '~/components/form/fields/NumberField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { useSiloSelector } from '~/hooks/use-params' +import { Button } from '~/ui/lib/Button' +import { Message } from '~/ui/lib/Message' +import { Table } from '~/ui/lib/Table' +import { classed } from '~/util/classed' +import { links } from '~/util/links' +import { bytesToGiB, GiB } from '~/util/units' + +const Unit = classed.span`ml-1 text-tertiary` + +export function SiloQuotasTab() { + const { silo } = useSiloSelector() + const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', { + path: { silo: silo }, + }) + + const { allocated: quotas, provisioned } = utilization + + const [editing, setEditing] = useState(false) + + return ( + <> + + + + Resource + Provisioned + Quota + + + + + CPU + + {provisioned.cpus} vCPUs + + + {quotas.cpus} vCPUs + + + + Memory + + {bytesToGiB(provisioned.memory)} GiB + + + {bytesToGiB(quotas.memory)} GiB + + + + Storage + + {bytesToGiB(provisioned.storage)} GiB + + + {bytesToGiB(quotas.storage)} GiB + + + +
+
+ +
+ {editing && setEditing(false)} />} + + ) +} + +function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { + const { silo } = useSiloSelector() + const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', { + path: { silo: silo }, + }) + const quotas = utilization.allocated + + // required because we need to rule out undefined because NumberField hates that + const defaultValues: Required = { + cpus: quotas.cpus, + memory: bytesToGiB(quotas.memory), + storage: bytesToGiB(quotas.storage), + } + + const form = useForm({ defaultValues }) + + const updateQuotas = useApiMutation('siloQuotasUpdate', { + onSuccess() { + apiQueryClient.invalidateQueries('siloUtilizationView') + onDismiss() + }, + }) + + return ( + + updateQuotas.mutate({ + body: { + cpus, + memory: memory * GiB, + // TODO: we use GiB on instance create but TiB on utilization. HM + storage: storage * GiB, + }, + path: { silo }, + }) + } + loading={updateQuotas.isPending} + submitError={updateQuotas.error} + > + } variant="info" /> + + + + + + ) +} + +function LearnMore() { + return ( + <> + If a quota is set below the amount currently in use, users will not be able to + provision resources. Learn more about quotas in the{' '} + + Silos + {' '} + guide. + + ) +} diff --git a/app/util/links.ts b/app/util/links.ts index 587a971753..a29cc91682 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -25,6 +25,8 @@ export const links = { quickStart: 'https://docs.oxide.computer/guides/quickstart', routersDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking#_custom_routers', + siloQuotasDocs: + 'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management', sledDocs: 'https://docs.oxide.computer/guides/architecture/service-processors#_server_sled', snapshotsDocs: diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index c271f0d197..4458dc4c17 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -284,6 +284,12 @@ export const lookup = { if (!silo) throw notFoundErr(`silo '${id}'`) return silo }, + siloQuotas(params: PP.Silo): Json { + const silo = lookup.silo(params) + const quotas = db.siloQuotas.find((q) => q.silo_id === silo.id) + if (!quotas) throw internalError(`Silo ${silo.name} has no quotas`) + return quotas + }, sled({ sledId: id }: PP.Sled): Json { if (!id) throw notFoundErr('sled not specified') return lookupById(db.sleds, id) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 16daa62477..055dd53f10 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -48,6 +48,7 @@ import { ipRangeLen, NotImplemented, paginated, + requireFleetCollab, requireFleetViewer, requireRole, unavailableErr, @@ -1317,7 +1318,20 @@ export const handlers = makeHandlers({ const idps = db.identityProviders.filter(({ siloId }) => siloId === silo.id).map(toIdp) return { items: idps } }, + siloQuotasUpdate({ body, path, cookies }) { + requireFleetCollab(cookies) + const quotas = lookup.siloQuotas(path) + if (body.cpus !== undefined) quotas.cpus = body.cpus + if (body.memory !== undefined) quotas.memory = body.memory + if (body.storage !== undefined) quotas.storage = body.storage + + return quotas + }, + siloQuotasView({ path, cookies }) { + requireFleetViewer(cookies) + return lookup.siloQuotas(path) + }, samlIdentityProviderCreate({ query, body, cookies }) { requireFleetViewer(cookies) const silo = lookup.silo(query) @@ -1444,8 +1458,6 @@ export const handlers = makeHandlers({ roleView: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, - siloQuotasUpdate: NotImplemented, - siloQuotasView: NotImplemented, siloUserList: NotImplemented, siloUserView: NotImplemented, sledAdd: NotImplemented, diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 117b12acc0..bcc8647127 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -363,6 +363,10 @@ export function requireFleetViewer(cookies: Record) { requireRole(cookies, 'fleet', FLEET_ID, 'viewer') } +export function requireFleetCollab(cookies: Record) { + requireRole(cookies, 'fleet', FLEET_ID, 'collaborator') +} + /** * Determine whether current user has a role on a resource by looking roles * for the user as well as for the user's groups. Do nothing if yes, throw 403 diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 1c912251b1..136057e8e1 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -51,11 +51,28 @@ test('Create silo', async ({ page }) => { await expect(page.getByRole('textbox', { name: 'Admin group name' })).toHaveValue('') await expect(page.getByRole('checkbox', { name: 'Grant fleet admin' })).toBeChecked() await page.getByRole('textbox', { name: 'Admin group name' }).fill('admins') + + //////////////////////////// + // QUOTAS + //////////////////////////// + + const cpuQuota = page.getByRole('textbox', { name: 'CPU quota' }) + const decreaseCpuQuota = page.getByRole('button', { name: 'Decrease CPU quota' }) + + // can't go below zero + await expect(cpuQuota).toHaveValue('0') + await expect(decreaseCpuQuota).toBeDisabled() + await page.getByRole('textbox', { name: 'CPU quota' }).fill('30') + await expect(decreaseCpuQuota).toBeEnabled() // now you can decrease it + await page.getByRole('textbox', { name: 'Memory quota' }).fill('58') await page.getByRole('textbox', { name: 'Storage quota' }).fill('735') - // Add a TLS cert + //////////////////////////// + // TLS CERT + //////////////////////////// + const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' }) await openCertModalButton.click() @@ -124,22 +141,16 @@ test('Create silo', async ({ page }) => { ]) await expect(page.getByText('Silo viewerFleet viewer')).toBeHidden() - await page.goBack() - // now go check the quotas in its entry in the utilization table - await page.getByRole('link', { name: 'Utilization' }).click() - await expectRowVisible(page.getByRole('table'), { - Silo: 'other-silo', - CPU: '30', - Memory: '58 GiB', - Storage: '0.72 TiB', - }) + await page.getByRole('tab', { name: 'Quotas' }).click() + await expectRowVisible(table, { Resource: 'CPU', Quota: '30 vCPUs' }) + await expectRowVisible(table, { Resource: 'Memory', Quota: '58 GiB' }) + await expectRowVisible(table, { Resource: 'Storage', Quota: '735 GiB' }) await page.goBack() // now delete it - await page.locator('role=button[name="Row actions"]').nth(2).click() - await page.click('role=menuitem[name="Delete"]') + await clickRowAction(page, 'other-silo', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() await expect(otherSiloCell).toBeHidden() @@ -283,3 +294,53 @@ test('form scrolls to name field on already exists error', async ({ page }) => { await expect(nameField).toBeInViewport() await expect(page.getByText('name already exists').nth(0)).toBeVisible() }) + +test('Quotas tab', async ({ page }) => { + await page.goto('/system/silos/maze-war') + await page.getByRole('tab', { name: 'Quotas' }).click() + + const table = page.getByRole('table') + await expectRowVisible(table, { + Resource: 'CPU', + Provisioned: '30 vCPUs', + Quota: '50 vCPUs', + }) + await expectRowVisible(table, { + Resource: 'Memory', + Provisioned: '234 GiB', + Quota: '300 GiB', + }) + await expectRowVisible(table, { + Resource: 'Storage', + Provisioned: '4403.2 GiB', + Quota: '7168 GiB', + }) + + const sideModal = page.getByRole('dialog', { name: 'Edit quotas' }) + const edit = page.getByRole('button', { name: 'Edit quotas' }) + const submit = sideModal.getByRole('button', { name: 'Update quotas' }) + + await edit.click() + await expect(sideModal).toBeVisible() + + // test validation on empty field + const memory = page.getByRole('textbox', { name: 'Memory' }) + await memory.clear() + await submit.click() + await expect(sideModal.getByText('Memory is required')).toBeVisible() + + // try to type in a negative number HAHA YOU CAN'T + await memory.fill('-5') + await expect(memory).toHaveValue('') + + // only change one + await memory.fill('50') + await submit.click() + + await expect(sideModal).toBeHidden() + + // only one changes, the others stay the same + await expectRowVisible(table, { Resource: 'CPU', Quota: '50 vCPUs' }) + await expectRowVisible(table, { Resource: 'Memory', Quota: '50 GiB' }) + await expectRowVisible(table, { Resource: 'Storage', Quota: '7168 GiB' }) +})