Skip to content

Commit cfeb014

Browse files
committed
feat: maintain resource usage limits on AdminPage
1 parent b214c4c commit cfeb014

7 files changed

Lines changed: 630 additions & 3 deletions

File tree

client/src/features/admin/AdminPage.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import ChevronFlippedIcon from "~/components/icons/ChevronFlippedIcon";
3636
import { Loader } from "~/components/Loader";
3737
import { isFetchBaseQueryError } from "~/utils/helpers/ApiErrors";
3838
import { toFullHumanDuration } from "~/utils/helpers/DurationUtils";
39+
import { useGetResourcePoolsByResourcePoolIdLimitsQuery } from "../resourceUsage/api/resourceUsage.api";
40+
import UpdateResourceClassCostButton from "../resourceUsage/UpdateResourceClassCostButton";
41+
import UpdateResourcePoolUsageLimitsButton from "../resourceUsage/UpdateResourcePoolUsageLimitsButton";
3942
import {
4043
useDeleteResourcePoolsByResourcePoolIdMutation,
4144
useDeleteResourcePoolsByResourcePoolIdUsersAndUserIdMutation,
@@ -44,6 +47,7 @@ import {
4447
type PoolUserWithId,
4548
type ResourceClassWithId,
4649
type ResourcePoolWithId,
50+
type ResourcePoolWithIdFiltered,
4751
} from "../sessionsV2/api/computeResources.api";
4852
import { useGetUsersQuery } from "../usersV2/api/users.api";
4953
import { useGetNotebooksVersionQuery } from "../versions/versions.api";
@@ -155,7 +159,10 @@ function ResourcePoolsList() {
155159
}
156160

157161
interface ResourcePoolItemProps {
158-
resourcePool: ResourcePoolWithId;
162+
// TODO: Cluster is not declared as being in the response
163+
// check if it should be added to the API spec
164+
resourcePool: ResourcePoolWithIdFiltered &
165+
Pick<ResourcePoolWithId, "cluster">;
159166
}
160167

161168
function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) {
@@ -173,6 +180,9 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) {
173180
const toggle = useCallback(() => {
174181
setIsOpen((isOpen) => !isOpen);
175182
}, []);
183+
const { data: usageLimits } = useGetResourcePoolsByResourcePoolIdLimitsQuery({
184+
resourcePoolId: resourcePool.id,
185+
});
176186

177187
return (
178188
<Card className="mt-2">
@@ -228,7 +238,7 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) {
228238
)}
229239
>
230240
<div className={cx("col", "col-sm-12", "col-md", "text-start")}>
231-
Quota:
241+
Resource Quota:
232242
</div>
233243
<div className="col">{quota.cpu}&nbsp;CPUs</div>
234244
<div className="col">{quota.memory}&nbsp;GB RAM</div>
@@ -241,7 +251,37 @@ function ResourcePoolItem({ resourcePool }: ResourcePoolItemProps) {
241251
<p className="mb-0">No quota</p>
242252
)}
243253
</div>
244-
254+
<div className={cx("border-bottom", "py-2")}>
255+
<div
256+
className={cx(
257+
"align-items-center",
258+
"row",
259+
"row-cols-1",
260+
"row-cols-sm-4",
261+
"row-cols-md-5",
262+
"text-end"
263+
)}
264+
>
265+
<div className={cx("col", "col-sm-12", "col-md", "text-start")}>
266+
Usage Quota:
267+
</div>
268+
{usageLimits != null && (
269+
<div className="col">
270+
{usageLimits.user_limit} credits / user
271+
</div>
272+
)}
273+
{usageLimits != null && (
274+
<div className="col">
275+
{usageLimits.total_limit} credits total
276+
</div>
277+
)}
278+
<div className={cx("col", "ms-auto")}>
279+
<UpdateResourcePoolUsageLimitsButton
280+
resourcePool={resourcePool}
281+
/>
282+
</div>
283+
</div>
284+
</div>
245285
<div className={cx("border-bottom", "py-2")}>
246286
{clusterId != null ? (
247287
<p className="mb-0">
@@ -438,6 +478,22 @@ function ResourceClassItem({
438478
<div className={cx(columnClasses)}>
439479
node affinities: {node_affinities?.length ?? 0}
440480
</div>
481+
<div
482+
className={cx(
483+
columnClasses,
484+
"ms-auto",
485+
"d-flex",
486+
"flex-column",
487+
"flex-sm-row",
488+
"flex-wrap",
489+
"justify-content-end"
490+
)}
491+
>
492+
<UpdateResourceClassCostButton
493+
resourceClass={resourceClass}
494+
resourcePool={resourcePool}
495+
/>
496+
</div>
441497
<div
442498
className={cx(
443499
"col-12",
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*!
2+
* Copyright 2026 - Swiss Data Science Center (SDSC)
3+
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
4+
* Eidgenössische Technische Hochschule Zürich (ETHZ).
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import cx from "classnames";
20+
import { useCallback, useEffect, useState } from "react";
21+
import { CheckLg, XLg } from "react-bootstrap-icons";
22+
import { Controller, useForm, useWatch } from "react-hook-form";
23+
import {
24+
Button,
25+
Form,
26+
FormText,
27+
Input,
28+
Label,
29+
Modal,
30+
ModalBody,
31+
ModalFooter,
32+
ModalHeader,
33+
} from "reactstrap";
34+
35+
import RtkOrDataServicesError from "~/components/errors/RtkOrDataServicesError";
36+
import { Loader } from "~/components/Loader";
37+
import {
38+
type ResourceClassWithId,
39+
type ResourcePoolWithId,
40+
} from "../sessionsV2/api/computeResources.api";
41+
import {
42+
useDeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation,
43+
useGetResourcePoolsByResourcePoolIdClassesAndClassIdCostQuery,
44+
useGetResourcePoolsByResourcePoolIdLimitsQuery,
45+
usePutResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation,
46+
type ResourcePoolLimits,
47+
} from "./api/resourceUsage.api";
48+
49+
interface UpdateResourceClassCostButtonProps {
50+
resourceClass: ResourceClassWithId;
51+
resourcePool: ResourcePoolWithId;
52+
}
53+
54+
function resourceClassUsageLimitText(
55+
cost: number | undefined,
56+
poolLimits: ResourcePoolLimits | undefined
57+
) {
58+
if (cost == null || cost <= 0 || poolLimits == null) {
59+
return "Users can use this resource class without limit";
60+
}
61+
const poolUserLimit =
62+
poolLimits.user_limit > 0 ? poolLimits.user_limit : poolLimits.total_limit;
63+
const hoursPerUser = (poolUserLimit / cost).toFixed(2);
64+
return `${hoursPerUser} hours / user`;
65+
}
66+
67+
export default function UpdateResourceClassCostButton({
68+
resourceClass,
69+
resourcePool,
70+
}: UpdateResourceClassCostButtonProps) {
71+
const [isOpen, setIsOpen] = useState(false);
72+
const toggle = useCallback(() => {
73+
setIsOpen((open) => !open);
74+
}, []);
75+
76+
const { data: classCost } =
77+
useGetResourcePoolsByResourcePoolIdClassesAndClassIdCostQuery({
78+
resourcePoolId: resourcePool.id,
79+
classId: resourceClass.id,
80+
});
81+
82+
return (
83+
<>
84+
<Button
85+
size="sm"
86+
color="outline-primary"
87+
data-cy="update-resource-class-cost-button"
88+
disabled={resourcePool.quota == null}
89+
onClick={toggle}
90+
>
91+
Cost{" "}
92+
{classCost?.cost == null
93+
? "None"
94+
: classCost.cost <= 0
95+
? "None"
96+
: classCost.cost}
97+
</Button>
98+
<UpdateResourceClassCostModal
99+
isOpen={isOpen}
100+
classCost={classCost?.cost}
101+
resourceClass={resourceClass}
102+
resourcePool={resourcePool}
103+
toggle={toggle}
104+
/>
105+
</>
106+
);
107+
}
108+
109+
interface UpdateResourceClassCostModalProps {
110+
isOpen: boolean;
111+
classCost: number | undefined;
112+
resourceClass: ResourceClassWithId;
113+
resourcePool: ResourcePoolWithId;
114+
toggle: () => void;
115+
}
116+
117+
function UpdateResourceClassCostModal({
118+
isOpen,
119+
resourceClass,
120+
resourcePool,
121+
toggle,
122+
classCost,
123+
}: UpdateResourceClassCostModalProps) {
124+
const { id, name: resourcePoolName } = resourcePool;
125+
const { id: resourceClassId, name: resourceClassName } = resourceClass;
126+
127+
const { data: poolLimits } = useGetResourcePoolsByResourcePoolIdLimitsQuery({
128+
resourcePoolId: id,
129+
});
130+
131+
const [updateResourceClassCost, result] =
132+
usePutResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation();
133+
const [deleteResourceClassCost, deleteResult] =
134+
useDeleteResourcePoolsByResourcePoolIdClassesAndClassIdCostMutation();
135+
136+
const { control, handleSubmit, reset } = useForm<UpdateResourceClassCostForm>(
137+
{
138+
defaultValues: {
139+
cost: classCost,
140+
},
141+
}
142+
);
143+
const formCost = useWatch({ control, name: "cost" });
144+
145+
useEffect(() => {
146+
if (isOpen) {
147+
reset({
148+
cost: classCost,
149+
});
150+
}
151+
}, [isOpen, reset, classCost]);
152+
const onSubmit = useCallback(
153+
(data: UpdateResourceClassCostForm) => {
154+
if (data.cost <= 0) {
155+
deleteResourceClassCost({
156+
resourcePoolId: id,
157+
classId: resourceClassId,
158+
});
159+
return;
160+
}
161+
updateResourceClassCost({
162+
resourcePoolId: id,
163+
classId: resourceClassId,
164+
resourceClassCostPut: {
165+
cost: data.cost,
166+
},
167+
});
168+
},
169+
[id, resourceClassId, updateResourceClassCost, deleteResourceClassCost]
170+
);
171+
172+
useEffect(() => {
173+
if (result.isSuccess || deleteResult.isSuccess) {
174+
toggle();
175+
}
176+
}, [deleteResult.isSuccess, result.isSuccess, toggle]);
177+
178+
return (
179+
<Modal backdrop="static" centered isOpen={isOpen} size="lg" toggle={toggle}>
180+
<ModalHeader tag="h2" toggle={toggle}>
181+
Update cost for {resourcePoolName} - {resourceClassName}
182+
</ModalHeader>
183+
<ModalBody>
184+
<Form
185+
className="form-rk-green"
186+
noValidate
187+
onSubmit={handleSubmit(onSubmit)}
188+
>
189+
{result.error && <RtkOrDataServicesError error={result.error} />}
190+
191+
<div className="mb-3">
192+
<Label className="form-label" for={`UpdateResourceClassCost-${id}`}>
193+
Cost (credits)
194+
</Label>
195+
<Controller
196+
control={control}
197+
name="cost"
198+
render={({ field }) => (
199+
<>
200+
<Input
201+
id={`UpdateResourceClassCost-${id}`}
202+
type="number"
203+
min={0}
204+
step={1}
205+
{...field}
206+
/>
207+
<FormText color="muted">
208+
{resourceClassUsageLimitText(formCost, poolLimits)}
209+
</FormText>
210+
</>
211+
)}
212+
/>
213+
</div>
214+
</Form>
215+
</ModalBody>
216+
<ModalFooter>
217+
<Button color="outline-primary" onClick={toggle}>
218+
<XLg className={cx("bi", "me-1")} />
219+
Close
220+
</Button>
221+
<Button
222+
color="primary"
223+
disabled={result.isLoading}
224+
onClick={handleSubmit(onSubmit)}
225+
type="submit"
226+
>
227+
{result.isLoading ? (
228+
<Loader className="me-1" inline size={16} />
229+
) : (
230+
<CheckLg className={cx("bi", "me-1")} />
231+
)}
232+
Update cost
233+
</Button>
234+
</ModalFooter>
235+
</Modal>
236+
);
237+
}
238+
239+
interface UpdateResourceClassCostForm {
240+
cost: number;
241+
}

0 commit comments

Comments
 (0)