Skip to content

Commit

Permalink
Silo quotas on silo detail (#2369)
Browse files Browse the repository at this point in the history
* mock silo quotas endpoints

* display silo quotas in a tab on silo detail (read only)

* edit form w/ basic e2e test

* fix required validation on edit form, test negative number validation on number field

* add provisioned column to table

* put the button *under* the table. genius
  • Loading branch information
david-crespo authored Aug 16, 2024
1 parent 9e83117 commit 1a2cb52
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 23 deletions.
15 changes: 13 additions & 2 deletions app/components/form/fields/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function NumberField<
)}
</div>
{/* passing the generated id is very important for a11y */}
<NumberFieldInner name={name} {...props} id={id} />
<NumberFieldInner name={name} id={id} label={label} required={required} {...props} />
</div>
)
}
Expand Down Expand Up @@ -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 (
<>
Expand Down
7 changes: 0 additions & 7 deletions app/forms/silo-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -124,23 +120,20 @@ export function CreateSiloSideModalForm() {
name="quotas.cpus"
required
units="vCPUs"
validate={validateQuota}
/>
<NumberField
control={form.control}
label="Memory quota"
name="quotas.memory"
required
units="GiB"
validate={validateQuota}
/>
<NumberField
control={form.control}
label="Storage quota"
name="quotas.storage"
required
units="GiB"
validate={validateQuota}
/>
<FormDivider />
<RadioField
Expand Down
6 changes: 6 additions & 0 deletions app/pages/system/silos/SiloPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import { docLinks } from '~/util/links'

import { SiloIdpsTab } from './SiloIdpsTab'
import { SiloIpPoolsTab } from './SiloIpPoolsTab'
import { SiloQuotasTab } from './SiloQuotasTab'

SiloPage.loader = async ({ params }: LoaderFunctionArgs) => {
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 },
}),
Expand Down Expand Up @@ -85,6 +87,7 @@ export function SiloPage() {
<Tabs.List>
<Tabs.Trigger value="idps">Identity Providers</Tabs.Trigger>
<Tabs.Trigger value="ip-pools">IP Pools</Tabs.Trigger>
<Tabs.Trigger value="quotas">Quotas</Tabs.Trigger>
<Tabs.Trigger value="fleet-roles">Fleet roles</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="idps">
Expand All @@ -93,6 +96,9 @@ export function SiloPage() {
<Tabs.Content value="ip-pools">
<SiloIpPoolsTab />
</Tabs.Content>
<Tabs.Content value="quotas">
<SiloQuotasTab />
</Tabs.Content>
<Tabs.Content value="fleet-roles">
{/* TODO: better empty state explaining that no roles are mapped so nothing will happen */}
{roleMapPairs.length === 0 ? (
Expand Down
172 changes: 172 additions & 0 deletions app/pages/system/silos/SiloQuotasTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Table className="max-w-lg">
<Table.Header>
<Table.HeaderRow>
<Table.HeadCell>Resource</Table.HeadCell>
<Table.HeadCell>Provisioned</Table.HeadCell>
<Table.HeadCell>Quota</Table.HeadCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>CPU</Table.Cell>
<Table.Cell>
{provisioned.cpus} <Unit>vCPUs</Unit>
</Table.Cell>
<Table.Cell>
{quotas.cpus} <Unit>vCPUs</Unit>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Memory</Table.Cell>
<Table.Cell>
{bytesToGiB(provisioned.memory)} <Unit>GiB</Unit>
</Table.Cell>
<Table.Cell>
{bytesToGiB(quotas.memory)} <Unit>GiB</Unit>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Storage</Table.Cell>
<Table.Cell>
{bytesToGiB(provisioned.storage)} <Unit>GiB</Unit>
</Table.Cell>
<Table.Cell>
{bytesToGiB(quotas.storage)} <Unit>GiB</Unit>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<div className="mt-4 flex space-x-2">
<Button size="sm" onClick={() => setEditing(true)}>
Edit quotas
</Button>
</div>
{editing && <EditQuotasForm onDismiss={() => 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<SiloQuotasUpdate> = {
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 (
<SideModalForm
form={form}
formType="edit"
resourceName="Quotas"
title="Edit quotas"
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}
>
<Message content={<LearnMore />} variant="info" />

<NumberField name="cpus" label="CPU" units="vCPUs" required control={form.control} />
<NumberField
name="memory"
label="Memory"
units="GiB"
required
control={form.control}
/>
<NumberField
name="storage"
label="Storage"
units="GiB"
required
control={form.control}
/>
</SideModalForm>
)
}

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{' '}
<a
href={links.siloQuotasDocs}
// don't need color and hover color because message text is already color-info anyway
className="underline"
target="_blank"
rel="noreferrer"
>
Silos
</a>{' '}
guide.
</>
)
}
2 changes: 2 additions & 0 deletions app/util/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions mock-api/msw/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ export const lookup = {
if (!silo) throw notFoundErr(`silo '${id}'`)
return silo
},
siloQuotas(params: PP.Silo): Json<Api.SiloQuotas> {
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<Api.Sled> {
if (!id) throw notFoundErr('sled not specified')
return lookupById(db.sleds, id)
Expand Down
16 changes: 14 additions & 2 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
ipRangeLen,
NotImplemented,
paginated,
requireFleetCollab,
requireFleetViewer,
requireRole,
unavailableErr,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1444,8 +1458,6 @@ export const handlers = makeHandlers({
roleView: NotImplemented,
siloPolicyUpdate: NotImplemented,
siloPolicyView: NotImplemented,
siloQuotasUpdate: NotImplemented,
siloQuotasView: NotImplemented,
siloUserList: NotImplemented,
siloUserView: NotImplemented,
sledAdd: NotImplemented,
Expand Down
4 changes: 4 additions & 0 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ export function requireFleetViewer(cookies: Record<string, string>) {
requireRole(cookies, 'fleet', FLEET_ID, 'viewer')
}

export function requireFleetCollab(cookies: Record<string, string>) {
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
Expand Down
Loading

0 comments on commit 1a2cb52

Please sign in to comment.