From 3f6161bae2319cb237c0c9779b6d4d288962f19d Mon Sep 17 00:00:00 2001 From: Shuvo Date: Wed, 2 Sep 2026 20:02:33 +0600 Subject: [PATCH 1/4] feat: add resource usage tracking for services and containers --- .../monitoring/resource-usage/columns.tsx | 139 ++++++++++++ .../resource-usage/show-resource-usage.tsx | 213 +++++++++++++++++ apps/dokploy/pages/dashboard/monitoring.tsx | 87 ++++--- apps/dokploy/server/api/routers/project.ts | 214 ++++++++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/services/docker.ts | 92 ++++++++ .../server/src/services/resource-usage.ts | 138 +++++++++++ 7 files changed, 837 insertions(+), 47 deletions(-) create mode 100644 apps/dokploy/components/dashboard/monitoring/resource-usage/columns.tsx create mode 100644 apps/dokploy/components/dashboard/monitoring/resource-usage/show-resource-usage.tsx create mode 100644 packages/server/src/services/resource-usage.ts diff --git a/apps/dokploy/components/dashboard/monitoring/resource-usage/columns.tsx b/apps/dokploy/components/dashboard/monitoring/resource-usage/columns.tsx new file mode 100644 index 000000000..a6716200d --- /dev/null +++ b/apps/dokploy/components/dashboard/monitoring/resource-usage/columns.tsx @@ -0,0 +1,139 @@ +import { formatMb } from "@dokploy/server/monitoring/units"; +import type { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, CircuitBoard, GlobeIcon } from "lucide-react"; +import type { ComponentType } from "react"; +import { + LibsqlIcon, + MariadbIcon, + MongodbIcon, + MysqlIcon, + PostgresqlIcon, + RedisIcon, +} from "@/components/icons/data-tools-icons"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import type { RouterOutputs } from "@/utils/api"; + +export type ServiceUsageRow = RouterOutputs["project"]["resourceUsage"][number]; + +const SERVICE_ICONS: Record< + ServiceUsageRow["type"], + ComponentType<{ className?: string }> +> = { + application: GlobeIcon, + compose: CircuitBoard, + postgres: PostgresqlIcon, + mysql: MysqlIcon, + mariadb: MariadbIcon, + redis: RedisIcon, + mongo: MongodbIcon, + libsql: LibsqlIcon, +}; + +const sortableHeader = (label: string) => + function SortableHeader({ + column, + }: { + column: { + toggleSorting: (desc?: boolean) => void; + getIsSorted: () => false | "asc" | "desc"; + }; + }) { + return ( + + ); + }; + +export const columns: ColumnDef[] = [ + { + accessorKey: "projectName", + header: sortableHeader("Project"), + cell: ({ row }) => ( +
+ {row.original.projectName} + + {row.original.environmentName} + +
+ ), + }, + { + accessorKey: "name", + header: sortableHeader("Service"), + cell: ({ row }) => { + const Icon = SERVICE_ICONS[row.original.type]; + return ( +
+ + {row.original.name} +
+ ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.original.status as + | "running" + | "error" + | "done" + | "idle" + | null; + return ; + }, + }, + { + id: "containers", + accessorFn: (row) => row.containers.length, + header: sortableHeader("Containers"), + cell: ({ row }) => ( + {row.original.containers.length} + ), + }, + { + accessorKey: "cpuPercent", + header: sortableHeader("CPU"), + cell: ({ row }) => { + const value = row.original.cpuPercent; + return ( +
+ {value.toFixed(1)}% + +
+ ); + }, + }, + { + accessorKey: "memUsedMb", + header: sortableHeader("Memory"), + cell: ({ row }) => { + const { memUsedMb, memLimitMb } = row.original; + const percentage = memLimitMb > 0 ? (memUsedMb / memLimitMb) * 100 : 0; + return ( +
+ + {formatMb(memUsedMb)} + {memLimitMb > 0 ? ` / ${formatMb(memLimitMb)}` : ""} + + +
+ ); + }, + }, + { + accessorKey: "diskUsedMb", + header: sortableHeader("Storage"), + cell: ({ row }) => ( + {formatMb(row.original.diskUsedMb)} + ), + }, +]; diff --git a/apps/dokploy/components/dashboard/monitoring/resource-usage/show-resource-usage.tsx b/apps/dokploy/components/dashboard/monitoring/resource-usage/show-resource-usage.tsx new file mode 100644 index 000000000..b40905023 --- /dev/null +++ b/apps/dokploy/components/dashboard/monitoring/resource-usage/show-resource-usage.tsx @@ -0,0 +1,213 @@ +import { formatMb } from "@dokploy/server/monitoring/units"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Cpu, HardDrive, MemoryStick, RefreshCw } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { columns } from "./columns"; + +interface Props { + serverId?: string; +} + +export const ShowResourceUsage = ({ serverId }: Props) => { + const { data, isPending, refetch, isRefetching } = + api.project.resourceUsage.useQuery( + { serverId }, + { + refetchInterval: 5_000, + }, + ); + + const [sorting, setSorting] = React.useState([ + { id: "cpuPercent", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + const table = useReactTable({ + data: data ?? [], + columns, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: (row, _columnId, filterValue) => { + const search = String(filterValue).toLowerCase(); + return ( + row.original.name.toLowerCase().includes(search) || + row.original.projectName.toLowerCase().includes(search) || + row.original.environmentName.toLowerCase().includes(search) + ); + }, + state: { + sorting, + globalFilter, + }, + }); + + const totals = React.useMemo(() => { + if (!data) return { cpu: 0, mem: 0, disk: 0, containers: 0 }; + return data.reduce( + (acc, service) => ({ + cpu: acc.cpu + service.cpuPercent, + mem: acc.mem + service.memUsedMb, + disk: acc.disk + service.diskUsedMb, + containers: acc.containers + service.containers.length, + }), + { cpu: 0, mem: 0, disk: 0, containers: 0 }, + ); + }, [data]); + + return ( +
+ +
+ + + + Resource Usage + + + CPU, memory, and storage usage per project, application, and + container + + + +
+
+ + + Total CPU: + + {totals.cpu.toFixed(1)}% +
+
+ + + Total Memory: + + {formatMb(totals.mem)} +
+
+ + + Total Storage: + + {formatMb(totals.disk)} +
+
+ + Containers: + + {totals.containers} +
+
+
+ setGlobalFilter(event.target.value)} + className="md:max-w-sm" + /> + +
+
+ {isPending ? ( +
+ + Loading... + +
+ ) : data?.length === 0 ? ( +
+ + No services found on this server. + +
+ ) : ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+ )} +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/monitoring.tsx b/apps/dokploy/pages/dashboard/monitoring.tsx index 8ab58e2eb..6b90ce3d8 100644 --- a/apps/dokploy/pages/dashboard/monitoring.tsx +++ b/apps/dokploy/pages/dashboard/monitoring.tsx @@ -6,8 +6,11 @@ import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring"; +import { ShowResourceUsage } from "@/components/dashboard/monitoring/resource-usage/show-resource-usage"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { ServerFilter } from "@/components/shared/server-filter"; import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import { api } from "@/utils/api"; @@ -24,18 +27,6 @@ const Dashboard = () => { const { data: monitoring, isPending } = api.user.getMetricsToken.useQuery(); return (
- {/* - You are watching the Free plan.{" "} - - Upgrade - {" "} - to get more features. - */} {isPending ? (
@@ -43,41 +34,43 @@ const Dashboard = () => {
) : ( - <> - {/* {monitoring?.enabledFeatures && ( -
- - -
- )} */} - {toggleMonitoring ? ( - -
- -
-
- ) : ( - -
- -
-
- )} - + + + Overview + Resource Usage + + + {toggleMonitoring ? ( + +
+ +
+
+ ) : ( + +
+ +
+
+ )} +
+ + + {(serverId) => } + + +
)}
); diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 7085b3bef..4a82fa4e9 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -27,7 +27,9 @@ import { findProjectById, findRedisById, findUserById, + getResourceUsage, IS_CLOUD, + type ServiceDescriptor, updateProjectById, updateUser, } from "@dokploy/server"; @@ -685,6 +687,218 @@ export const projectRouter = createTRPCRouter({ return { ok: true }; }), + resourceUsage: protectedProcedure + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + if (IS_CLOUD) { + return []; + } + + const isPrivileged = + ctx.user.role === "owner" || ctx.user.role === "admin"; + + let accessedProjects: string[] = []; + let accessedEnvironments: string[] = []; + let accessedServices: string[] = []; + + if (!isPrivileged) { + const member = await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + accessedProjects = member.accessedProjects; + accessedEnvironments = member.accessedEnvironments; + accessedServices = member.accessedServices; + + if (accessedProjects.length === 0) { + return []; + } + } + + const projectIdFilter = isPrivileged + ? eq(projects.organizationId, ctx.session.activeOrganizationId) + : and( + sql`${projects.projectId} IN (${sql.join( + accessedProjects.map((id) => sql`${id}`), + sql`, `, + )})`, + eq(projects.organizationId, ctx.session.activeOrganizationId), + ); + + const environmentFilter = isPrivileged + ? undefined + : accessedEnvironments.length === 0 + ? sql`false` + : sql`${environments.environmentId} IN (${sql.join( + accessedEnvironments.map((envId) => sql`${envId}`), + sql`, `, + )})`; + + const applyFilter = (col: AnyPgColumn) => + isPrivileged ? undefined : buildServiceFilter(col, accessedServices); + + const rows = await db.query.projects.findMany({ + where: projectIdFilter, + columns: { projectId: true, name: true }, + with: { + environments: { + where: environmentFilter, + columns: { environmentId: true, name: true }, + with: { + applications: { + where: applyFilter(applications.applicationId), + columns: { ...serviceColumns, applicationId: true }, + }, + compose: { + where: applyFilter(compose.composeId), + columns: { + ...serviceColumns, + composeId: true, + composeStatus: true, + composeType: true, + }, + }, + libsql: { + where: applyFilter(libsql.libsqlId), + columns: { ...serviceColumns, libsqlId: true }, + }, + mariadb: { + where: applyFilter(mariadb.mariadbId), + columns: { ...serviceColumns, mariadbId: true }, + }, + mongo: { + where: applyFilter(mongo.mongoId), + columns: { ...serviceColumns, mongoId: true }, + }, + mysql: { + where: applyFilter(mysql.mysqlId), + columns: { ...serviceColumns, mysqlId: true }, + }, + postgres: { + where: applyFilter(postgres.postgresId), + columns: { ...serviceColumns, postgresId: true }, + }, + redis: { + where: applyFilter(redis.redisId), + columns: { ...serviceColumns, redisId: true }, + }, + }, + }, + }, + }); + + const services: ServiceDescriptor[] = []; + const matchesServer = (serverId: string | null) => + (serverId ?? undefined) === input.serverId; + + for (const project of rows) { + for (const env of project.environments) { + const base = { + projectId: project.projectId, + projectName: project.name, + environmentId: env.environmentId, + environmentName: env.name, + }; + + for (const a of env.applications) { + if (!matchesServer(a.serverId)) continue; + services.push({ + ...base, + id: a.applicationId, + name: a.name, + appName: a.appName, + type: "application", + status: a.applicationStatus, + }); + } + for (const c of env.compose) { + if (!matchesServer(c.serverId)) continue; + services.push({ + ...base, + id: c.composeId, + name: c.name, + appName: c.appName, + type: "compose", + composeType: c.composeType, + status: c.composeStatus, + }); + } + for (const s of env.libsql) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.libsqlId, + name: s.name, + appName: s.appName, + type: "libsql", + status: s.applicationStatus, + }); + } + for (const s of env.mariadb) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.mariadbId, + name: s.name, + appName: s.appName, + type: "mariadb", + status: s.applicationStatus, + }); + } + for (const s of env.mongo) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.mongoId, + name: s.name, + appName: s.appName, + type: "mongo", + status: s.applicationStatus, + }); + } + for (const s of env.mysql) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.mysqlId, + name: s.name, + appName: s.appName, + type: "mysql", + status: s.applicationStatus, + }); + } + for (const s of env.postgres) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.postgresId, + name: s.name, + appName: s.appName, + type: "postgres", + status: s.applicationStatus, + }); + } + for (const s of env.redis) { + if (!matchesServer(s.serverId)) continue; + services.push({ + ...base, + id: s.redisId, + name: s.name, + appName: s.appName, + type: "redis", + status: s.applicationStatus, + }); + } + } + } + + return await getResourceUsage(services, input.serverId); + }), + search: protectedProcedure .input( z.object({ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 766ccdd6f..c461f45fe 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -47,6 +47,7 @@ export * from "./services/proprietary/whitelabeling"; export * from "./services/redirect"; export * from "./services/redis"; export * from "./services/registry"; +export * from "./services/resource-usage"; export * from "./services/rollbacks"; export * from "./services/schedule"; export * from "./services/security"; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 65eb29041..d99f8d1d9 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -4,6 +4,7 @@ import { execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { quote } from "shell-quote"; +import { toMb } from "../monitoring/units"; export const getContainers = async (serverId?: string | null) => { try { @@ -719,6 +720,97 @@ export const getAllContainerStats = async (serverId?: string) => { } }; +export interface ContainerWithLabels { + containerId: string; + name: string; + image: string; + state: string; + labels: Record; + sizeMb: number; + virtualSizeMb: number; +} + +// docker ps's `.Labels` format is a single "k1=v1,k2=v2" string, so we only +// extract the label keys Dokploy itself relies on to identify a service. +export const SWARM_SERVICE_LABEL = "com.docker.swarm.service.name"; +export const STACK_NAMESPACE_LABEL = "com.docker.stack.namespace"; +export const COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; +export const COMPOSE_SERVICE_LABEL = "com.docker.compose.service"; +const RELEVANT_LABEL_KEYS = [ + SWARM_SERVICE_LABEL, + STACK_NAMESPACE_LABEL, + COMPOSE_PROJECT_LABEL, + COMPOSE_SERVICE_LABEL, +]; + +const parseRelevantLabels = (raw: string): Record => { + const labels: Record = {}; + if (!raw) return labels; + + for (const key of RELEVANT_LABEL_KEYS) { + const match = raw.match(new RegExp(`(?:^|,)${key}=([^,]*)`)); + if (match?.[1] !== undefined) { + labels[key] = match[1]; + } + } + + return labels; +}; + +// `docker ps -s` formats Size as either "0B" or "1.83kB (virtual 5.58MB)" — +// the first number is the container's own writable layer, the second +// (when present) also includes the shared image layers. +const parseSize = (raw: string): { sizeMb: number; virtualSizeMb: number } => { + const [sizePart, virtualPart] = raw.split(" (virtual "); + const sizeMb = toMb(sizePart); + const virtualSizeMb = virtualPart + ? toMb(virtualPart.replace(")", "")) + : sizeMb; + return { sizeMb, virtualSizeMb }; +}; + +export const getAllContainersWithLabels = async ( + serverId?: string, +): Promise => { + try { + let stdout = ""; + const command = + 'docker ps -a -s --format \'{"ID":"{{.ID}}","Name":"{{.Names}}","Image":"{{.Image}}","State":"{{.State}}","Labels":"{{.Labels}}","Size":"{{.Size}}"}\''; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + } else { + const result = await execAsync(command); + stdout = result.stdout; + } + + if (!stdout.trim()) { + return []; + } + + return stdout + .trim() + .split("\n") + .map((line) => { + const raw = JSON.parse(line); + const { sizeMb, virtualSizeMb } = parseSize(raw.Size ?? ""); + return { + containerId: raw.ID as string, + name: raw.Name as string, + image: raw.Image as string, + state: raw.State as string, + labels: parseRelevantLabels(raw.Labels ?? ""), + sizeMb, + virtualSizeMb, + }; + }); + } catch (error) { + console.error("getAllContainersWithLabels error:", error); + return []; + } +}; + const destinationPathRegex = /^[a-zA-Z0-9.\-_/]+$/; export const uploadFileToContainer = async ( diff --git a/packages/server/src/services/resource-usage.ts b/packages/server/src/services/resource-usage.ts new file mode 100644 index 000000000..2a536c475 --- /dev/null +++ b/packages/server/src/services/resource-usage.ts @@ -0,0 +1,138 @@ +import { toMb } from "../monitoring/units"; +import { + COMPOSE_PROJECT_LABEL, + type ContainerWithLabels, + getAllContainerStats, + getAllContainersWithLabels, + STACK_NAMESPACE_LABEL, + SWARM_SERVICE_LABEL, +} from "./docker"; + +export type ResourceServiceType = + | "application" + | "compose" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "libsql"; + +export interface ServiceDescriptor { + id: string; + name: string; + appName: string; + type: ResourceServiceType; + composeType?: "docker-compose" | "stack"; + status: string | null; + projectId: string; + projectName: string; + environmentId: string; + environmentName: string; +} + +export interface ContainerUsage { + containerId: string; + containerName: string; + state: string; + cpuPercent: number; + memUsedMb: number; + memLimitMb: number; + netInputMb: number; + netOutputMb: number; + blockReadMb: number; + blockWriteMb: number; + diskUsedMb: number; +} + +export interface ServiceUsage extends ServiceDescriptor { + containers: ContainerUsage[]; + cpuPercent: number; + memUsedMb: number; + memLimitMb: number; + diskUsedMb: number; +} + +const sum = (values: number[]) => values.reduce((total, v) => total + v, 0); + +const isContainerForService = ( + container: ContainerWithLabels, + service: ServiceDescriptor, +): boolean => { + const { labels } = container; + + if (service.type === "compose" && service.composeType === "docker-compose") { + return labels[COMPOSE_PROJECT_LABEL] === service.appName; + } + + if (service.type === "compose" && service.composeType === "stack") { + return ( + labels[STACK_NAMESPACE_LABEL] === service.appName || + (labels[SWARM_SERVICE_LABEL]?.startsWith(`${service.appName}_`) ?? false) + ); + } + + return labels[SWARM_SERVICE_LABEL] === service.appName; +}; + +const toContainerUsage = ( + container: ContainerWithLabels, + statsByName: Map>, +): ContainerUsage => { + const stat = statsByName.get(container.name); + const [usedRaw, limitRaw] = (stat?.MemUsage ?? "0MB / 0MB").split(" / "); + const [inputRaw, outputRaw] = (stat?.NetIO ?? "0MB / 0MB").split(" / "); + const [readRaw, writeRaw] = (stat?.BlockIO ?? "0MB / 0MB").split(" / "); + + return { + containerId: container.containerId, + containerName: container.name, + state: container.state, + cpuPercent: Number.parseFloat(stat?.CPUPerc ?? "0") || 0, + memUsedMb: toMb(usedRaw), + memLimitMb: toMb(limitRaw), + netInputMb: toMb(inputRaw), + netOutputMb: toMb(outputRaw), + blockReadMb: toMb(readRaw), + blockWriteMb: toMb(writeRaw), + diskUsedMb: container.sizeMb, + }; +}; + +/** + * Correlates registered Dokploy services with live container stats on a + * single host. Only two `docker` commands are run in total (one for all + * container labels, one for all container stats) no matter how many + * services are passed in, so this stays cheap as a project grows. + */ +export const getResourceUsage = async ( + services: ServiceDescriptor[], + serverId?: string, +): Promise => { + const [containers, stats] = await Promise.all([ + getAllContainersWithLabels(serverId), + getAllContainerStats(serverId), + ]); + + const statsByName = new Map>( + stats.map((stat) => [stat.Name, stat]), + ); + + return services.map((service) => { + const matched = containers.filter((container) => + isContainerForService(container, service), + ); + const containerUsages = matched.map((container) => + toContainerUsage(container, statsByName), + ); + + return { + ...service, + containers: containerUsages, + cpuPercent: sum(containerUsages.map((c) => c.cpuPercent)), + memUsedMb: sum(containerUsages.map((c) => c.memUsedMb)), + memLimitMb: Math.max(0, ...containerUsages.map((c) => c.memLimitMb)), + diskUsedMb: sum(containerUsages.map((c) => c.diskUsedMb)), + }; + }); +}; From 31bf45a7e04469ea8456329537f0c21b499da325 Mon Sep 17 00:00:00 2001 From: Shuvo Date: Wed, 2 Sep 2026 23:55:47 +0600 Subject: [PATCH 2/4] fix: improve container parsing and error handling in getAllContainersWithLabels --- packages/server/src/services/docker.ts | 37 ++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index d99f8d1d9..8e6c033ec 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -774,8 +774,7 @@ export const getAllContainersWithLabels = async ( ): Promise => { try { let stdout = ""; - const command = - 'docker ps -a -s --format \'{"ID":"{{.ID}}","Name":"{{.Names}}","Image":"{{.Image}}","State":"{{.State}}","Labels":"{{.Labels}}","Size":"{{.Size}}"}\''; + const command = "docker ps -a -s --format '{{json .}}'"; if (serverId) { const result = await execAsyncRemote(serverId, command); @@ -792,18 +791,28 @@ export const getAllContainersWithLabels = async ( return stdout .trim() .split("\n") - .map((line) => { - const raw = JSON.parse(line); - const { sizeMb, virtualSizeMb } = parseSize(raw.Size ?? ""); - return { - containerId: raw.ID as string, - name: raw.Name as string, - image: raw.Image as string, - state: raw.State as string, - labels: parseRelevantLabels(raw.Labels ?? ""), - sizeMb, - virtualSizeMb, - }; + .flatMap((line) => { + try { + const raw = JSON.parse(line); + const { sizeMb, virtualSizeMb } = parseSize(raw.Size ?? ""); + return [ + { + containerId: raw.ID as string, + name: raw.Names as string, + image: raw.Image as string, + state: raw.State as string, + labels: parseRelevantLabels(raw.Labels ?? ""), + sizeMb, + virtualSizeMb, + }, + ]; + } catch (error) { + console.error( + "getAllContainersWithLabels: failed to parse container line:", + error, + ); + return []; + } }); } catch (error) { console.error("getAllContainersWithLabels error:", error); From 314fcec9f89bbd9a30ed13a444c529e6d3f617ea Mon Sep 17 00:00:00 2001 From: Shuvo Date: Thu, 3 Sep 2026 21:47:19 +0600 Subject: [PATCH 3/4] test: add unit tests for resource usage correlation logic Covers matching containers to services by Swarm/Compose labels (application, docker-compose, and swarm-stack), summing CPU/memory/ storage across replicas, and the zero-usage case for idle services. --- .../monitoring/resource-usage.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 apps/dokploy/__test__/monitoring/resource-usage.test.ts diff --git a/apps/dokploy/__test__/monitoring/resource-usage.test.ts b/apps/dokploy/__test__/monitoring/resource-usage.test.ts new file mode 100644 index 000000000..3f0e7acf0 --- /dev/null +++ b/apps/dokploy/__test__/monitoring/resource-usage.test.ts @@ -0,0 +1,244 @@ +import type { ContainerWithLabels, ServiceDescriptor } from "@dokploy/server"; +import { getResourceUsage } from "@dokploy/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getAllContainersWithLabels, getAllContainerStats } = vi.hoisted(() => ({ + getAllContainersWithLabels: + vi.fn<(serverId?: string) => Promise>(), + getAllContainerStats: + vi.fn<(serverId?: string) => Promise[]>>(), +})); + +vi.mock("@dokploy/server/services/docker", async (importOriginal) => ({ + ...(await importOriginal()), + getAllContainersWithLabels, + getAllContainerStats, +})); + +const baseService: Omit = + { + status: "done", + projectId: "project-1", + projectName: "Project", + environmentId: "env-1", + environmentName: "production", + }; + +const container = ( + overrides: Partial, +): ContainerWithLabels => ({ + containerId: "container-id", + name: "container-name", + image: "some-image:latest", + state: "running", + labels: {}, + sizeMb: 0, + virtualSizeMb: 0, + ...overrides, +}); + +const stat = (overrides: Partial>) => ({ + CPUPerc: "0%", + MemUsage: "0MB / 0MB", + MemPerc: "0%", + NetIO: "0MB / 0MB", + BlockIO: "0MB / 0MB", + Container: "", + ID: "", + Name: "", + ...overrides, +}); + +describe("getResourceUsage", () => { + beforeEach(() => { + getAllContainersWithLabels.mockReset(); + getAllContainerStats.mockReset(); + }); + + it("matches an application by its swarm service name label", async () => { + getAllContainersWithLabels.mockResolvedValue([ + container({ + name: "my-app.1.abc123", + labels: { "com.docker.swarm.service.name": "my-app" }, + sizeMb: 12, + }), + ]); + getAllContainerStats.mockResolvedValue([ + stat({ + Name: "my-app.1.abc123", + CPUPerc: "2.50%", + MemUsage: "100MB / 512MB", + }), + ]); + + const service: ServiceDescriptor = { + ...baseService, + id: "app-1", + name: "My App", + appName: "my-app", + type: "application", + }; + + const [result] = await getResourceUsage([service]); + + expect(result?.containers).toHaveLength(1); + expect(result?.cpuPercent).toBeCloseTo(2.5); + expect(result?.memUsedMb).toBeCloseTo(100); + expect(result?.memLimitMb).toBeCloseTo(512); + expect(result?.diskUsedMb).toBe(12); + }); + + it("does not match a container belonging to a different service", async () => { + getAllContainersWithLabels.mockResolvedValue([ + container({ + name: "other-app.1.xyz", + labels: { "com.docker.swarm.service.name": "other-app" }, + }), + ]); + getAllContainerStats.mockResolvedValue([]); + + const service: ServiceDescriptor = { + ...baseService, + id: "app-1", + name: "My App", + appName: "my-app", + type: "application", + }; + + const [result] = await getResourceUsage([service]); + + expect(result?.containers).toHaveLength(0); + expect(result?.cpuPercent).toBe(0); + }); + + it("sums CPU, memory, and storage across replicas of the same service", async () => { + getAllContainersWithLabels.mockResolvedValue([ + container({ + name: "my-app.1.aaa", + labels: { "com.docker.swarm.service.name": "my-app" }, + sizeMb: 10, + }), + container({ + name: "my-app.2.bbb", + labels: { "com.docker.swarm.service.name": "my-app" }, + sizeMb: 5, + }), + ]); + getAllContainerStats.mockResolvedValue([ + stat({ + Name: "my-app.1.aaa", + CPUPerc: "1.00%", + MemUsage: "50MB / 512MB", + }), + stat({ + Name: "my-app.2.bbb", + CPUPerc: "3.00%", + MemUsage: "80MB / 512MB", + }), + ]); + + const service: ServiceDescriptor = { + ...baseService, + id: "app-1", + name: "My App", + appName: "my-app", + type: "application", + }; + + const [result] = await getResourceUsage([service]); + + expect(result?.containers).toHaveLength(2); + expect(result?.cpuPercent).toBeCloseTo(4); + expect(result?.memUsedMb).toBeCloseTo(130); + expect(result?.diskUsedMb).toBe(15); + }); + + it("matches a plain docker-compose service by its compose project label", async () => { + getAllContainersWithLabels.mockResolvedValue([ + container({ + name: "my-compose-web-1", + labels: { + "com.docker.compose.project": "my-compose", + "com.docker.compose.service": "web", + }, + }), + container({ + name: "unrelated-container", + labels: { "com.docker.swarm.service.name": "unrelated" }, + }), + ]); + getAllContainerStats.mockResolvedValue([ + stat({ Name: "my-compose-web-1", CPUPerc: "1.00%" }), + ]); + + const service: ServiceDescriptor = { + ...baseService, + id: "compose-1", + name: "My Compose", + appName: "my-compose", + type: "compose", + composeType: "docker-compose", + }; + + const [result] = await getResourceUsage([service]); + + expect(result?.containers).toHaveLength(1); + expect(result?.containers[0]?.containerName).toBe("my-compose-web-1"); + }); + + it("matches a swarm-stack compose service by namespace and per-service prefix", async () => { + getAllContainersWithLabels.mockResolvedValue([ + container({ + name: "my-stack_web.1.aaa", + labels: { + "com.docker.stack.namespace": "my-stack", + "com.docker.swarm.service.name": "my-stack_web", + }, + }), + container({ + name: "my-stack_worker.1.bbb", + labels: { + "com.docker.stack.namespace": "my-stack", + "com.docker.swarm.service.name": "my-stack_worker", + }, + }), + ]); + getAllContainerStats.mockResolvedValue([]); + + const service: ServiceDescriptor = { + ...baseService, + id: "compose-1", + name: "My Stack", + appName: "my-stack", + type: "compose", + composeType: "stack", + }; + + const [result] = await getResourceUsage([service]); + + expect(result?.containers).toHaveLength(2); + }); + + it("returns zeroed usage for a service with no running containers", async () => { + getAllContainersWithLabels.mockResolvedValue([]); + getAllContainerStats.mockResolvedValue([]); + + const service: ServiceDescriptor = { + ...baseService, + id: "app-1", + name: "Idle App", + appName: "idle-app", + type: "application", + }; + + const [result] = await getResourceUsage([service]); + + expect(result).toMatchObject({ + containers: [], + cpuPercent: 0, + memUsedMb: 0, + memLimitMb: 0, + diskUsedMb: 0, + }); + }); +}); From 9e9d197ff8d53caf1c9cf5e67f531fec30f03b55 Mon Sep 17 00:00:00 2001 From: Shuvo Date: Thu, 3 Sep 2026 21:59:17 +0600 Subject: [PATCH 4/4] fix: enforce monitoring permission and server ownership on resourceUsage project.resourceUsage was reachable with only protectedProcedure and accepted an arbitrary serverId, so it bypassed the monitoring:read permission gate the page relies on and could be pointed at a server belonging to a different organization to run docker commands over its SSH connection. Gates on withPermission("monitoring", "read") and validates the server belongs to the caller's active organization, matching the pattern already used by docker.getContainers. --- apps/dokploy/server/api/routers/project.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 4a82fa4e9..779d5bc81 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -26,6 +26,7 @@ import { findPostgresById, findProjectById, findRedisById, + findServerById, findUserById, getResourceUsage, IS_CLOUD, @@ -687,7 +688,7 @@ export const projectRouter = createTRPCRouter({ return { ok: true }; }), - resourceUsage: protectedProcedure + resourceUsage: withPermission("monitoring", "read") .input( z.object({ serverId: z.string().optional(), @@ -698,6 +699,13 @@ export const projectRouter = createTRPCRouter({ return []; } + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + const isPrivileged = ctx.user.role === "owner" || ctx.user.role === "admin";