mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: docker usage examples & statfs
This commit is contained in:
@@ -13,7 +13,7 @@ export type ListVolumesResponses = {
|
||||
*/
|
||||
200: {
|
||||
volumes: Array<{
|
||||
autoRemount: boolean;
|
||||
autoRemount: 0 | 1;
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
@@ -72,7 +72,6 @@ export type CreateVolumeResponses = {
|
||||
201: {
|
||||
message: string;
|
||||
volume: {
|
||||
createdAt: number;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
@@ -156,29 +155,36 @@ export type GetVolumeResponses = {
|
||||
* Volume details
|
||||
*/
|
||||
200: {
|
||||
autoRemount: boolean;
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
exportPath: string;
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number | string;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
};
|
||||
createdAt: number;
|
||||
lastError: string;
|
||||
lastHealthCheck: number;
|
||||
name: string;
|
||||
path: string;
|
||||
status: "error" | "mounted" | "unknown" | "unmounted";
|
||||
type: "directory" | "nfs" | "smb";
|
||||
updatedAt: number;
|
||||
statfs: {
|
||||
free: number;
|
||||
total: number;
|
||||
used: number;
|
||||
};
|
||||
volume: {
|
||||
autoRemount: 0 | 1;
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
exportPath: string;
|
||||
server: string;
|
||||
version: "3" | "4" | "4.1";
|
||||
port?: number | string;
|
||||
}
|
||||
| {
|
||||
backend: "smb";
|
||||
};
|
||||
createdAt: 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
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
status: "error" | "mounted" | "unmounted";
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -295,7 +302,8 @@ export type UnmountVolumeResponses = {
|
||||
* Volume unmounted successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
status: "error" | "mounted" | "unmounted";
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
115
apps/client/app/components/bytes-size.tsx
Normal file
115
apps/client/app/components/bytes-size.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/client/app/components/ui/code-block.tsx
Normal file
46
apps/client/app/components/ui/code-block.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
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"];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
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";
|
||||
import type { Volume } from "~/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: GetVolumeResponse;
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const HealthchecksCard = ({ volume }: Props) => {
|
||||
@@ -28,7 +28,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
Remount on error
|
||||
<Switch className="ml-auto cursor-pointer" checked={volume.autoRemount} />
|
||||
<Switch className="ml-auto cursor-pointer" checked={Boolean(volume.autoRemount)} />
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline">Run Health Check</Button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Card } from "~/components/ui/card";
|
||||
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 { CodeBlock } from "~/components/ui/code-block";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
@@ -27,23 +25,16 @@ export const DockerTabContent = ({ volume }: Props) => {
|
||||
return (
|
||||
<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">
|
||||
<CodeMirror readOnly={true} value={yamlString} height="200px" extensions={[yaml()]} theme={copilot} />
|
||||
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">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
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
|
||||
the container to fit your application's needs.
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { CreateVolumeForm } from "~/components/create-volume-form";
|
||||
import { Card } from "~/components/ui/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 = {
|
||||
volume: Volume;
|
||||
statfs: StatFs;
|
||||
};
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume }: Props) => {
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
return (
|
||||
<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">
|
||||
@@ -16,6 +18,11 @@ export const VolumeInfoTabContent = ({ volume }: Props) => {
|
||||
<HealthchecksCard volume={volume} />
|
||||
<Card className="p-6 h-full">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,16 @@ import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
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) => {
|
||||
const volume = await getVolume({ path: { name: params.name ?? "" } });
|
||||
if (volume.data) return volume.data;
|
||||
@@ -83,15 +93,18 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
const { volume, statfs } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 text-muted-foreground 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>
|
||||
<VolumeIcon size={14} backend={data?.config.backend} />
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
@@ -99,7 +112,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
variant="secondary"
|
||||
onClick={() => mountVol.mutate({ path: { name } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: data.status === "mounted" })}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
Mount
|
||||
</Button>
|
||||
@@ -107,7 +120,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { name } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: data.status !== "mounted" })}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
Unmount
|
||||
</Button>
|
||||
@@ -122,10 +135,10 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
<TabsTrigger value="docker">Docker usage</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={data} />
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={data} />
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Copy, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { type ListVolumesResponse, listVolumes } from "~/api-client";
|
||||
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { CreateVolumeDialog } from "~/components/create-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { VolumeIcon } from "~/components/volume-icon";
|
||||
import type { Route } from "./+types/home";
|
||||
import { StatusDot } from "~/components/status-dot";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
@@ -32,6 +33,15 @@ export const clientLoader = async () => {
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const [volumeToEdit, setVolumeToEdit] = useState<ListVolumesResponse["volumes"][number]>();
|
||||
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();
|
||||
|
||||
@@ -40,6 +50,14 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
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 (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
||||
@@ -48,8 +66,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
</h2>
|
||||
<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…" />
|
||||
<Select>
|
||||
<Input
|
||||
className="w-[180px]"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
@@ -59,7 +82,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
@@ -69,6 +92,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
</div>
|
||||
@@ -83,7 +112,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.volumes.map((volume) => (
|
||||
{filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
|
||||
25
apps/client/app/utils/clipboard.ts
Normal file
25
apps/client/app/utils/clipboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@ironmount/schemas": "workspace:*",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -25,8 +24,6 @@
|
||||
"@tanstack/react-query": "^5.84.2",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/codemirror-theme-copilot": "^4.25.2",
|
||||
"@uiw/react-codemirror": "^4.25.2",
|
||||
"arktype": "^2.1.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -34,6 +31,7 @@
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
@@ -46,6 +44,7 @@
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
testConnectionDto,
|
||||
updateVolumeBody,
|
||||
updateVolumeDto,
|
||||
type VolumeDto,
|
||||
mountVolumeDto,
|
||||
unmountVolumeDto,
|
||||
type GetVolumeResponseDto,
|
||||
} from "./volume.dto";
|
||||
import { volumeService } from "./volume.service";
|
||||
|
||||
@@ -55,11 +55,14 @@ export const volumeController = new Hono()
|
||||
const res = await volumeService.getVolume(name);
|
||||
|
||||
const response = {
|
||||
...res.volume,
|
||||
createdAt: res.volume.createdAt.getTime(),
|
||||
updatedAt: res.volume.updatedAt.getTime(),
|
||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||
} satisfies VolumeDto;
|
||||
...res,
|
||||
volume: {
|
||||
...res.volume,
|
||||
createdAt: res.volume.createdAt.getTime(),
|
||||
updatedAt: res.volume.updatedAt.getTime(),
|
||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||
},
|
||||
} satisfies GetVolumeResponseDto;
|
||||
|
||||
return c.json(response, 200);
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -113,7 +123,7 @@ export const getVolumeDto = describeRoute({
|
||||
description: "Volume details",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(volumeSchema),
|
||||
schema: resolver(getVolumeResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@ import { db } from "../../db/db";
|
||||
import { volumesTable } from "../../db/schema";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { getStatFs } from "../../utils/mountinfo";
|
||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||
|
||||
const listVolumes = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
@@ -99,11 +101,13 @@ const getVolume = async (name: string) => {
|
||||
where: eq(volumesTable.name, name),
|
||||
});
|
||||
|
||||
const statfs = await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`);
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
}
|
||||
|
||||
return { volume };
|
||||
return { volume, statfs };
|
||||
};
|
||||
|
||||
const updateVolume = async (name: string, backendConfig: BackendConfig) => {
|
||||
|
||||
@@ -51,3 +51,13 @@ export async function getMountForPath(p: string): Promise<MountInfo | undefined>
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user