Skip to content

Commit 1a2cb52

Browse files
authored
Silo quotas on silo detail (#2369)
* 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
1 parent 9e83117 commit 1a2cb52

File tree

9 files changed

+290
-23
lines changed

9 files changed

+290
-23
lines changed

app/components/form/fields/NumberField.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function NumberField<
4545
)}
4646
</div>
4747
{/* passing the generated id is very important for a11y */}
48-
<NumberFieldInner name={name} {...props} id={id} />
48+
<NumberFieldInner name={name} id={id} label={label} required={required} {...props} />
4949
</div>
5050
)
5151
}
@@ -80,7 +80,18 @@ export const NumberFieldInner = <
8080
const {
8181
field,
8282
fieldState: { error },
83-
} = useController({ name, control, rules: { required, validate } })
83+
} = useController({
84+
name,
85+
control,
86+
rules: {
87+
required,
88+
// it seems we need special logic to enforce required on NaN
89+
validate(value, values) {
90+
if (required && Number.isNaN(value)) return `${label} is required`
91+
return validate?.(value, values)
92+
},
93+
},
94+
})
8495

8596
return (
8697
<>

app/forms/silo-create.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ const defaultValues: SiloCreateFormValues = {
4747
},
4848
}
4949

50-
function validateQuota(value: number) {
51-
if (value < 0) return 'Must be at least 0'
52-
}
53-
5450
export function CreateSiloSideModalForm() {
5551
const navigate = useNavigate()
5652
const queryClient = useApiQueryClient()
@@ -124,23 +120,20 @@ export function CreateSiloSideModalForm() {
124120
name="quotas.cpus"
125121
required
126122
units="vCPUs"
127-
validate={validateQuota}
128123
/>
129124
<NumberField
130125
control={form.control}
131126
label="Memory quota"
132127
name="quotas.memory"
133128
required
134129
units="GiB"
135-
validate={validateQuota}
136130
/>
137131
<NumberField
138132
control={form.control}
139133
label="Storage quota"
140134
name="quotas.storage"
141135
required
142136
units="GiB"
143-
validate={validateQuota}
144137
/>
145138
<FormDivider />
146139
<RadioField

app/pages/system/silos/SiloPage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ import { docLinks } from '~/util/links'
2626

2727
import { SiloIdpsTab } from './SiloIdpsTab'
2828
import { SiloIpPoolsTab } from './SiloIpPoolsTab'
29+
import { SiloQuotasTab } from './SiloQuotasTab'
2930

3031
SiloPage.loader = async ({ params }: LoaderFunctionArgs) => {
3132
const { silo } = getSiloSelector(params)
3233
await Promise.all([
3334
apiQueryClient.prefetchQuery('siloView', { path: { silo } }),
35+
apiQueryClient.prefetchQuery('siloUtilizationView', { path: { silo } }),
3436
apiQueryClient.prefetchQuery('siloIdentityProviderList', {
3537
query: { silo, limit: PAGE_SIZE },
3638
}),
@@ -85,6 +87,7 @@ export function SiloPage() {
8587
<Tabs.List>
8688
<Tabs.Trigger value="idps">Identity Providers</Tabs.Trigger>
8789
<Tabs.Trigger value="ip-pools">IP Pools</Tabs.Trigger>
90+
<Tabs.Trigger value="quotas">Quotas</Tabs.Trigger>
8891
<Tabs.Trigger value="fleet-roles">Fleet roles</Tabs.Trigger>
8992
</Tabs.List>
9093
<Tabs.Content value="idps">
@@ -93,6 +96,9 @@ export function SiloPage() {
9396
<Tabs.Content value="ip-pools">
9497
<SiloIpPoolsTab />
9598
</Tabs.Content>
99+
<Tabs.Content value="quotas">
100+
<SiloQuotasTab />
101+
</Tabs.Content>
96102
<Tabs.Content value="fleet-roles">
97103
{/* TODO: better empty state explaining that no roles are mapped so nothing will happen */}
98104
{roleMapPairs.length === 0 ? (
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useState } from 'react'
10+
import { useForm } from 'react-hook-form'
11+
12+
import {
13+
apiQueryClient,
14+
useApiMutation,
15+
usePrefetchedApiQuery,
16+
type SiloQuotasUpdate,
17+
} from '~/api'
18+
import { NumberField } from '~/components/form/fields/NumberField'
19+
import { SideModalForm } from '~/components/form/SideModalForm'
20+
import { useSiloSelector } from '~/hooks/use-params'
21+
import { Button } from '~/ui/lib/Button'
22+
import { Message } from '~/ui/lib/Message'
23+
import { Table } from '~/ui/lib/Table'
24+
import { classed } from '~/util/classed'
25+
import { links } from '~/util/links'
26+
import { bytesToGiB, GiB } from '~/util/units'
27+
28+
const Unit = classed.span`ml-1 text-tertiary`
29+
30+
export function SiloQuotasTab() {
31+
const { silo } = useSiloSelector()
32+
const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', {
33+
path: { silo: silo },
34+
})
35+
36+
const { allocated: quotas, provisioned } = utilization
37+
38+
const [editing, setEditing] = useState(false)
39+
40+
return (
41+
<>
42+
<Table className="max-w-lg">
43+
<Table.Header>
44+
<Table.HeaderRow>
45+
<Table.HeadCell>Resource</Table.HeadCell>
46+
<Table.HeadCell>Provisioned</Table.HeadCell>
47+
<Table.HeadCell>Quota</Table.HeadCell>
48+
</Table.HeaderRow>
49+
</Table.Header>
50+
<Table.Body>
51+
<Table.Row>
52+
<Table.Cell>CPU</Table.Cell>
53+
<Table.Cell>
54+
{provisioned.cpus} <Unit>vCPUs</Unit>
55+
</Table.Cell>
56+
<Table.Cell>
57+
{quotas.cpus} <Unit>vCPUs</Unit>
58+
</Table.Cell>
59+
</Table.Row>
60+
<Table.Row>
61+
<Table.Cell>Memory</Table.Cell>
62+
<Table.Cell>
63+
{bytesToGiB(provisioned.memory)} <Unit>GiB</Unit>
64+
</Table.Cell>
65+
<Table.Cell>
66+
{bytesToGiB(quotas.memory)} <Unit>GiB</Unit>
67+
</Table.Cell>
68+
</Table.Row>
69+
<Table.Row>
70+
<Table.Cell>Storage</Table.Cell>
71+
<Table.Cell>
72+
{bytesToGiB(provisioned.storage)} <Unit>GiB</Unit>
73+
</Table.Cell>
74+
<Table.Cell>
75+
{bytesToGiB(quotas.storage)} <Unit>GiB</Unit>
76+
</Table.Cell>
77+
</Table.Row>
78+
</Table.Body>
79+
</Table>
80+
<div className="mt-4 flex space-x-2">
81+
<Button size="sm" onClick={() => setEditing(true)}>
82+
Edit quotas
83+
</Button>
84+
</div>
85+
{editing && <EditQuotasForm onDismiss={() => setEditing(false)} />}
86+
</>
87+
)
88+
}
89+
90+
function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) {
91+
const { silo } = useSiloSelector()
92+
const { data: utilization } = usePrefetchedApiQuery('siloUtilizationView', {
93+
path: { silo: silo },
94+
})
95+
const quotas = utilization.allocated
96+
97+
// required because we need to rule out undefined because NumberField hates that
98+
const defaultValues: Required<SiloQuotasUpdate> = {
99+
cpus: quotas.cpus,
100+
memory: bytesToGiB(quotas.memory),
101+
storage: bytesToGiB(quotas.storage),
102+
}
103+
104+
const form = useForm({ defaultValues })
105+
106+
const updateQuotas = useApiMutation('siloQuotasUpdate', {
107+
onSuccess() {
108+
apiQueryClient.invalidateQueries('siloUtilizationView')
109+
onDismiss()
110+
},
111+
})
112+
113+
return (
114+
<SideModalForm
115+
form={form}
116+
formType="edit"
117+
resourceName="Quotas"
118+
title="Edit quotas"
119+
onDismiss={onDismiss}
120+
onSubmit={({ cpus, memory, storage }) =>
121+
updateQuotas.mutate({
122+
body: {
123+
cpus,
124+
memory: memory * GiB,
125+
// TODO: we use GiB on instance create but TiB on utilization. HM
126+
storage: storage * GiB,
127+
},
128+
path: { silo },
129+
})
130+
}
131+
loading={updateQuotas.isPending}
132+
submitError={updateQuotas.error}
133+
>
134+
<Message content={<LearnMore />} variant="info" />
135+
136+
<NumberField name="cpus" label="CPU" units="vCPUs" required control={form.control} />
137+
<NumberField
138+
name="memory"
139+
label="Memory"
140+
units="GiB"
141+
required
142+
control={form.control}
143+
/>
144+
<NumberField
145+
name="storage"
146+
label="Storage"
147+
units="GiB"
148+
required
149+
control={form.control}
150+
/>
151+
</SideModalForm>
152+
)
153+
}
154+
155+
function LearnMore() {
156+
return (
157+
<>
158+
If a quota is set below the amount currently in use, users will not be able to
159+
provision resources. Learn more about quotas in the{' '}
160+
<a
161+
href={links.siloQuotasDocs}
162+
// don't need color and hover color because message text is already color-info anyway
163+
className="underline"
164+
target="_blank"
165+
rel="noreferrer"
166+
>
167+
Silos
168+
</a>{' '}
169+
guide.
170+
</>
171+
)
172+
}

app/util/links.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const links = {
2525
quickStart: 'https://docs.oxide.computer/guides/quickstart',
2626
routersDocs:
2727
'https://docs.oxide.computer/guides/configuring-guest-networking#_custom_routers',
28+
siloQuotasDocs:
29+
'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management',
2830
sledDocs:
2931
'https://docs.oxide.computer/guides/architecture/service-processors#_server_sled',
3032
snapshotsDocs:

mock-api/msw/db.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,12 @@ export const lookup = {
284284
if (!silo) throw notFoundErr(`silo '${id}'`)
285285
return silo
286286
},
287+
siloQuotas(params: PP.Silo): Json<Api.SiloQuotas> {
288+
const silo = lookup.silo(params)
289+
const quotas = db.siloQuotas.find((q) => q.silo_id === silo.id)
290+
if (!quotas) throw internalError(`Silo ${silo.name} has no quotas`)
291+
return quotas
292+
},
287293
sled({ sledId: id }: PP.Sled): Json<Api.Sled> {
288294
if (!id) throw notFoundErr('sled not specified')
289295
return lookupById(db.sleds, id)

mock-api/msw/handlers.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
ipRangeLen,
4949
NotImplemented,
5050
paginated,
51+
requireFleetCollab,
5152
requireFleetViewer,
5253
requireRole,
5354
unavailableErr,
@@ -1317,7 +1318,20 @@ export const handlers = makeHandlers({
13171318
const idps = db.identityProviders.filter(({ siloId }) => siloId === silo.id).map(toIdp)
13181319
return { items: idps }
13191320
},
1321+
siloQuotasUpdate({ body, path, cookies }) {
1322+
requireFleetCollab(cookies)
1323+
const quotas = lookup.siloQuotas(path)
13201324

1325+
if (body.cpus !== undefined) quotas.cpus = body.cpus
1326+
if (body.memory !== undefined) quotas.memory = body.memory
1327+
if (body.storage !== undefined) quotas.storage = body.storage
1328+
1329+
return quotas
1330+
},
1331+
siloQuotasView({ path, cookies }) {
1332+
requireFleetViewer(cookies)
1333+
return lookup.siloQuotas(path)
1334+
},
13211335
samlIdentityProviderCreate({ query, body, cookies }) {
13221336
requireFleetViewer(cookies)
13231337
const silo = lookup.silo(query)
@@ -1444,8 +1458,6 @@ export const handlers = makeHandlers({
14441458
roleView: NotImplemented,
14451459
siloPolicyUpdate: NotImplemented,
14461460
siloPolicyView: NotImplemented,
1447-
siloQuotasUpdate: NotImplemented,
1448-
siloQuotasView: NotImplemented,
14491461
siloUserList: NotImplemented,
14501462
siloUserView: NotImplemented,
14511463
sledAdd: NotImplemented,

mock-api/msw/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ export function requireFleetViewer(cookies: Record<string, string>) {
363363
requireRole(cookies, 'fleet', FLEET_ID, 'viewer')
364364
}
365365

366+
export function requireFleetCollab(cookies: Record<string, string>) {
367+
requireRole(cookies, 'fleet', FLEET_ID, 'collaborator')
368+
}
369+
366370
/**
367371
* Determine whether current user has a role on a resource by looking roles
368372
* for the user as well as for the user's groups. Do nothing if yes, throw 403

0 commit comments

Comments
 (0)