feat: volume details

This commit is contained in:
Nicolas Meienberger
2025-09-07 16:08:08 +02:00
parent aa82f95c56
commit 833bcb590f
13 changed files with 280 additions and 153 deletions

View File

@@ -3,7 +3,7 @@ import { volumeConfigSchema } from "@ironmount/schemas";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { type } from "arktype"; import { type } from "arktype";
import { CheckCircle, Loader2, XCircle } from "lucide-react"; import { CheckCircle, Loader2, XCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
import { slugify } from "~/lib/utils"; import { slugify } from "~/lib/utils";
@@ -28,21 +28,9 @@ type Props = {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: arktypeResolver(formSchema), resolver: arktypeResolver(formSchema),
defaultValues: { defaultValues: initialValues,
name: "",
backend: "directory",
},
}); });
const { setValue, formState, watch, getValues } = form; const { watch, getValues } = form;
const { isDirty } = formState;
useEffect(() => {
if (initialValues && !isDirty) {
for (const [key, value] of Object.entries(initialValues)) {
setValue(key as keyof FormValues, value as string);
}
}
}, [initialValues, isDirty, setValue]);
const watchedBackend = watch("backend"); const watchedBackend = watch("backend");
@@ -108,7 +96,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Backend</FormLabel> <FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a backend" /> <SelectValue placeholder="Select a backend" />

View File

@@ -0,0 +1,20 @@
import { Outlet } from "react-router";
import { cn } from "~/lib/utils";
export default function Layout() {
return (
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "~/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,31 @@
import { ScanHeartIcon } from "lucide-react";
import type { GetVolumeResponse } from "~/api-client";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
type Props = {
volume: GetVolumeResponse;
};
export const HealthchecksCard = ({ volume }: Props) => {
return (
<Card className="p-6 flex-1 flex flex-col">
<div className="flex flex-col flex-1 justify-start">
<span className="flex items-center gap-2 mb-4">
<ScanHeartIcon size={24} />
<h2 className="text-lg font-medium">Health Checks</h2>
</span>
<span className="">Status: {volume.status ?? "Unknown"}</span>
<span className="text-sm text-muted-foreground mb-4">
Last checked: {new Date(volume.lastHealthCheck).toLocaleString()}
</span>
<span className="flex items-center">
Enable auto remount
<Switch className="ml-auto cursor-pointer" checked={volume.autoRemount} />
</span>
</div>
<Button variant="outline">Run Health Check</Button>
</Card>
);
};

View File

@@ -1,3 +1,5 @@
import { index, type RouteConfig } from "@react-router/dev/routes"; import { index, layout, type RouteConfig, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig; export default [
layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]),
] satisfies RouteConfig;

View File

@@ -0,0 +1,89 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { WifiIcon } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { getVolume } from "~/api-client";
import { deleteVolumeMutation, getVolumeOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm } from "~/components/create-volume-form";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors";
import { HealthchecksCard } from "~/modules/details/components/healthchecks-card";
import type { Route } from "./+types/details";
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name ?? "" } });
if (volume.data) return volume.data;
};
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
});
const deleteVol = useMutation({
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
navigate("/");
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
const handleDeleteConfirm = (name: string) => {
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) {
deleteVol.mutate({ path: { name } });
}
};
if (!name) {
return <div>Volume not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-0 uppercase">Volume: {name}</h1>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span className="text-green-500 flex items-center gap-2">
<WifiIcon size={16} />
{data.status}
</span>
<VolumeIcon size={14} backend={data?.config.backend} />
</div>
</div>
<div className="flex gap-4">
<Button>Mount</Button>
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}>
Delete
</Button>
</div>
</div>
<div className="flex gap-4">
<Card className="my-4 p-6 flex-1">
<CreateVolumeForm initialValues={{ ...data, ...data?.config }} onSubmit={console.log} />
</Card>
<div className="flex flex-col my-4 gap-4">
<HealthchecksCard volume={data} />
<Card className="p-6 flex-1">
<h2 className="text-lg font-medium">Volume Information</h2>
</Card>
</div>
</div>
</>
);
}

View File

@@ -1,18 +1,15 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { useNavigate } from "react-router";
import { type ListVolumesResponse, listVolumes } from "~/api-client"; import { type ListVolumesResponse, listVolumes } from "~/api-client";
import { deleteVolumeMutation, listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { CreateVolumeDialog } from "~/components/create-volume-dialog";
import { EditVolumeDialog } from "~/components/edit-volume-dialog"; import { EditVolumeDialog } from "~/components/edit-volume-dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon"; import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
@@ -35,23 +32,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
const [volumeToEdit, setVolumeToEdit] = useState<ListVolumesResponse["volumes"][number]>(); const [volumeToEdit, setVolumeToEdit] = useState<ListVolumesResponse["volumes"][number]>();
const [createVolumeOpen, setCreateVolumeOpen] = useState(false); const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const deleteVol = useMutation({ const navigate = useNavigate();
...deleteVolumeMutation(),
onSuccess: () => {
toast.success("Volume deleted successfully");
},
onError: (error) => {
toast.error("Failed to delete volume", {
description: parseError(error)?.message,
});
},
});
const handleDeleteConfirm = (name: string) => {
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) {
deleteVol.mutate({ path: { name } });
}
};
const { data } = useQuery({ const { data } = useQuery({
...listVolumesOptions(), ...listVolumesOptions(),
@@ -59,103 +40,81 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}); });
return ( return (
<div <>
className={cn( <h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
"absolute inset-0", <h2 className="text-sm font-semibold mb-2 text-muted-foreground">
"[background-size:40px_40px]", Create, manage, monitor, and automate your volumes with ease.
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", </h2>
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", <div className="flex items-center gap-2 mt-4 justify-between">
)} <span className="flex items-center gap-2">
> <Input className="w-[180px]" placeholder="Search volumes…" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div> <Select>
<main className="relative flex flex-col pt-16 p-4 container mx-auto"> <SelectTrigger className="w-[180px]">
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1> <SelectValue placeholder="All status" />
<h2 className="text-sm font-semibold mb-2 text-muted-foreground"> </SelectTrigger>
Create, manage, monitor, and automate your volumes with ease. <SelectContent>
</h2> <SelectItem value="mounted">Mounted</SelectItem>
<div className="flex items-center gap-2 mt-4 justify-between"> <SelectItem value="unmounted">Unmounted</SelectItem>
<span className="flex items-center gap-2"> <SelectItem value="error">Error</SelectItem>
<Input className="w-[180px]" placeholder="Search volumes…" /> </SelectContent>
<Select> </Select>
<SelectTrigger className="w-[180px]"> <Select>
<SelectValue placeholder="All status" /> <SelectTrigger className="w-[180px]">
</SelectTrigger> <SelectValue placeholder="All backends" />
<SelectContent> </SelectTrigger>
<SelectItem value="mounted">Mounted</SelectItem> <SelectContent>
<SelectItem value="unmounted">Unmounted</SelectItem> <SelectItem value="directory">Directory</SelectItem>
<SelectItem value="error">Error</SelectItem> <SelectItem value="nfs">NFS</SelectItem>
</SelectContent> <SelectItem value="smb">SMB</SelectItem>
</Select> </SelectContent>
<Select> </Select>
<SelectTrigger className="w-[180px]"> </span>
<SelectValue placeholder="All backends" /> <CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</SelectTrigger> </div>
<SelectContent> <Table className="mt-4 border bg-white dark:bg-secondary">
<SelectItem value="directory">Directory</SelectItem> <TableCaption>A list of your managed volumes.</TableCaption>
<SelectItem value="nfs">NFS</SelectItem> <TableHeader>
<SelectItem value="smb">SMB</SelectItem> <TableRow>
</SelectContent> <TableHead className="w-[100px] uppercase">Name</TableHead>
</Select> <TableHead className="uppercase text-left">Backend</TableHead>
</span> <TableHead className="uppercase">Mountpoint</TableHead>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} /> <TableHead className="uppercase text-center">Status</TableHead>
</div> </TableRow>
<Table className="mt-4 border bg-white dark:bg-secondary"> </TableHeader>
<TableCaption>A list of your managed volumes.</TableCaption> <TableBody>
<TableHeader> {data?.volumes.map((volume) => (
<TableRow> <TableRow
<TableHead className="w-[100px] uppercase">Name</TableHead> key={volume.name}
<TableHead className="uppercase text-left">Backend</TableHead> className="hover:bg-accent/50 hover:cursor-pointer"
<TableHead className="uppercase">Mountpoint</TableHead> onClick={() => navigate(`/volumes/${volume.name}`)}
<TableHead className="uppercase text-center">Status</TableHead> >
<TableHead className="uppercase text-right">Actions</TableHead> <TableCell className="font-medium">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<span className="relative flex size-3 mx-auto">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
</span>
</TableCell>
</TableRow> </TableRow>
</TableHeader> ))}
<TableBody> </TableBody>
{data?.volumes.map((volume) => ( </Table>
<TableRow key={volume.name}> <EditVolumeDialog
<TableCell className="font-medium">{volume.name}</TableCell> open={Boolean(volumeToEdit)}
<TableCell> setOpen={() => setVolumeToEdit(undefined)}
<VolumeIcon backend={volume.type} /> initialValues={volumeToEdit}
</TableCell> />
<TableCell> </>
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<span className="relative flex size-3 mx-auto">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
onClick={() => {
setVolumeToEdit(volume);
}}
>
Edit
</Button>
<Button type="button" variant="destructive" onClick={() => handleDeleteConfirm(volume.name)}>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<EditVolumeDialog
open={Boolean(volumeToEdit)}
setOpen={() => setVolumeToEdit(undefined)}
initialValues={volumeToEdit}
/>
</main>
</div>
); );
} }

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1", "@react-router/serve": "^7.7.1",
"@tanstack/react-query": "^5.84.2", "@tanstack/react-query": "^5.84.2",

View File

@@ -22,10 +22,16 @@ const mount = async (config: BackendConfig, path: string) => {
const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`; const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`;
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Mount command timed out"));
}, 5000);
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout, stderr) => {
console.log("Mount command executed:", { cmd, error, stdout, stderr }); console.log("Mount command executed:", { cmd, error, stdout, stderr });
clearTimeout(timeout);
if (error) { if (error) {
// console.error(`Error mounting NFS volume: ${stderr}`); console.error(`Error mounting NFS volume: ${stderr}`);
return reject(new Error(`Failed to mount NFS volume: ${stderr}`)); return reject(new Error(`Failed to mount NFS volume: ${stderr}`));
} }
console.log(`NFS volume mounted successfully: ${stdout}`); console.log(`NFS volume mounted successfully: ${stdout}`);
@@ -43,8 +49,14 @@ const unmount = async (path: string) => {
const cmd = `umount -f ${path}`; const cmd = `umount -f ${path}`;
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Mount command timed out"));
}, 5000);
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout, stderr) => {
console.log("Unmount command executed:", { cmd, error, stdout, stderr }); console.log("Unmount command executed:", { cmd, error, stdout, stderr });
clearTimeout(timeout);
if (error) { if (error) {
console.error(`Error unmounting NFS volume: ${stderr}`); console.error(`Error unmounting NFS volume: ${stderr}`);
return reject(new Error(`Failed to unmount NFS volume: ${stderr}`)); return reject(new Error(`Failed to unmount NFS volume: ${stderr}`));

View File

@@ -12,6 +12,7 @@ import {
testConnectionDto, testConnectionDto,
updateVolumeBody, updateVolumeBody,
updateVolumeDto, updateVolumeDto,
type VolumeDto,
} from "./volume.dto"; } from "./volume.dto";
import { volumeService } from "./volume.service"; import { volumeService } from "./volume.service";
@@ -68,13 +69,11 @@ export const volumeController = new Hono()
} }
const response = { const response = {
name: res.volume.name, ...res.volume,
path: res.volume.path,
type: res.volume.type,
createdAt: res.volume.createdAt.getTime(), createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(), updatedAt: res.volume.updatedAt.getTime(),
config: res.volume.config, lastHealthCheck: res.volume.lastHealthCheck.getTime(),
}; } satisfies VolumeDto;
return c.json(response, 200); return c.json(response, 200);
}) })

View File

@@ -15,6 +15,8 @@ const volumeSchema = type({
config: volumeConfigSchema, config: volumeConfigSchema,
}); });
export type VolumeDto = typeof volumeSchema.infer;
/** /**
* List all volumes * List all volumes
*/ */

View File

@@ -108,25 +108,17 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => {
return { error: new NotFoundError("Volume not found") }; return { error: new NotFoundError("Volume not found") };
} }
const oldBackend = createVolumeBackend(existing);
await oldBackend.unmount().catch((err) => {
console.warn("Failed to unmount backend:", err);
});
const updated = await db const updated = await db
.update(volumesTable) .update(volumesTable)
.set({ .set({
config: backendConfig, config: backendConfig,
type: backendConfig.backend, type: backendConfig.backend,
updatedAt: new Date(), updatedAt: new Date(),
status: "unmounted",
}) })
.where(eq(volumesTable.name, name)) .where(eq(volumesTable.name, name))
.returning(); .returning();
// Mount with new configuration
const newBackend = createVolumeBackend(updated[0]);
await newBackend.mount();
return { volume: updated[0] }; return { volume: updated[0] };
} catch (error) { } catch (error) {
return { return {

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1", "@react-router/serve": "^7.7.1",
"@tanstack/react-query": "^5.84.2", "@tanstack/react-query": "^5.84.2",
@@ -287,6 +288,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],