Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
},
},
})
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think NaN wasn't failing RHF's check for a required field the way you'd hope


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'
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is covered by the min = 0 on NumberField. I tested it in the E2E.

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