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, + }); + }); +}); 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..779d5bc81 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -26,8 +26,11 @@ import { findPostgresById, findProjectById, findRedisById, + findServerById, findUserById, + getResourceUsage, IS_CLOUD, + type ServiceDescriptor, updateProjectById, updateUser, } from "@dokploy/server"; @@ -685,6 +688,225 @@ export const projectRouter = createTRPCRouter({ return { ok: true }; }), + resourceUsage: withPermission("monitoring", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + if (IS_CLOUD) { + 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"; + + 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 cd7bef41f..719e474b9 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..8e6c033ec 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,106 @@ 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 '{{json .}}'"; + + 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") + .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); + 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)), + }; + }); +};