{/* 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' })
+})