mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers * refactor: correct paths for openapi & drizzle * refactor: move api-client to client * fix: drizzle paths * chore: fix linting issues * fix: form reset issue
This commit is contained in:
90
app/client/modules/volumes/components/healthchecks-card.tsx
Normal file
90
app/client/modules/volumes/components/healthchecks-card.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { HeartIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const HealthchecksCard = ({ volume }: Props) => {
|
||||
const timeAgo = formatDistanceToNow(volume.lastHealthCheck, {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const healthcheck = useMutation({
|
||||
...healthCheckVolumeMutation(),
|
||||
onSuccess: (d) => {
|
||||
if (d.error) {
|
||||
toast.error("Health check failed", { description: d.error });
|
||||
return;
|
||||
}
|
||||
toast.success("Health check completed", { description: "The volume is healthy." });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Health check failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAutoRemount = useMutation({
|
||||
...updateVolumeMutation(),
|
||||
onSuccess: (d) => {
|
||||
toast.success("Volume updated", {
|
||||
description: `Auto remount is now ${d.autoRemount ? "enabled" : "paused"}.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Update failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex-1 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HeartIcon className="h-4 w-4" />
|
||||
Health Checks
|
||||
</CardTitle>
|
||||
<CardDescription>Monitor and automatically remount volumes on errors to ensure availability.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col flex-1 justify-start">
|
||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
||||
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>}
|
||||
{volume.status !== "unmounted" && (
|
||||
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>
|
||||
)}
|
||||
<span className="flex justify-between items-center gap-2">
|
||||
<span className="text-sm">Remount on error</span>
|
||||
<OnOff
|
||||
isOn={volume.autoRemount}
|
||||
toggle={() =>
|
||||
toggleAutoRemount.mutate({ path: { name: volume.name }, body: { autoRemount: !volume.autoRemount } })
|
||||
}
|
||||
disabled={toggleAutoRemount.isPending}
|
||||
enabledLabel="Enabled"
|
||||
disabledLabel="Paused"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{volume.status !== "unmounted" && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
loading={healthcheck.isPending}
|
||||
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
|
||||
>
|
||||
Run Health Check
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
133
app/client/modules/volumes/components/storage-chart.tsx
Normal file
133
app/client/modules/volumes/components/storage-chart.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { HardDrive, Unplug } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Label, Pie, PieChart } from "recharts";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
|
||||
import type { StatFs } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
statfs: StatFs;
|
||||
};
|
||||
|
||||
export function StorageChart({ statfs }: Props) {
|
||||
const chartData = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Used",
|
||||
value: statfs.used,
|
||||
fill: "#ff543a",
|
||||
},
|
||||
{
|
||||
name: "Free",
|
||||
value: statfs.free,
|
||||
fill: "lightgray",
|
||||
},
|
||||
],
|
||||
[statfs],
|
||||
);
|
||||
|
||||
const chartConfig = {} satisfies ChartConfig;
|
||||
|
||||
const usagePercentage = React.useMemo(() => {
|
||||
return Math.round((statfs.used / statfs.total) * 100);
|
||||
}, [statfs]);
|
||||
|
||||
const isEmpty = !statfs.total;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Card className="flex flex-col h-full text-sm">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-10 flex flex-col items-center justify-center text-center">
|
||||
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No storage data available. Mount the volume to see usage statistics.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full text-sm">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => [<ByteSize key={name} bytes={value as number} />, name]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={60} strokeWidth={5}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
|
||||
{usagePercentage}%
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
|
||||
Used
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="flex flex-col h-full justify-center">
|
||||
<div className="grid gap-4 w-full">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Total Capacity</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-4 rounded-full bg-strong-accent" />
|
||||
<span className="font-medium">Used Space</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-4 rounded-full bg-primary" />
|
||||
<span className="font-medium">Free Space</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
197
app/client/modules/volumes/routes/volume-details.tsx
Normal file
197
app/client/modules/volumes/routes/volume-details.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { Route } from "./+types/volume-details";
|
||||
import { VolumeInfoTabContent } from "../tabs/info";
|
||||
import { FilesTabContent } from "../tabs/files";
|
||||
import { DockerTabContent } from "../tabs/docker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { name: params.name } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume deleted successfully");
|
||||
navigate("/volumes");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mountVol = useMutation({
|
||||
...mountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume mounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to mount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unmountVol = useMutation({
|
||||
...unmountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume unmounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to unmount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteVol.mutate({ path: { name: name ?? "" } });
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const { volume, statfs } = data;
|
||||
const dockerAvailable = capabilities.docker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => mountVol.mutate({ path: { name } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
Mount
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { name } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
Unmount
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: dockerAvailable })}>
|
||||
<p>Enable Docker support to access this tab.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
{dockerAvailable && (
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete volume
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
170
app/client/modules/volumes/routes/volumes.tsx
Normal file
170
app/client/modules/volumes/routes/volumes.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import type { Route } from "./+types/volumes";
|
||||
import { listVolumes } from "~/client/api-client";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const volumes = await listVolumes();
|
||||
if (volumes.data) return volumes.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
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 { data } = useQuery({
|
||||
...listVolumesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredVolumes =
|
||||
data.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;
|
||||
}) || [];
|
||||
|
||||
const hasNoVolumes = data.length === 0;
|
||||
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||
|
||||
if (hasNoVolumes) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
title="No volume"
|
||||
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mounted">Mounted</SelectItem>
|
||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredVolumes ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No volumes match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
<VolumeIcon backend={volume.type} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusDot status={volume.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
{hasNoFilteredVolumes ? (
|
||||
"No volumes match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
|
||||
{filteredVolumes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
app/client/modules/volumes/tabs/docker.tsx
Normal file
133
app/client/modules/volumes/tabs/docker.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Unplug } from "lucide-react";
|
||||
import * as YML from "yaml";
|
||||
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const DockerTabContent = ({ volume }: Props) => {
|
||||
const yamlString = YML.stringify({
|
||||
services: {
|
||||
nginx: {
|
||||
image: "nginx:latest",
|
||||
volumes: [`im-${volume.name}:/path/in/container`],
|
||||
},
|
||||
},
|
||||
volumes: {
|
||||
[`im-${volume.name}`]: {
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
|
||||
|
||||
const {
|
||||
data: containersData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
...getContainersUsingVolumeOptions({ path: { name: volume.name } }),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const containers = containersData || [];
|
||||
|
||||
const getStateClass = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "exited":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plug-and-play Docker integration</CardTitle>
|
||||
<CardDescription>
|
||||
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
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Alternatively, you can use the following command to run a Docker container with the volume mounted
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<CodeBlock code={dockerRunCommand} filename="CLI one-liner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Containers Using This Volume</CardTitle>
|
||||
<CardDescription>List of Docker containers mounting this volume.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 text-sm h-full">
|
||||
{isLoading && <div>Loading containers...</div>}
|
||||
{error && <div className="text-destructive">Failed to load containers: {String(error)}</div>}
|
||||
{!isLoading && !error && containers.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center text-center h-full">
|
||||
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No Docker containers are currently using this volume.</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && containers.length > 0 && (
|
||||
<div className="max-h-130 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Image</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-sm">
|
||||
{containers.map((container) => (
|
||||
<TableRow key={container.id}>
|
||||
<TableCell>{container.name}</TableCell>
|
||||
<TableCell>{container.id.slice(0, 12)}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStateClass(container.state)}`}
|
||||
>
|
||||
{container.state}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{container.image}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
app/client/modules/volumes/tabs/files.tsx
Normal file
40
app/client/modules/volumes/tabs/files.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const FilesTabContent = ({ volume }: Props) => {
|
||||
if (volume.status !== "mounted") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-[600px] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>File Explorer</CardTitle>
|
||||
<CardDescription>Browse the files and folders in this volume.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col">
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
enabled={volume.status === "mounted"}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||
emptyMessage="This volume is empty."
|
||||
emptyDescription="Files and folders will appear here once you add them."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
95
app/client/modules/volumes/tabs/info.tsx
Normal file
95
app/client/modules/volumes/tabs/info.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import type { StatFs, Volume } from "~/client/lib/types";
|
||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||
import { StorageChart } from "../components/storage-chart";
|
||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
statfs: StatFs;
|
||||
};
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
const updateMutation = useMutation({
|
||||
...updateVolumeMutation(),
|
||||
onSuccess: (_) => {
|
||||
toast.success("Volume updated successfully");
|
||||
setOpen(false);
|
||||
setPendingValues(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update volume", { description: error.message });
|
||||
setOpen(false);
|
||||
setPendingValues(null);
|
||||
},
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
setPendingValues(values);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const confirmUpdate = () => {
|
||||
if (pendingValues) {
|
||||
updateMutation.mutate({
|
||||
path: { name: volume.name },
|
||||
body: { config: pendingValues },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
|
||||
<Card className="p-6">
|
||||
<CreateVolumeForm
|
||||
initialValues={{ ...volume, ...volume.config }}
|
||||
onSubmit={handleSubmit}
|
||||
mode="update"
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="self-start w-full">
|
||||
<HealthchecksCard volume={volume} />
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<StorageChart statfs={statfs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Volume Configuration</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Editing the volume will remount it with the new config immediately. This may temporarily disrupt access to
|
||||
the volume. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user