feat(dashboard): enhance monitoring charts with new Docker disk usage component and refactor existing charts for consistency

- Added DockerDiskUsageChart component to visualize Docker disk usage data.
- Refactored existing chart components (DockerBlockChart, DockerCpuChart, DockerDiskChart, DockerMemoryChart, DockerNetworkChart) to use a consistent ChartContainer and updated chart configurations.
- Improved tooltip functionality and styling across all charts for better user experience.
- Integrated new API endpoint for fetching Docker disk usage data.
This commit is contained in:
Mauricio Siu
2026-04-03 21:27:54 -06:00
parent b7adb7fb0a
commit 28845c145e
9 changed files with 671 additions and 419 deletions

View File

@@ -1,103 +1,106 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
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) => {
const transformedData = accumulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="readMb"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
name="Read Mb"
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorWrite)"
name="Write Mb"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-readMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-readMb)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-writeMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-writeMb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time
? format(new Date(time), "PPpp")
: "";
}}
formatter={(value, name) => {
const label =
name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
}}
/>
}
/>
<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,83 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
accumulativeData: DockerStatsJSON["cpu"];
}
const chartConfig = {
usage: {
label: "CPU Usage",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const DockerCpuChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => {
return {
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
};
});
const transformedData = accumulativeData.map((item, index) => ({
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
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 { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
@@ -15,91 +15,99 @@ interface Props {
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) => {
const transformedData = accumulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
freeGb: item.value.diskFree,
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
freeGb: item.value.diskFree,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usedGb"
stroke="#6C28D9"
fillOpacity={1}
fill="url(#colorUsed)"
name="Used GB"
/>
<Area
type="monotone"
dataKey="freeGb"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorFree)"
name="Free GB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usedGb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usedGb)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillDiskFree" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-freeGb)"
stopOpacity={0.4}
/>
<stop
offset="95%"
stopColor="var(--color-freeGb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
domain={[0, diskTotal]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value} GB`}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
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,181 @@
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 { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring";
@@ -16,78 +16,74 @@ interface Props {
memoryLimitGB: number;
}
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({
accumulativeData,
memoryLimitGB,
}: Props) => {
const transformedData = accumulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value} GB`}
domain={[0, +memoryLimitGB.toFixed(2)]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
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,105 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
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) => {
const transformedData = accumulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="inMB"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
name="In MB"
/>
<Area
type="monotone"
dataKey="outMB"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorUv)"
name="Out MB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-inMB)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-inMB)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-outMB)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-outMB)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time
? format(new Date(time), "PPpp")
: "";
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
}}
/>
}
/>
<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 { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart";
@@ -284,6 +285,18 @@ export const ContainerFreeMonitoring = ({
</CardContent>
</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">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">

View File

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