feat: docker usage examples & statfs

This commit is contained in:
Nicolas Meienberger
2025-09-25 21:13:49 +02:00
parent 86f7ae8a89
commit c261590ea3
16 changed files with 339 additions and 121 deletions

View File

@@ -13,7 +13,7 @@ export type ListVolumesResponses = {
*/ */
200: { 200: {
volumes: Array<{ volumes: Array<{
autoRemount: boolean; autoRemount: 0 | 1;
config: config:
| { | {
backend: "directory"; backend: "directory";
@@ -72,7 +72,6 @@ export type CreateVolumeResponses = {
201: { 201: {
message: string; message: string;
volume: { volume: {
createdAt: number;
name: string; name: string;
path: string; path: string;
}; };
@@ -156,29 +155,36 @@ export type GetVolumeResponses = {
* Volume details * Volume details
*/ */
200: { 200: {
autoRemount: boolean; statfs: {
config: free: number;
| { total: number;
backend: "directory"; used: number;
} };
| { volume: {
backend: "nfs"; autoRemount: 0 | 1;
exportPath: string; config:
server: string; | {
version: "3" | "4" | "4.1"; backend: "directory";
port?: number | string; }
} | {
| { backend: "nfs";
backend: "smb"; exportPath: string;
}; server: string;
createdAt: number; version: "3" | "4" | "4.1";
lastError: string; port?: number | string;
lastHealthCheck: number; }
name: string; | {
path: string; backend: "smb";
status: "error" | "mounted" | "unknown" | "unmounted"; };
type: "directory" | "nfs" | "smb"; createdAt: number;
updatedAt: number; lastError: string;
lastHealthCheck: number;
name: string;
path: string;
status: "error" | "mounted" | "unknown" | "unmounted";
type: "directory" | "nfs" | "smb";
updatedAt: number;
};
}; };
}; };
@@ -268,7 +274,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully * Volume mounted successfully
*/ */
200: { 200: {
message: string; status: "error" | "mounted" | "unmounted";
error?: string;
}; };
}; };
@@ -295,7 +302,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully * Volume unmounted successfully
*/ */
200: { 200: {
message: string; status: "error" | "mounted" | "unmounted";
error?: string;
}; };
}; };

View File

@@ -0,0 +1,115 @@
import React from "react";
type ByteSizeProps = {
bytes: number;
base?: 1000 | 1024; // 1000 = SI (KB, MB, ...), 1024 = IEC (KiB, MiB, ...)
maximumFractionDigits?: number; // default: 2
smartRounding?: boolean; // dynamically reduces decimals for big numbers (default: true)
locale?: string | string[]; // e.g., 'en', 'de', or navigator.languages
space?: boolean; // space between number and unit (default: true)
className?: string;
style?: React.CSSProperties;
fallback?: string; // shown if bytes is not a finite number (default: '—')
};
const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const;
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const;
type FormatBytesResult = {
text: string;
unit: string;
unitIndex: number;
numeric: number; // numeric value before formatting (with sign)
};
export function formatBytes(
bytes: number,
options?: {
base?: 1000 | 1024;
maximumFractionDigits?: number;
smartRounding?: boolean;
locale?: string | string[];
},
): FormatBytesResult {
const { base = 1000, maximumFractionDigits = 2, smartRounding = true, locale } = options ?? {};
if (!Number.isFinite(bytes)) {
return {
text: "—",
unit: "",
unitIndex: 0,
numeric: NaN,
};
}
const units = base === 1024 ? IEC_UNITS : SI_UNITS;
const sign = Math.sign(bytes) || 1;
const abs = Math.abs(bytes);
let idx = 0;
if (abs > 0) {
idx = Math.floor(Math.log(abs) / Math.log(base));
if (!Number.isFinite(idx)) idx = 0;
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / Math.pow(base, idx)) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;
const v = Math.abs(numeric);
if (v >= 100) return 0;
if (v >= 10) return Math.min(1, maximumFractionDigits);
return maximumFractionDigits;
})();
const text = new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac,
}).format(numeric);
return {
text,
unit: units[idx],
unitIndex: idx,
numeric,
};
}
export function ByteSize(props: ByteSizeProps) {
const {
bytes,
base = 1000,
maximumFractionDigits = 2,
smartRounding = true,
locale,
space = true,
className,
style,
fallback = "—",
} = props;
const { text, unit } = formatBytes(bytes, {
base,
maximumFractionDigits,
smartRounding,
locale,
});
if (text === "—") {
return (
<span className={className} style={style}>
{fallback}
</span>
);
}
return (
<span className={className} style={style}>
{text}
{space ? " " : ""}
{unit}
</span>
);
}

View File

@@ -0,0 +1,46 @@
import React, { useEffect } from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-twilight.css";
import "prismjs/components/prism-yaml";
import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard";
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => {
useEffect(() => {
Prism.highlightAll();
}, []);
const handleCopy = async () => {
await copyToClipboard(code);
toast.success("Code copied to clipboard");
};
return (
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>}
</div>
<button
type="button"
onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
>
Copy
</button>
</div>
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}>
<code className={`language-${language}`}>{code}</code>
</pre>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import type { GetVolumeResponse } from "~/api-client"; import type { GetVolumeResponse } from "~/api-client";
export type Volume = GetVolumeResponse; export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"]; export type VolumeStatus = Volume["status"];

View File

@@ -1,12 +1,12 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { ScanHeartIcon } from "lucide-react"; import { ScanHeartIcon } from "lucide-react";
import type { GetVolumeResponse } from "~/api-client";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import type { Volume } from "~/lib/types";
type Props = { type Props = {
volume: GetVolumeResponse; volume: Volume;
}; };
export const HealthchecksCard = ({ volume }: Props) => { export const HealthchecksCard = ({ volume }: Props) => {
@@ -28,7 +28,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
)} )}
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
Remount on error Remount on error
<Switch className="ml-auto cursor-pointer" checked={volume.autoRemount} /> <Switch className="ml-auto cursor-pointer" checked={Boolean(volume.autoRemount)} />
</span> </span>
</div> </div>
<Button variant="outline">Run Health Check</Button> <Button variant="outline">Run Health Check</Button>

View File

@@ -1,9 +1,7 @@
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/lib/types";
import CodeMirror from "@uiw/react-codemirror";
import { yaml } from "@codemirror/lang-yaml";
import { copilot } from "@uiw/codemirror-theme-copilot";
import * as YML from "yaml"; import * as YML from "yaml";
import { CodeBlock } from "~/components/ui/code-block";
type Props = { type Props = {
volume: Volume; volume: Volume;
@@ -27,23 +25,16 @@ export const DockerTabContent = ({ volume }: Props) => {
return ( return (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[auto_1fr]"> <div className="grid gap-4 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[auto_1fr]">
<Card className="p-6 lg:col-span-2 lg:row-span-2"> <Card className="p-6 lg:col-span-2 lg:row-span-2">
<CodeMirror readOnly={true} value={yamlString} height="200px" extensions={[yaml()]} theme={copilot} /> <div className="text-sm text-muted-foreground">
Alternatively, you can use the following command to run a Docker container with the volume mounted:
<CodeMirror
readOnly={true}
value={`docker run -v ${volume.name}:/path/in/container nginx:latest`}
height="25px"
extensions={[yaml()]}
theme={copilot}
/>
</Card>
<Card className="p-6 h-full lg:row-span-2">
<h3 className="text-lg font-semibold mb-2 text-center">Using the volume with Docker</h3>
<div className="text-sm text-muted-foreground mb-4 text-center flex h-full">
This volume can be used in your Docker Compose files by referencing it as an external volume. The example This volume can be used in your Docker Compose files by referencing it as an external volume. The example
demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside
the container to fit your application's needs. the container to fit your application's needs.
</div> </div>
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
<div className="text-sm text-muted-foreground">
Alternatively, you can use the following command to run a Docker container with the volume mounted:
</div>
<CodeBlock code={`docker run -v ${volume.name}:/path/in/container nginx:latest`} />
</Card> </Card>
</div> </div>
); );

View File

@@ -1,13 +1,15 @@
import { CreateVolumeForm } from "~/components/create-volume-form"; import { CreateVolumeForm } from "~/components/create-volume-form";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { HealthchecksCard } from "../components/healthchecks-card"; import { HealthchecksCard } from "../components/healthchecks-card";
import type { Volume } from "~/lib/types"; import type { StatFs, Volume } from "~/lib/types";
import { ByteSize } from "~/components/bytes-size";
type Props = { type Props = {
volume: Volume; volume: Volume;
statfs: StatFs;
}; };
export const VolumeInfoTabContent = ({ volume }: Props) => { export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
return ( return (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[auto_1fr]"> <div className="grid gap-4 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[auto_1fr]">
<Card className="p-6 lg:col-span-2 lg:row-span-2"> <Card className="p-6 lg:col-span-2 lg:row-span-2">
@@ -16,6 +18,11 @@ export const VolumeInfoTabContent = ({ volume }: Props) => {
<HealthchecksCard volume={volume} /> <HealthchecksCard volume={volume} />
<Card className="p-6 h-full"> <Card className="p-6 h-full">
<h2 className="text-lg font-medium">Volume Information</h2> <h2 className="text-lg font-medium">Volume Information</h2>
Total: <ByteSize bytes={statfs.total} />
<br />
Free: <ByteSize bytes={statfs.free} />
<br />
Used: <ByteSize bytes={statfs.used} />
</Card> </Card>
</div> </div>
); );

View File

@@ -18,6 +18,16 @@ import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { DockerTabContent } from "~/modules/details/tabs/docker"; import { DockerTabContent } from "~/modules/details/tabs/docker";
export function meta({ params }: Route.MetaArgs) {
return [
{ title: "Ironmount - " + params.name },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name ?? "" } }); const volume = await getVolume({ path: { name: params.name ?? "" } });
if (volume.data) return volume.data; if (volume.data) return volume.data;
@@ -83,15 +93,18 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
return <div>Loading...</div>; return <div>Loading...</div>;
} }
console.log(data);
const { volume, statfs } = data;
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2"> <div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<StatusDot status={data.status} /> {data.status[0].toUpperCase() + data.status.slice(1)} <StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
</span> </span>
<VolumeIcon size={14} backend={data?.config.backend} /> <VolumeIcon size={14} backend={volume?.config.backend} />
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -99,7 +112,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
variant="secondary" variant="secondary"
onClick={() => mountVol.mutate({ path: { name } })} onClick={() => mountVol.mutate({ path: { name } })}
loading={mountVol.isPending} loading={mountVol.isPending}
className={cn({ hidden: data.status === "mounted" })} className={cn({ hidden: volume.status === "mounted" })}
> >
Mount Mount
</Button> </Button>
@@ -107,7 +120,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
variant="secondary" variant="secondary"
onClick={() => unmountVol.mutate({ path: { name } })} onClick={() => unmountVol.mutate({ path: { name } })}
loading={unmountVol.isPending} loading={unmountVol.isPending}
className={cn({ hidden: data.status !== "mounted" })} className={cn({ hidden: volume.status !== "mounted" })}
> >
Unmount Unmount
</Button> </Button>
@@ -122,10 +135,10 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
<TabsTrigger value="docker">Docker usage</TabsTrigger> <TabsTrigger value="docker">Docker usage</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={data} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent> </TabsContent>
<TabsContent value="docker"> <TabsContent value="docker">
<DockerTabContent volume={data} /> <DockerTabContent volume={volume} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</> </>

View File

@@ -1,17 +1,18 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react"; import { Copy, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { type ListVolumesResponse, listVolumes } from "~/api-client"; import { type ListVolumesResponse, listVolumes } from "~/api-client";
import { 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 { StatusDot } from "~/components/status-dot";
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 type { Route } from "./+types/home"; import type { Route } from "./+types/home";
import { StatusDot } from "~/components/status-dot";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -32,6 +33,15 @@ export const clientLoader = async () => {
export default function Home({ loaderData }: Route.ComponentProps) { 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 [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,6 +50,14 @@ export default function Home({ loaderData }: Route.ComponentProps) {
initialData: loaderData, initialData: loaderData,
}); });
const filteredVolumes =
data?.volumes.filter((volume) => {
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || volume.status === statusFilter;
const matchesBackend = !backendFilter || volume.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
return ( return (
<> <>
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1> <h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
@@ -48,8 +66,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</h2> </h2>
<div className="flex items-center gap-2 mt-4 justify-between"> <div className="flex items-center gap-2 mt-4 justify-between">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Input className="w-[180px]" placeholder="Search volumes…" /> <Input
<Select> className="w-[180px]"
placeholder="Search volumes…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
@@ -59,7 +82,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<SelectItem value="error">Error</SelectItem> <SelectItem value="error">Error</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
@@ -69,6 +92,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<SelectItem value="smb">SMB</SelectItem> <SelectItem value="smb">SMB</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || statusFilter || backendFilter) && (
<Button variant="outline" size="sm" onClick={clearFilters}>
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span> </span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} /> <CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</div> </div>
@@ -83,7 +112,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.volumes.map((volume) => ( {filteredVolumes.map((volume) => (
<TableRow <TableRow
key={volume.name} key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer" className="hover:bg-accent/50 hover:cursor-pointer"

View File

@@ -0,0 +1,25 @@
export async function copyToClipboard(textToCopy: string) {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(textToCopy);
} else {
// Use the 'out of viewport hidden text area' trick
const textArea = document.createElement("textarea");
textArea.value = textToCopy;
// Move textarea out of the viewport so it's not visible
textArea.style.position = "absolute";
textArea.style.left = "-999999px";
document.body.prepend(textArea);
textArea.select();
try {
document.execCommand("copy");
} catch (error) {
console.error(error);
} finally {
textArea.remove();
}
}
}

View File

@@ -9,7 +9,6 @@
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -25,8 +24,6 @@
"@tanstack/react-query": "^5.84.2", "@tanstack/react-query": "^5.84.2",
"@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-copilot": "^4.25.2",
"@uiw/react-codemirror": "^4.25.2",
"arktype": "^2.1.20", "arktype": "^2.1.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -34,6 +31,7 @@
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
@@ -46,6 +44,7 @@
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/node": "^20", "@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",

View File

@@ -11,9 +11,9 @@ import {
testConnectionDto, testConnectionDto,
updateVolumeBody, updateVolumeBody,
updateVolumeDto, updateVolumeDto,
type VolumeDto,
mountVolumeDto, mountVolumeDto,
unmountVolumeDto, unmountVolumeDto,
type GetVolumeResponseDto,
} from "./volume.dto"; } from "./volume.dto";
import { volumeService } from "./volume.service"; import { volumeService } from "./volume.service";
@@ -55,11 +55,14 @@ export const volumeController = new Hono()
const res = await volumeService.getVolume(name); const res = await volumeService.getVolume(name);
const response = { const response = {
...res.volume, ...res,
createdAt: res.volume.createdAt.getTime(), volume: {
updatedAt: res.volume.updatedAt.getTime(), ...res.volume,
lastHealthCheck: res.volume.lastHealthCheck.getTime(), createdAt: res.volume.createdAt.getTime(),
} satisfies VolumeDto; updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
} satisfies GetVolumeResponseDto;
return c.json(response, 200); return c.json(response, 200);
}) })

View File

@@ -100,6 +100,16 @@ export const deleteVolumeDto = describeRoute({
}, },
}); });
const getVolumeResponse = type({
volume: volumeSchema,
statfs: type({
total: "number",
used: "number",
free: "number",
}),
});
export type GetVolumeResponseDto = typeof getVolumeResponse.infer;
/** /**
* Get a volume * Get a volume
*/ */
@@ -113,7 +123,7 @@ export const getVolumeDto = describeRoute({
description: "Volume details", description: "Volume details",
content: { content: {
"application/json": { "application/json": {
schema: resolver(volumeSchema), schema: resolver(getVolumeResponse),
}, },
}, },
}, },

View File

@@ -10,6 +10,8 @@ import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { createVolumeBackend } from "../backends/backend"; import { createVolumeBackend } from "../backends/backend";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { getStatFs } from "../../utils/mountinfo";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
const listVolumes = async () => { const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@@ -99,11 +101,13 @@ const getVolume = async (name: string) => {
where: eq(volumesTable.name, name), where: eq(volumesTable.name, name),
}); });
const statfs = await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`);
if (!volume) { if (!volume) {
throw new NotFoundError("Volume not found"); throw new NotFoundError("Volume not found");
} }
return { volume }; return { volume, statfs };
}; };
const updateVolume = async (name: string, backendConfig: BackendConfig) => { const updateVolume = async (name: string, backendConfig: BackendConfig) => {

View File

@@ -51,3 +51,13 @@ export async function getMountForPath(p: string): Promise<MountInfo | undefined>
} }
return best; return best;
} }
export async function getStatFs(mountPoint: string) {
const stats = await fs.statfs(mountPoint);
const total = Number(stats.blocks) * Number(stats.bsize);
const free = Number(stats.bfree) * Number(stats.bsize);
const used = total - free;
return { total, used, free };
}

View File

@@ -11,7 +11,6 @@
"apps/client": { "apps/client": {
"name": "@ironmount/client", "name": "@ironmount/client",
"dependencies": { "dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@ironmount/schemas": "workspace:*", "@ironmount/schemas": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -27,8 +26,6 @@
"@tanstack/react-query": "^5.84.2", "@tanstack/react-query": "^5.84.2",
"@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-copilot": "^4.25.2",
"@uiw/react-codemirror": "^4.25.2",
"arktype": "^2.1.20", "arktype": "^2.1.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -36,6 +33,7 @@
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
@@ -48,6 +46,7 @@
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/node": "^20", "@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
@@ -145,32 +144,12 @@
"@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="], "@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.7", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ=="],
"@codemirror/commands": ["@codemirror/commands@6.8.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw=="],
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="],
"@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="],
"@codemirror/lint": ["@codemirror/lint@6.8.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA=="],
"@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="],
"@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.38.3", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-x2t87+oqwB1mduiQZ6huIghjMt4uZKFEdj66IcXw7+a5iBEvv9lh7EWDRHI7crnD4BMGpnyq/RzmCGbiEZLcvQ=="],
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="],
@@ -271,16 +250,6 @@
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
"@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="],
"@lezer/highlight": ["@lezer/highlight@1.2.1", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA=="],
"@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="],
"@lezer/yaml": ["@lezer/yaml@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
"@npmcli/git": ["@npmcli/git@4.1.0", "", { "dependencies": { "@npmcli/promise-spawn": "^6.0.0", "lru-cache": "^7.4.4", "npm-pick-manifest": "^8.0.0", "proc-log": "^3.0.0", "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^3.0.0" } }, "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ=="], "@npmcli/git": ["@npmcli/git@4.1.0", "", { "dependencies": { "@npmcli/promise-spawn": "^6.0.0", "lru-cache": "^7.4.4", "npm-pick-manifest": "^8.0.0", "proc-log": "^3.0.0", "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^3.0.0" } }, "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ=="],
@@ -467,20 +436,14 @@
"@types/node": ["@types/node@20.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow=="], "@types/node": ["@types/node@20.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow=="],
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="], "@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-s2fbpdXrSMWEc86moll/d007ZFhu6jzwNu5cWv/2o7egymvLeZO52LWkewgbr+BUCGWGPsoJVWeaejbsb/hLcw=="],
"@uiw/codemirror-theme-copilot": ["@uiw/codemirror-theme-copilot@4.25.2", "", { "dependencies": { "@uiw/codemirror-themes": "4.25.2" } }, "sha512-fsJbXVyeqZm1olA6arpZUI6oRSWvK4hKqzNnsefaVDXs4klSEbq5LG7oa/velqeV9W+/+Zenf9l8/h+sOYn94w=="],
"@uiw/codemirror-themes": ["@uiw/codemirror-themes@4.25.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-WFYxW3OlCkMomXQBlQdGj1JZ011UNCa7xYdmgYqywVc4E8f5VgIzRwCZSBNVjpWGGDBOjc+Z6F65l7gttP16pg=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.2", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-XP3R1xyE0CP6Q0iR0xf3ed+cJzJnfmbLelgJR6osVVtMStGGZP3pGQjjwDRYptmjGHfEELUyyBLdY25h0BQg7w=="],
"@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.4.11", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.7.0", "es-module-lexer": "^1.7.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17", "periscopic": "^4.0.2", "turbo-stream": "^3.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*", "vite": "*" } }, "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw=="], "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.4.11", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.7.0", "es-module-lexer": "^1.7.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17", "periscopic": "^4.0.2", "turbo-stream": "^3.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*", "vite": "*" } }, "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -547,8 +510,6 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], "color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="],
"color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
@@ -581,8 +542,6 @@
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], "cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -889,6 +848,8 @@
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"proc-log": ["proc-log@3.0.0", "", {}, "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A=="], "proc-log": ["proc-log@3.0.0", "", {}, "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A=="],
"promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="],
@@ -999,8 +960,6 @@
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"style-mod": ["style-mod@4.1.2", "", {}, "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="], "tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
@@ -1079,8 +1038,6 @@
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="], "which": ["which@3.0.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg=="],
"winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="],