diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json
index ba67571b4e1f3..97803c3c45014 100644
--- a/ui-v2/package-lock.json
+++ b/ui-v2/package-lock.json
@@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
@@ -2377,6 +2378,35 @@
}
}
},
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz",
+ "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.0",
+ "@radix-ui/react-compose-refs": "1.1.0",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.0",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-use-previous": "1.1.0",
+ "@radix-ui/react-use-size": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz",
diff --git a/ui-v2/package.json b/ui-v2/package.json
index 46fe5dbd6922c..43c190b4969bc 100644
--- a/ui-v2/package.json
+++ b/ui-v2/package.json
@@ -31,6 +31,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx
new file mode 100644
index 0000000000000..01e640e3d8335
--- /dev/null
+++ b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx
@@ -0,0 +1,125 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+
+import { type GlobalConcurrencyLimit } from "@/hooks/global-concurrency-limits";
+import { useCreateOrEditLimitDialog } from "./use-create-or-edit-limit-dialog";
+
+type Props = {
+ limitToUpdate: undefined | GlobalConcurrencyLimit;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: () => void;
+ open: boolean;
+};
+
+export const CreateOrEditLimitDialog = ({
+ limitToUpdate,
+ onOpenChange,
+ onSubmit,
+ open,
+}: Props) => {
+ const { dialogTitle, form, isLoading, saveOrUpdate } =
+ useCreateOrEditLimitDialog({
+ limitToUpdate,
+ onSubmit,
+ });
+
+ return (
+
+ );
+};
diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts
new file mode 100644
index 0000000000000..f09e6f221c2cd
--- /dev/null
+++ b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts
@@ -0,0 +1,124 @@
+import {
+ GlobalConcurrencyLimit,
+ useCreateGlobalConcurrencyLimit,
+ useUpdateGlobalConcurrencyLimit,
+} from "@/hooks/global-concurrency-limits";
+import { useToast } from "@/hooks/use-toast";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const formSchema = z.object({
+ active: z.boolean().default(true),
+ /** Coerce to solve common issue of transforming a string number to a number type */
+ denied_slots: z.number().default(0).or(z.string()).pipe(z.coerce.number()),
+ /** Coerce to solve common issue of transforming a string number to a number type */
+ limit: z.number().default(0).or(z.string()).pipe(z.coerce.number()),
+ name: z
+ .string()
+ .min(2, { message: "Name must be at least 2 characters" })
+ .default(""),
+ /** Coerce to solve common issue of transforming a string number to a number type */
+ slot_decay_per_second: z
+ .number()
+ .default(0)
+ .or(z.string())
+ .pipe(z.coerce.number()),
+ /** Additional fields post creation. Coerce to solve common issue of transforming a string number to a number type */
+ active_slots: z.number().default(0).or(z.string()).pipe(z.coerce.number()),
+});
+
+const DEFAULT_VALUES = {
+ active: true,
+ name: "",
+ limit: 0,
+ slot_decay_per_second: 0,
+ denied_slots: 0,
+ active_slots: 0,
+} as const;
+
+type UseCreateOrEditLimitDialog = {
+ /** Limit to edit. Pass undefined if creating a new limit */
+ limitToUpdate: GlobalConcurrencyLimit | undefined;
+ /** Callback after hitting Save or Update */
+ onSubmit: () => void;
+};
+
+export const useCreateOrEditLimitDialog = ({
+ limitToUpdate,
+ onSubmit,
+}: UseCreateOrEditLimitDialog) => {
+ const { toast } = useToast();
+
+ const { createGlobalConcurrencyLimit, status: createStatus } =
+ useCreateGlobalConcurrencyLimit();
+ const { updateGlobalConcurrencyLimit, status: updateStatus } =
+ useUpdateGlobalConcurrencyLimit();
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: DEFAULT_VALUES,
+ });
+
+ // Sync form data with limit-to-edit data
+ useEffect(() => {
+ if (limitToUpdate) {
+ const { active, name, limit, slot_decay_per_second, active_slots } =
+ limitToUpdate;
+ form.reset({ active, name, limit, slot_decay_per_second, active_slots });
+ } else {
+ form.reset(DEFAULT_VALUES);
+ }
+ }, [form, limitToUpdate]);
+
+ const saveOrUpdate = (values: z.infer) => {
+ const onSettled = () => {
+ form.reset(DEFAULT_VALUES);
+ onSubmit();
+ };
+
+ if (limitToUpdate?.id) {
+ updateGlobalConcurrencyLimit(
+ {
+ id_or_name: limitToUpdate.id,
+ ...values,
+ },
+ {
+ onSuccess: () => {
+ toast({ title: "Limit updated" });
+ },
+ onError: (error) => {
+ const message =
+ error.message || "Unknown error while updating limit.";
+ form.setError("root", { message });
+ },
+ onSettled,
+ },
+ );
+ } else {
+ createGlobalConcurrencyLimit(values, {
+ onSuccess: () => {
+ toast({ title: "Limit created" });
+ },
+ onError: (error) => {
+ const message =
+ error.message || "Unknown error while creating variable.";
+ form.setError("root", {
+ message,
+ });
+ },
+ onSettled,
+ });
+ }
+ };
+
+ return {
+ form,
+ saveOrUpdate,
+ dialogTitle: limitToUpdate
+ ? `Update ${limitToUpdate.name}`
+ : "Add Concurrency Limit",
+ isLoading: createStatus === "pending" || updateStatus === "pending",
+ };
+};
diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx
index 033d5fb162ed1..f61e30f4ba60e 100644
--- a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx
+++ b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx
@@ -1,28 +1,58 @@
import { useListGlobalConcurrencyLimits } from "@/hooks/global-concurrency-limits";
-import { useState } from "react";
-
+import { useMemo, useState } from "react";
+import { CreateOrEditLimitDialog } from "./create-or-edit-limit-dialog";
+import { GlobalConcurrencyLimitEmptyState } from "./global-concurrency-limit-empty-state";
import { GlobalConcurrencyLimitsHeader } from "./global-concurrency-limits-header";
+type AddOrEditDialogState = {
+ open: boolean;
+ limitIdToEdit?: string;
+};
+
export const GlobalConcurrencyView = () => {
- const [showAddDialog, setShowAddDialog] = useState(false);
+ const [openAddOrEditDialog, setOpenAddOrEditDialog] =
+ useState({
+ open: false,
+ });
const { data } = useListGlobalConcurrencyLimits();
- const openAddDialog = () => setShowAddDialog(true);
- const closeAddDialog = () => setShowAddDialog(false);
+ const selectedlimitToUpdate = useMemo(() => {
+ if (!openAddOrEditDialog.limitIdToEdit) {
+ return undefined;
+ }
+ return data.find((limit) => limit.id === openAddOrEditDialog.limitIdToEdit);
+ }, [data, openAddOrEditDialog.limitIdToEdit]);
+
+ const openAddDialog = () =>
+ setOpenAddOrEditDialog((curr) => ({ ...curr, open: true }));
+
+ // close and deselect any selected limits
+ const closeAddDialog = () => setOpenAddOrEditDialog({ open: false });
return (
<>
-
-
-
- TODO
-
- {data.map((limit) => (
- - {JSON.stringify(limit)}
- ))}
-
- {showAddDialog && TODO: DIALOG
}
+ {data.length === 0 ? (
+
+ ) : (
+
+
+
TODO
+
+ {data.map((limit) => (
+ - {JSON.stringify(limit)}
+ ))}
+
+
+ )}
+
+ setOpenAddOrEditDialog((curr) => ({ ...curr, open }))
+ }
+ limitToUpdate={selectedlimitToUpdate}
+ onSubmit={closeAddDialog}
+ />
>
);
};
diff --git a/ui-v2/src/components/ui/switch.tsx b/ui-v2/src/components/ui/switch.tsx
new file mode 100644
index 0000000000000..e20fb378e4edc
--- /dev/null
+++ b/ui-v2/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ className: string;
+ }
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };