From c261590ea355b3eba4a83b61627a5be8507bb16e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 25 Sep 2025 21:13:49 +0200 Subject: [PATCH] feat: docker usage examples & statfs --- apps/client/app/api-client/types.gen.ts | 62 ++++++---- apps/client/app/components/bytes-size.tsx | 115 ++++++++++++++++++ apps/client/app/components/ui/code-block.tsx | 46 +++++++ apps/client/app/lib/types.ts | 3 +- .../details/components/healthchecks-card.tsx | 6 +- .../app/modules/details/tabs/docker.tsx | 23 ++-- apps/client/app/modules/details/tabs/info.tsx | 11 +- apps/client/app/routes/details.tsx | 25 +++- apps/client/app/routes/home.tsx | 41 ++++++- apps/client/app/utils/clipboard.ts | 25 ++++ apps/client/package.json | 5 +- .../src/modules/volumes/volume.controller.ts | 15 ++- apps/server/src/modules/volumes/volume.dto.ts | 12 +- .../src/modules/volumes/volume.service.ts | 6 +- apps/server/src/utils/mountinfo.ts | 10 ++ bun.lock | 55 +-------- 16 files changed, 339 insertions(+), 121 deletions(-) create mode 100644 apps/client/app/components/bytes-size.tsx create mode 100644 apps/client/app/components/ui/code-block.tsx create mode 100644 apps/client/app/utils/clipboard.ts diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 65bb89f..473fca2 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -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; }; }; diff --git a/apps/client/app/components/bytes-size.tsx b/apps/client/app/components/bytes-size.tsx new file mode 100644 index 0000000..fbe1b15 --- /dev/null +++ b/apps/client/app/components/bytes-size.tsx @@ -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 ( + + {fallback} + + ); + } + + return ( + + {text} + {space ? " " : ""} + {unit} + + ); +} diff --git a/apps/client/app/components/ui/code-block.tsx b/apps/client/app/components/ui/code-block.tsx new file mode 100644 index 0000000..5047616 --- /dev/null +++ b/apps/client/app/components/ui/code-block.tsx @@ -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 = ({ code, language = "jsx", filename }) => { + useEffect(() => { + Prism.highlightAll(); + }, []); + + const handleCopy = async () => { + await copyToClipboard(code); + toast.success("Code copied to clipboard"); + }; + + return ( +
+
+
+ + + + {filename && {filename}} +
+ +
+
+				{code}
+			
+
+ ); +}; diff --git a/apps/client/app/lib/types.ts b/apps/client/app/lib/types.ts index 9aea22f..033e686 100644 --- a/apps/client/app/lib/types.ts +++ b/apps/client/app/lib/types.ts @@ -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"]; diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/details/components/healthchecks-card.tsx index 994dd77..2bc03a6 100644 --- a/apps/client/app/modules/details/components/healthchecks-card.tsx +++ b/apps/client/app/modules/details/components/healthchecks-card.tsx @@ -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) => { )} Remount on error - + diff --git a/apps/client/app/modules/details/tabs/docker.tsx b/apps/client/app/modules/details/tabs/docker.tsx index af50986..59ed2c2 100644 --- a/apps/client/app/modules/details/tabs/docker.tsx +++ b/apps/client/app/modules/details/tabs/docker.tsx @@ -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 (
- - Alternatively, you can use the following command to run a Docker container with the volume mounted: - - - -

Using the volume with Docker

-
+
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.
+ +
+ Alternatively, you can use the following command to run a Docker container with the volume mounted: +
+
); diff --git a/apps/client/app/modules/details/tabs/info.tsx b/apps/client/app/modules/details/tabs/info.tsx index 7e4a6a0..d1e138d 100644 --- a/apps/client/app/modules/details/tabs/info.tsx +++ b/apps/client/app/modules/details/tabs/info.tsx @@ -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 (
@@ -16,6 +18,11 @@ export const VolumeInfoTabContent = ({ volume }: Props) => {

Volume Information

+ Total: +
+ Free: +
+ Used:
); diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx index 742eb4b..903c163 100644 --- a/apps/client/app/routes/details.tsx +++ b/apps/client/app/routes/details.tsx @@ -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
Loading...
; } + console.log(data); + const { volume, statfs } = data; + return ( <>
- {data.status[0].toUpperCase() + data.status.slice(1)} + {volume.status[0].toUpperCase() + volume.status.slice(1)} - +
@@ -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 @@ -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 @@ -122,10 +135,10 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) { Docker usage - + - + diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index ee0c1c9..ff443dd 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -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(); 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 ( <>

Ironmount

@@ -48,8 +66,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
- - setSearchQuery(e.target.value)} + /> + - @@ -69,6 +92,12 @@ export default function Home({ loaderData }: Route.ComponentProps) { SMB + {(searchQuery || statusFilter || backendFilter) && ( + + )}
@@ -83,7 +112,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { - {data?.volumes.map((volume) => ( + {filteredVolumes.map((volume) => (