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: {
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;
};
};

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";
export type Volume = GetVolumeResponse;
export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"];

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
</>

View File

@@ -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"

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();
}
}
}