diff --git a/app/forms/silo-quotas-edit.tsx b/app/forms/silo-quotas-edit.tsx
new file mode 100644
index 000000000..cb90ff0d2
--- /dev/null
+++ b/app/forms/silo-quotas-edit.tsx
@@ -0,0 +1,128 @@
+/*
+ * 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 { SideModalFormDocs } from '~/ui/lib/ModalLinks'
+import { ResourceLabel } from '~/ui/lib/SideModal'
+import { docLinks } 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
+ /** Currently provisioned amounts, shown under each input for context */
+ provisioned: VirtualResourceCounts
+ onDismiss: () => void
+}
+
+const ProvisionedHint = ({ value, unit }: { value: number; unit: string }) => (
+
+ Provisioned: {value} {unit}
+
+)
+
+export function EditQuotasSideModalForm({ silo, quotas, provisioned, 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}
+ >
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx
index f40b4eaf3..95b5d60d1 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,106 @@ 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..b82b2098e 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,16 @@ 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/app/util/links.ts b/app/util/links.ts
index 0179c31a4..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',
diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts
index faad42860..605981955 100644
--- a/test/e2e/utilization.e2e.ts
+++ b/test/e2e/utilization.e2e.ts
@@ -56,6 +56,42 @@ 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()
+
+ // provisioned amounts show under each input
+ await expect(sideModal.getByText('Provisioned: 30 vCPUs')).toBeVisible()
+ await expect(sideModal.getByText('Provisioned: 234 GiB')).toBeVisible()
+ await expect(sideModal.getByText('Provisioned: 4403.2 GiB')).toBeVisible()
+
+ await expect(
+ sideModal.getByRole('link', { name: 'Resource Management' })
+ ).toHaveAttribute(
+ 'href',
+ 'https://docs.oxide.computer/guides/operator/resource-management'
+ )
+
+ 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')