From 430c7602de58e9b889078f1b700e9fcc60b044dc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 09:41:08 -0400 Subject: [PATCH 1/3] Add modal to utilization page to edit silo quotas --- app/forms/silo-quotas-edit.tsx | 119 +++++++++++++++ app/pages/system/UtilizationPage.tsx | 177 +++++++++++++---------- app/pages/system/silos/SiloQuotasTab.tsx | 110 ++------------ test/e2e/utilization.e2e.ts | 24 +++ 4 files changed, 253 insertions(+), 177 deletions(-) create mode 100644 app/forms/silo-quotas-edit.tsx diff --git a/app/forms/silo-quotas-edit.tsx b/app/forms/silo-quotas-edit.tsx new file mode 100644 index 000000000..09d0129b3 --- /dev/null +++ b/app/forms/silo-quotas-edit.tsx @@ -0,0 +1,119 @@ +/* + * 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 type { SetNonNullable } from 'type-fest' + +import { + api, + queryClient, + useApiMutation, + type SiloQuotasUpdate, + type VirtualResourceCounts, +} from '@oxide/api' +import { Cloud16Icon } from '@oxide/design-system/icons/react' + +import { NumberField } from '~/components/form/fields/NumberField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { links } from '~/util/links' +import { bytesToGiB, GiB } from '~/util/units' + +type Props = { + /** Silo name, used as the path param on update */ + silo: string + /** Current quotas, i.e., the `allocated` counts from silo utilization */ + quotas: VirtualResourceCounts + onDismiss: () => void +} + +export function EditQuotasSideModalForm({ silo, quotas, onDismiss }: Props) { + // required because we need to rule out undefined because NumberField hates that + const defaultValues: SetNonNullable> = { + cpus: quotas.cpus, + memory: bytesToGiB(quotas.memory), + storage: bytesToGiB(quotas.storage), + } + + const form = useForm({ defaultValues }) + + const updateQuotas = useApiMutation(api.siloQuotasUpdate, { + onSuccess() { + queryClient.invalidateEndpoint('siloUtilizationView') + queryClient.invalidateEndpoint('siloUtilizationList') + addToast({ content: 'Quotas updated' }) + onDismiss() + }, + }) + + return ( + + {silo} + + } + onDismiss={onDismiss} + onSubmit={({ cpus, memory, storage }) => + 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/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index f40b4eaf3..58841c88a 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -16,6 +16,7 @@ import { queryClient, totalUtilization, usePrefetchedQuery, + type SiloUtilization, } from '@oxide/api' import { Metrics16Icon, Metrics24Icon } from '@oxide/design-system/icons/react' @@ -25,6 +26,7 @@ import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePi import { QueryParamTabs } from '~/components/QueryParamTabs' import { useIntervalPicker } from '~/components/RefetchIntervalPicker' import { SystemMetric } from '~/components/SystemMetric' +import { EditQuotasSideModalForm } from '~/forms/silo-quotas-edit' import { LinkCell } from '~/table/cells/LinkCell' import { RowActions } from '~/table/columns/action-col' import { Listbox } from '~/ui/lib/Listbox' @@ -171,84 +173,105 @@ const MetricsTab = () => { function UsageTab() { const { data: siloUtilizations } = usePrefetchedQuery(siloUtilList.optionsFn()) + // silo whose quotas are being edited, if any + const [editingSilo, setEditingSilo] = useState(null) + return ( - - - - - {/* data-test-ignore makes the row asserts work in the e2e tests */} - - Provisioned / Quota - - - Available - - - - - Silo - CPU - Memory - Storage - CPU - Memory - Storage - - - - - {siloUtilizations.items.map((silo) => ( - - - {silo.siloName} - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
+ <> + + + + + {/* data-test-ignore makes the row asserts work in the e2e tests */} + + Provisioned / Quota + + + Available + + + + + Silo + CPU + Memory + Storage + CPU + Memory + Storage + + + + + {siloUtilizations.items.map((silo) => ( + + + {silo.siloName} + + + + + + + + + + + + + + + + + + + + + setEditingSilo(silo), + }, + ]} + /> + + + ))} + +
+ {editingSilo && ( + setEditingSilo(null)} + /> + )} + ) } diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 16bab9dd1..9fc063ba7 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -7,30 +7,17 @@ */ import { useState } from 'react' -import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import type { SetNonNullable } from 'type-fest' -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type SiloQuotasUpdate, -} from '~/api' -import { NumberField } from '~/components/form/fields/NumberField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { api, q, queryClient, usePrefetchedQuery } from '~/api' +import { EditQuotasSideModalForm } from '~/forms/silo-quotas-edit' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' -import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' import { ValueUnit } from '~/ui/lib/ValueUnit' -import { links } from '~/util/links' import type * as PP from '~/util/path-params' -import { bytesToGiB, GiB } from '~/util/units' +import { bytesToGiB } from '~/util/units' const siloUtil = ({ silo }: PP.Silo) => q(api.siloUtilizationView, { path: { silo } }) @@ -93,92 +80,15 @@ export default function SiloQuotasTab() { Edit quotas - {editing && setEditing(false)} />} + {editing && ( + setEditing(false)} + /> + )} ) } export const handle = makeCrumb('Quotas') - -function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { - const { silo } = useSiloSelector() - const { data: utilization } = usePrefetchedQuery(siloUtil({ silo })) - const quotas = utilization.allocated - - // required because we need to rule out undefined because NumberField hates that - const defaultValues: SetNonNullable> = { - cpus: quotas.cpus, - memory: bytesToGiB(quotas.memory), - storage: bytesToGiB(quotas.storage), - } - - const form = useForm({ defaultValues }) - - const updateQuotas = useApiMutation(api.siloQuotasUpdate, { - onSuccess() { - queryClient.invalidateEndpoint('siloUtilizationView') - addToast({ content: 'Quotas updated' }) - 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/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts index faad42860..762c0d33e 100644 --- a/test/e2e/utilization.e2e.ts +++ b/test/e2e/utilization.e2e.ts @@ -56,6 +56,30 @@ test.describe('System utilization', () => { expect(await clipboardText(page)).toEqual('6d3a9c06-475e-4f75-b272-c0d0e3f980fa') }) + test('can edit quotas', async ({ page }) => { + await page.goto('/system/utilization') + + const table = page.getByRole('table') + // CPU here is the available column, i.e., quota (50) minus provisioned (30) + await expectRowVisible(table, { Silo: 'maze-war', CPU: '20' }) + + await clickRowAction(page, 'maze-war', 'Edit quotas') + + const sideModal = page.getByRole('dialog', { name: 'Edit quotas' }) + await expect(sideModal).toBeVisible() + await expect(sideModal.getByRole('heading', { name: 'maze-war' })).toBeVisible() + + const cpus = sideModal.getByRole('textbox', { name: 'CPU' }) + await expect(cpus).toHaveValue('50') + await cpus.fill('60') + await sideModal.getByRole('button', { name: 'Update quotas' }).click() + + await expect(sideModal).toBeHidden() + await closeToast(page) + + await expectRowVisible(table, { Silo: 'maze-war', CPU: '30' }) + }) + test('does not appear for dev user', async ({ browser }) => { const page = await getPageAsUser(browser, 'Hans Jonas') await page.goto('/system/utilization') From 3ff153d49350e4e831d15086ebe707e4f4e76811 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 09:53:10 -0400 Subject: [PATCH 2/3] Use link --- app/forms/silo-quotas-edit.tsx | 28 +++++++--------------------- app/util/links.ts | 4 ++++ test/e2e/utilization.e2e.ts | 6 ++++++ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/app/forms/silo-quotas-edit.tsx b/app/forms/silo-quotas-edit.tsx index 09d0129b3..585ebbfc9 100644 --- a/app/forms/silo-quotas-edit.tsx +++ b/app/forms/silo-quotas-edit.tsx @@ -21,8 +21,9 @@ import { NumberField } from '~/components/form/fields/NumberField' import { SideModalForm } from '~/components/form/SideModalForm' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { ResourceLabel } from '~/ui/lib/SideModal' -import { links } from '~/util/links' +import { docLinks } from '~/util/links' import { bytesToGiB, GiB } from '~/util/units' type Props = { @@ -78,7 +79,10 @@ export function EditQuotasSideModalForm({ silo, quotas, onDismiss }: Props) { 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 0179c31a4..078367a35 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -166,6 +166,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/operator/silo-management', linkText: 'Silos', }, + siloQuotas: { + href: links.siloQuotasDocs, + linkText: 'Silos', + }, systemUpdate: { href: 'https://docs.oxide.computer/guides/operator/system-update', linkText: 'System Update', diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts index 762c0d33e..c3d1cb257 100644 --- a/test/e2e/utilization.e2e.ts +++ b/test/e2e/utilization.e2e.ts @@ -69,6 +69,12 @@ test.describe('System utilization', () => { await expect(sideModal).toBeVisible() await expect(sideModal.getByRole('heading', { name: 'maze-war' })).toBeVisible() + const docs = sideModal.getByRole('link', { name: 'Silos' }) + await expect(docs).toHaveAttribute( + 'href', + 'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management' + ) + const cpus = sideModal.getByRole('textbox', { name: 'CPU' }) await expect(cpus).toHaveValue('50') await cpus.fill('60') From 20a93e81c9b1b81c1c859676a8ca4cb1fd91b67a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 10:07:09 -0400 Subject: [PATCH 3/3] Update relevant doc link --- app/forms/silo-quotas-edit.tsx | 2 +- app/util/links.ts | 10 ++++------ test/e2e/utilization.e2e.ts | 7 ++++--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/forms/silo-quotas-edit.tsx b/app/forms/silo-quotas-edit.tsx index 585ebbfc9..179ce92f2 100644 --- a/app/forms/silo-quotas-edit.tsx +++ b/app/forms/silo-quotas-edit.tsx @@ -99,7 +99,7 @@ export function EditQuotasSideModalForm({ silo, quotas, onDismiss }: Props) { required control={form.control} /> - + ) } diff --git a/app/util/links.ts b/app/util/links.ts index 078367a35..d4021b24e 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -22,8 +22,6 @@ export const links = { instanceBootDiskDocs: 'https://docs.oxide.computer/guides/deploying-workloads', oxqlSchemaDocs: (metric: string) => `https://docs.oxide.computer/guides/metrics/timeseries-schemas#_${metric.replace(':', '')}`, - siloQuotasDocs: - 'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management', siloTlsCertsDocs: 'https://docs.oxide.computer/guides/system/system-setup#tls-certificate', transitIpsDocs: @@ -110,6 +108,10 @@ export const docLinks = { href: remoteAccess, linkText: 'Remote Access', }, + resourceManagement: { + href: 'https://docs.oxide.computer/guides/operator/resource-management', + linkText: 'Resource Management', + }, scim: { href: 'https://docs.oxide.computer/guides/operator/identity-providers#_saml_authentication_scim_user_provisioning', linkText: 'SCIM', @@ -166,10 +168,6 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/operator/silo-management', linkText: 'Silos', }, - siloQuotas: { - href: links.siloQuotasDocs, - linkText: 'Silos', - }, systemUpdate: { href: 'https://docs.oxide.computer/guides/operator/system-update', linkText: 'System Update', diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts index c3d1cb257..c4df3b729 100644 --- a/test/e2e/utilization.e2e.ts +++ b/test/e2e/utilization.e2e.ts @@ -69,10 +69,11 @@ test.describe('System utilization', () => { await expect(sideModal).toBeVisible() await expect(sideModal.getByRole('heading', { name: 'maze-war' })).toBeVisible() - const docs = sideModal.getByRole('link', { name: 'Silos' }) - await expect(docs).toHaveAttribute( + await expect( + sideModal.getByRole('link', { name: 'Resource Management' }) + ).toHaveAttribute( 'href', - 'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management' + 'https://docs.oxide.computer/guides/operator/resource-management' ) const cpus = sideModal.getByRole('textbox', { name: 'CPU' })