Merge pull request #4142 from Dokploy/2267-add-a-disk-space-pie-chart

feat(dashboard): enhance monitoring charts with new Docker disk usage…
This commit is contained in:
Mauricio Siu
2026-04-03 21:29:28 -06:00
committed by GitHub
9 changed files with 656 additions and 419 deletions

View File

@@ -1,103 +1,103 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
Area, type ChartConfig,
AreaChart, ChartContainer,
CartesianGrid, ChartLegend,
Legend, ChartLegendContent,
ResponsiveContainer, ChartTooltip,
Tooltip, ChartTooltipContent,
YAxis, } from "@/components/ui/chart";
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["block"]; accumulativeData: DockerStatsJSON["block"];
} }
const chartConfig = {
readMb: {
label: "Read (MB)",
color: "hsl(var(--chart-1))",
},
writeMb: {
label: "Write (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerBlockChart = ({ accumulativeData }: Props) => { export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => { const transformedData = accumulativeData.map((item, index) => ({
return { time: item.time,
time: item.time, name: `Point ${index + 1}`,
name: `Point ${index + 1}`, readMb: item.value.readMb,
readMb: item.value.readMb, writeMb: item.value.writeMb,
writeMb: item.value.writeMb, }));
};
});
return ( return (
<div className="mt-6 w-full h-[10rem]"> <ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<ResponsiveContainer> <AreaChart
<AreaChart data={transformedData}
data={transformedData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
margin={{ >
top: 10, <defs>
right: 30, <linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
left: 0, <stop
bottom: 0, offset="5%"
}} stopColor="var(--color-readMb)"
> stopOpacity={0.8}
<defs> />
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} /> offset="95%"
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} /> stopColor="var(--color-readMb)"
</linearGradient> stopOpacity={0.1}
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1"> />
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} /> </linearGradient>
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} /> <linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop
</defs> offset="5%"
<YAxis stroke="#A1A1AA" /> stopColor="var(--color-writeMb)"
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" /> stopOpacity={0.8}
{/* @ts-ignore */} />
<Tooltip content={<CustomTooltip />} /> <stop
<Legend /> offset="95%"
<Area stopColor="var(--color-writeMb)"
type="monotone" stopOpacity={0.1}
dataKey="readMb" />
stroke="#27272A" </linearGradient>
fillOpacity={1} </defs>
fill="url(#colorUv)" <CartesianGrid vertical={false} />
name="Read Mb" <YAxis tickLine={false} axisLine={false} />
/> <ChartTooltip
<Area cursor={false}
type="monotone" content={
dataKey="writeMb" <ChartTooltipContent
stroke="#82ca9d" labelFormatter={(_, payload) => {
fillOpacity={1} const time = payload?.[0]?.payload?.time;
fill="url(#colorWrite)" return time ? format(new Date(time), "PPpp") : "";
name="Write Mb" }}
/> formatter={(value, name) => {
</AreaChart> const label = name === "readMb" ? "Read" : "Write";
</ResponsiveContainer> return [`${value} MB`, label];
</div> }}
/>
}
/>
<Area
type="monotone"
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
readMb: number;
writeMb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}
return null;
};

View File

@@ -1,87 +1,81 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
Area, type ChartConfig,
AreaChart, ChartContainer,
CartesianGrid, ChartLegend,
Legend, ChartLegendContent,
ResponsiveContainer, ChartTooltip,
Tooltip, ChartTooltipContent,
YAxis, } from "@/components/ui/chart";
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["cpu"]; accumulativeData: DockerStatsJSON["cpu"];
} }
const chartConfig = {
usage: {
label: "CPU Usage",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const DockerCpuChart = ({ accumulativeData }: Props) => { export const DockerCpuChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => { const transformedData = accumulativeData.map((item, index) => ({
return { name: `Point ${index + 1}`,
name: `Point ${index + 1}`, time: item.time,
time: item.time, usage: item.value.toString().split("%")[0],
usage: item.value.toString().split("%")[0], }));
};
});
return ( return (
<div className="mt-6 w-full h-[10rem]"> <ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<ResponsiveContainer> <AreaChart
<AreaChart data={transformedData}
data={transformedData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
margin={{ >
top: 10, <defs>
right: 30, <linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
left: 0, <stop
bottom: 0, offset="5%"
}} stopColor="var(--color-usage)"
> stopOpacity={0.8}
<defs> />
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} /> offset="95%"
<stop offset="95%" stopColor="white" stopOpacity={0} /> stopColor="var(--color-usage)"
</linearGradient> stopOpacity={0.1}
</defs> />
<YAxis stroke="#A1A1AA" domain={[0, 100]} /> </linearGradient>
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" /> </defs>
{/* @ts-ignore */} <CartesianGrid vertical={false} />
<Tooltip content={<CustomTooltip />} /> <YAxis
<Legend /> tickFormatter={(value) => `${value}%`}
<Area domain={[0, 100]}
type="monotone" tickLine={false}
dataKey="usage" axisLine={false}
stroke="#27272A" />
fillOpacity={1} <ChartTooltip
fill="url(#colorUv)" cursor={false}
/> content={
</AreaChart> <ChartTooltipContent
</ResponsiveContainer> labelFormatter={(_, payload) => {
</div> const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value}%`, "CPU Usage"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);
}
return null;
};

View File

@@ -1,13 +1,13 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
Area, type ChartConfig,
AreaChart, ChartContainer,
CartesianGrid, ChartLegend,
Legend, ChartLegendContent,
ResponsiveContainer, ChartTooltip,
Tooltip, ChartTooltipContent,
YAxis, } from "@/components/ui/chart";
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
@@ -15,91 +15,96 @@ interface Props {
diskTotal: number; diskTotal: number;
} }
const chartConfig = {
usedGb: {
label: "Used (GB)",
color: "hsl(var(--chart-3))",
},
freeGb: {
label: "Free (GB)",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => { export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
const transformedData = accumulativeData.map((item, index) => { const transformedData = accumulativeData.map((item, index) => ({
return { time: item.time,
time: item.time, name: `Point ${index + 1}`,
name: `Point ${index + 1}`, usedGb: +item.value.diskUsage,
usedGb: +item.value.diskUsage, totalGb: +item.value.diskTotal,
totalGb: +item.value.diskTotal, freeGb: item.value.diskFree,
freeGb: item.value.diskFree, }));
};
});
return ( return (
<div className="mt-6 w-full h-[10rem]"> <ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<ResponsiveContainer> <AreaChart
<AreaChart data={transformedData}
data={transformedData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
margin={{ >
top: 10, <defs>
right: 30, <linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
left: 0, <stop
bottom: 0, offset="5%"
}} stopColor="var(--color-usedGb)"
> stopOpacity={0.8}
<defs> />
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} /> offset="95%"
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} /> stopColor="var(--color-usedGb)"
</linearGradient> stopOpacity={0.1}
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1"> />
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} /> </linearGradient>
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} /> <linearGradient id="fillDiskFree" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop
</defs> offset="5%"
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} /> stopColor="var(--color-freeGb)"
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" /> stopOpacity={0.4}
{/* @ts-ignore */} />
<Tooltip content={<CustomTooltip />} /> <stop
<Legend /> offset="95%"
<Area stopColor="var(--color-freeGb)"
type="monotone" stopOpacity={0.1}
dataKey="usedGb" />
stroke="#6C28D9" </linearGradient>
fillOpacity={1} </defs>
fill="url(#colorUsed)" <CartesianGrid vertical={false} />
name="Used GB" <YAxis
/> domain={[0, diskTotal]}
<Area tickLine={false}
type="monotone" axisLine={false}
dataKey="freeGb" tickFormatter={(value) => `${value} GB`}
stroke="#8884d8" />
fillOpacity={1} <ChartTooltip
fill="url(#colorFree)" cursor={false}
name="Free GB" content={
/> <ChartTooltipContent
</AreaChart> labelFormatter={(_, payload) => {
</ResponsiveContainer> const time = payload?.[0]?.payload?.time;
</div> return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value, name) => {
const label = name === "usedGb" ? "Used" : "Free";
return [`${value} GB`, label];
}}
/>
}
/>
<Area
type="monotone"
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="freeGb"
stroke="var(--color-freeGb)"
fill="url(#fillDiskFree)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usedGb: number;
freeGb: number;
totalGb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -0,0 +1,182 @@
import { Loader2, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Cell, Label, Pie, PieChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
const TYPE_TO_KEY: Record<string, string> = {
Images: "images",
Containers: "containers",
"Local Volumes": "volumes",
"Build Cache": "buildCache",
};
const chartConfig = {
value: {
label: "Size",
},
images: {
label: "Images",
color: "hsl(var(--chart-1))",
},
containers: {
label: "Containers",
color: "hsl(var(--chart-2))",
},
volumes: {
label: "Volumes",
color: "hsl(var(--chart-3))",
},
buildCache: {
label: "Build Cache",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
const formatSize = (bytes: number): string => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
export const DockerDiskUsageChart = () => {
const { data, isLoading, refetch, isRefetching } =
api.settings.getDockerDiskUsage.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { chartData, totalBytes } = useMemo(() => {
const items =
data
?.filter((item) => item.sizeBytes > 0)
.map((item) => {
const key = TYPE_TO_KEY[item.type] ?? item.type;
return {
name: key,
value: item.sizeBytes,
size: item.size,
active: item.active,
totalCount: item.totalCount,
reclaimable: item.reclaimable,
fill: `var(--color-${key})`,
};
}) ?? [];
return {
chartData: items,
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[16rem]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (chartData.length === 0) {
return (
<p className="text-xs text-muted-foreground mt-4">
No Docker disk usage data available.
</p>
);
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total: {formatSize(totalBytes)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<ChartContainer
config={chartConfig}
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => {
const item = chartData.find((d) => d.name === name);
if (!item) return [formatSize(value as number), name];
return [
`${item.size}${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
chartConfig[name as keyof typeof chartConfig]?.label ??
name,
];
}}
/>
}
/>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={85}
strokeWidth={3}
stroke="hsl(var(--background))"
minAngle={15}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<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 || 0) - 8}
className="fill-foreground text-2xl font-bold"
>
{formatSize(totalBytes)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 14}
className="fill-muted-foreground text-xs"
>
Docker Usage
</tspan>
</text>
);
}
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
</div>
);
};

View File

@@ -1,13 +1,13 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
Area, type ChartConfig,
AreaChart, ChartContainer,
CartesianGrid, ChartLegend,
Legend, ChartLegendContent,
ResponsiveContainer, ChartTooltip,
Tooltip, ChartTooltipContent,
YAxis, } from "@/components/ui/chart";
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring"; import { convertMemoryToBytes } from "./show-free-container-monitoring";
@@ -16,78 +16,72 @@ interface Props {
memoryLimitGB: number; memoryLimitGB: number;
} }
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({ export const DockerMemoryChart = ({
accumulativeData, accumulativeData,
memoryLimitGB, memoryLimitGB,
}: Props) => { }: Props) => {
const transformedData = accumulativeData.map((item, index) => { const transformedData = accumulativeData.map((item, index) => ({
return { time: item.time,
time: item.time, name: `Point ${index + 1}`,
name: `Point ${index + 1}`, // @ts-ignore
// @ts-ignore usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2), }));
};
});
return ( return (
<div className="mt-6 w-full h-[10rem]"> <ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<ResponsiveContainer> <AreaChart
<AreaChart data={transformedData}
data={transformedData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
margin={{ >
top: 10, <defs>
right: 30, <linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
left: 0, <stop
bottom: 0, offset="5%"
}} stopColor="var(--color-usage)"
> stopOpacity={0.8}
<defs> />
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1"> <stop
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} /> offset="95%"
<stop offset="95%" stopColor="white" stopOpacity={0} /> stopColor="var(--color-usage)"
</linearGradient> stopOpacity={0.1}
</defs> />
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} /> </linearGradient>
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" /> </defs>
{/* @ts-ignore */} <CartesianGrid vertical={false} />
<Tooltip content={<CustomTooltip />} /> <YAxis
<Legend /> tickFormatter={(value) => `${value} GB`}
<Area domain={[0, +memoryLimitGB.toFixed(2)]}
type="monotone" tickLine={false}
dataKey="usage" axisLine={false}
stroke="#27272A" />
fillOpacity={1} <ChartTooltip
fill="url(#colorUv)" cursor={false}
/> content={
</AreaChart> <ChartTooltipContent
</ResponsiveContainer> labelFormatter={(_, payload) => {
</div> const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value} GB`, "Memory"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,99 +1,99 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
Area, type ChartConfig,
AreaChart, ChartContainer,
CartesianGrid, ChartLegend,
Legend, ChartLegendContent,
ResponsiveContainer, ChartTooltip,
Tooltip, ChartTooltipContent,
YAxis, } from "@/components/ui/chart";
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["network"]; accumulativeData: DockerStatsJSON["network"];
} }
const chartConfig = {
inMB: {
label: "In (MB)",
color: "hsl(var(--chart-1))",
},
outMB: {
label: "Out (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerNetworkChart = ({ accumulativeData }: Props) => { export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => { const transformedData = accumulativeData.map((item, index) => ({
return { time: item.time,
time: item.time, name: `Point ${index + 1}`,
name: `Point ${index + 1}`, inMB: item.value.inputMb,
inMB: item.value.inputMb, outMB: item.value.outputMb,
outMB: item.value.outputMb, }));
};
});
return ( return (
<div className="mt-6 w-full h-[10rem]"> <ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<ResponsiveContainer> <AreaChart
<AreaChart data={transformedData}
data={transformedData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
margin={{ >
top: 10, <defs>
right: 30, <linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
left: 0, <stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
bottom: 0, <stop
}} offset="95%"
> stopColor="var(--color-inMB)"
<defs> stopOpacity={0.1}
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1"> />
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} /> </linearGradient>
<stop offset="95%" stopColor="white" stopOpacity={0} /> <linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop
</defs> offset="5%"
<YAxis stroke="#A1A1AA" /> stopColor="var(--color-outMB)"
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" /> stopOpacity={0.8}
{/* @ts-ignore */} />
<Tooltip content={<CustomTooltip />} /> <stop
<Legend /> offset="95%"
<Area stopColor="var(--color-outMB)"
type="monotone" stopOpacity={0.1}
dataKey="inMB" />
stroke="#8884d8" </linearGradient>
fillOpacity={1} </defs>
fill="url(#colorUv)" <CartesianGrid vertical={false} />
name="In MB" <YAxis tickLine={false} axisLine={false} />
/> <ChartTooltip
<Area cursor={false}
type="monotone" content={
dataKey="outMB" <ChartTooltipContent
stroke="#82ca9d" labelFormatter={(_, payload) => {
fillOpacity={1} const time = payload?.[0]?.payload?.time;
fill="url(#colorUv)" return time ? format(new Date(time), "PPpp") : "";
name="Out MB" }}
/> formatter={(value, name) => {
</AreaChart> const label = name === "inMB" ? "In" : "Out";
</ResponsiveContainer> return [`${value} MB`, label];
</div> }}
/>
}
/>
<Area
type="monotone"
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
inMB: number;
outMB: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}
return null;
};

View File

@@ -5,6 +5,7 @@ import { api } from "@/utils/api";
import { DockerBlockChart } from "./docker-block-chart"; import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart"; import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart"; import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart"; import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart"; import { DockerNetworkChart } from "./docker-network-chart";
@@ -284,6 +285,18 @@ export const ContainerFreeMonitoring = ({
</CardContent> </CardContent>
</Card> </Card>
)} )}
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Docker Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<DockerDiskUsageChart />
</CardContent>
</Card>
)}
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">

View File

@@ -15,6 +15,7 @@ import {
DEFAULT_UPDATE_DATA, DEFAULT_UPDATE_DATA,
execAsync, execAsync,
findServerById, findServerById,
getDockerDiskUsage,
getDokployImageTag, getDokployImageTag,
getLogCleanupStatus, getLogCleanupStatus,
getUpdateData, getUpdateData,
@@ -291,6 +292,12 @@ export const settingsRouter = createTRPCRouter({
}); });
return true; return true;
}), }),
getDockerDiskUsage: adminProcedure.query(async () => {
if (IS_CLOUD) {
return [];
}
return getDockerDiskUsage();
}),
saveSSHPrivateKey: adminProcedure saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey) .input(apiSaveSSHKey)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -259,6 +259,48 @@ export const cleanupSystem = async (serverId?: string) => {
} }
}; };
export interface DockerDiskUsageItem {
type: string;
totalCount: number;
active: number;
size: string;
reclaimable: string;
sizeBytes: number;
}
const parseSizeToBytes = (size: string): number => {
const match = size.match(/^([\d.]+)\s*([KMGT]?B)$/i);
if (!match) return 0;
const value = Number.parseFloat(match[1] as string);
const unit = match[2].toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
return value * (multipliers[unit] || 0);
};
export const getDockerDiskUsage = async (): Promise<DockerDiskUsageItem[]> => {
const command = "docker system df --format '{{json .}}'";
const { stdout } = await execAsync(command);
const lines = stdout.trim().split("\n").filter(Boolean);
return lines.map((line) => {
const data = JSON.parse(line);
return {
type: data.Type,
totalCount: Number.parseInt(data.TotalCount, 10) || 0,
active: Number.parseInt(data.Active, 10) || 0,
size: data.Size,
reclaimable: data.Reclaimable,
sizeBytes: parseSizeToBytes(data.Size),
};
});
};
/** /**
* Volume cleanup should always be performed manually by the user. The reason is that during automatic cleanup, a volume may be deleted due to a stopped container, which is a dangerous situation. * Volume cleanup should always be performed manually by the user. The reason is that during automatic cleanup, a volume may be deleted due to a stopped container, which is a dangerous situation.
* *