From 28845c145ed385035537810b6e1ebda9c6300cba Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 3 Apr 2026 21:27:54 -0600 Subject: [PATCH] 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. --- .../free/container/docker-block-chart.tsx | 183 ++++++++--------- .../free/container/docker-cpu-chart.tsx | 146 +++++++------- .../free/container/docker-disk-chart.tsx | 190 +++++++++--------- .../container/docker-disk-usage-chart.tsx | 181 +++++++++++++++++ .../free/container/docker-memory-chart.tsx | 148 +++++++------- .../free/container/docker-network-chart.tsx | 180 +++++++++-------- .../show-free-container-monitoring.tsx | 13 ++ apps/dokploy/server/api/routers/settings.ts | 7 + packages/server/src/utils/docker/utils.ts | 42 ++++ 9 files changed, 671 insertions(+), 419 deletions(-) create mode 100644 apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-usage-chart.tsx diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 6dc5cd90c..0864834b8 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx @@ -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 ( -
- - - - - - - - - - - - - - - {/* @ts-ignore */} - } /> - - - - - -
+ + + + + + + + + + + + + + + { + 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]; + }} + /> + } + /> + + + } /> + + ); }; -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 ( -
- {payload[0].payload.time && ( -

{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}

- )} -

{`Read ${payload[0].payload.readMb} `}

-

{`Write: ${payload[0].payload.writeMb} `}

-
- ); - } - - return null; -}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-cpu-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-cpu-chart.tsx index 67404268b..407eb6bf8 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-cpu-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-cpu-chart.tsx @@ -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 ( -
- - - - - - - - - - - {/* @ts-ignore */} - } /> - - - - -
+ + + + + + + + + + `${value}%`} + domain={[0, 100]} + tickLine={false} + axisLine={false} + /> + { + const time = payload?.[0]?.payload?.time; + return time + ? format(new Date(time), "PPpp") + : ""; + }} + formatter={(value) => [`${value}%`, "CPU Usage"]} + /> + } + /> + + } /> + + ); }; - -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 ( -
- {payload[0].payload.time && ( -

{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}

- )} -

{`CPU Usage: ${payload[0].payload.usage}%`}

-
- ); - } - - return null; -}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-chart.tsx index 58cefe6b3..4d17e7bde 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-chart.tsx @@ -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 ( -
- - - - - - - - - - - - - - - {/* @ts-ignore */} - } /> - - - - - -
+ + + + + + + + + + + + + + `${value} GB`} + /> + { + 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]; + }} + /> + } + /> + + + } /> + + ); }; -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 ( -
-

{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}

-

{`Disk usage: ${payload[0].payload.usedGb} GB`}

-

{`Disk free: ${payload[0].payload.freeGb} GB`}

-

{`Total disk: ${payload[0].payload.totalGb} GB`}

-
- ); - } - - return null; -}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-usage-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-usage-chart.tsx new file mode 100644 index 000000000..dbd6b6b4c --- /dev/null +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-disk-usage-chart.tsx @@ -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 = { + 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 ( +
+ +
+ ); + } + + if (chartData.length === 0) { + return ( +

+ No Docker disk usage data available. +

+ ); + } + + return ( +
+
+ + Total: {formatSize(totalBytes)} + + +
+ + + { + 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, + ]; + }} + /> + } + /> + + {chartData.map((entry) => ( + + ))} + + } /> + + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-memory-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-memory-chart.tsx index 226623fa2..4a6cf92ad 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-memory-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-memory-chart.tsx @@ -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 ( -
- - - - - - - - - - - {/* @ts-ignore */} - } /> - - - - -
+ + + + + + + + + + `${value} GB`} + domain={[0, +memoryLimitGB.toFixed(2)]} + tickLine={false} + axisLine={false} + /> + { + const time = payload?.[0]?.payload?.time; + return time + ? format(new Date(time), "PPpp") + : ""; + }} + formatter={(value) => [`${value} GB`, "Memory"]} + /> + } + /> + + } /> + + ); }; -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 ( -
- {payload[0].payload.time && ( -

{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}

- )} - -

{`Memory usage: ${payload[0].payload.usage} GB`}

-
- ); - } - - return null; -}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx index 8dafcb465..9fee45adc 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx @@ -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 ( -
- - - - - - - - - - - {/* @ts-ignore */} - } /> - - - - - -
+ + + + + + + + + + + + + + + { + 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]; + }} + /> + } + /> + + + } /> + + ); }; - -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 ( -
- {payload[0].payload.time && ( -

{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}

- )} -

{`In Usage: ${payload[0].payload.inMB} `}

-

{`Out Usage: ${payload[0].payload.outMB} `}

-
- ); - } - - return null; -}; diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index 6e572c224..fd666255c 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -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 = ({ )} + {appName === "dokploy" && ( + + + + Docker Disk Usage + + + + + + + )} diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 26c5d4822..9965643f7 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -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 }) => { diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index dd645cd1b..cfe9b95ac 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -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 = { + B: 1, + KB: 1024, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, + }; + return value * (multipliers[unit] || 0); +}; + +export const getDockerDiskUsage = async (): Promise => { + 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. *