From 9c56c46ad8e75d2fb94d6d8f8b1937afb257d354 Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Tue, 23 Jun 2026 16:43:46 -0400 Subject: [PATCH] feat(project): add resource usage summaries --- .../resource-metrics/resource-metrics.test.ts | 240 ++++++++++ .../components/dashboard/projects/show.tsx | 20 + .../resource-metrics/usage-strip.tsx | 197 +++++++++ .../environment/[environmentId].tsx | 18 + apps/dokploy/server/api/routers/project.ts | 289 ++++++++++++ packages/server/src/index.ts | 1 + .../server/src/services/resource-metrics.ts | 411 ++++++++++++++++++ 7 files changed, 1176 insertions(+) create mode 100644 apps/dokploy/__test__/resource-metrics/resource-metrics.test.ts create mode 100644 apps/dokploy/components/dashboard/resource-metrics/usage-strip.tsx create mode 100644 packages/server/src/services/resource-metrics.ts diff --git a/apps/dokploy/__test__/resource-metrics/resource-metrics.test.ts b/apps/dokploy/__test__/resource-metrics/resource-metrics.test.ts new file mode 100644 index 0000000000..6890671fc9 --- /dev/null +++ b/apps/dokploy/__test__/resource-metrics/resource-metrics.test.ts @@ -0,0 +1,240 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + DockerContainerLabels, + DockerStatsRow, + ResourceMetricDockerClient, + ResourceMetricService, + ResourceMetricSnapshot, +} from "@dokploy/server/services/resource-metrics"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let monitoringPath = ""; + +vi.mock("@dokploy/server/constants", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + paths: () => ({ + ...actual.paths(), + MONITORING_PATH: monitoringPath, + }), + }; +}); + +const { + collectResourceMetricsForServices, + readResourceMetricHistory, + recordResourceMetricSnapshot, + serviceOwnsContainer, +} = await import("@dokploy/server/services/resource-metrics"); + +const container = ( + overrides: Partial, +): DockerContainerLabels => ({ + id: "container-id", + name: "container-name", + swarmServiceName: "", + composeProject: "", + stackNamespace: "", + ...overrides, +}); + +const service = ( + overrides: Partial, +): ResourceMetricService => ({ + serviceId: "service-id", + type: "application", + appName: "app-web", + ...overrides, +}); + +const snapshot = ( + time: string, + overrides: Partial = {}, +): ResourceMetricSnapshot => ({ + time, + cpuPercent: 0, + memoryBytes: 0, + memoryLimitBytes: 0, + blockReadBytes: 0, + blockWriteBytes: 0, + networkRxBytes: 0, + networkTxBytes: 0, + containers: 0, + ...overrides, +}); + +describe("resource metrics", () => { + let consoleError: ReturnType; + + beforeEach(async () => { + monitoringPath = await fs.mkdtemp( + path.join(os.tmpdir(), "dokploy-resource-metrics-"), + ); + consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + consoleError.mockRestore(); + await fs.rm(monitoringPath, { recursive: true, force: true }); + }); + + it("attributes containers by exact labels and safe task-name delimiters", () => { + expect( + serviceOwnsContainer( + service({ type: "compose", appName: "web" }), + container({ composeProject: "web-api", stackNamespace: "web-api" }), + ), + ).toBe(false); + + expect( + serviceOwnsContainer( + service({ type: "compose", appName: "web" }), + container({ stackNamespace: "web" }), + ), + ).toBe(true); + + expect( + serviceOwnsContainer( + service({ type: "application", appName: "web" }), + container({ name: "web-api.1.abcd", swarmServiceName: "web-api" }), + ), + ).toBe(false); + + expect( + serviceOwnsContainer( + service({ type: "application", appName: "web" }), + container({ name: "web.1.abcd" }), + ), + ).toBe(true); + + expect( + serviceOwnsContainer( + service({ type: "application", appName: "web" }), + container({ swarmServiceName: "web" }), + ), + ).toBe(true); + }); + + it("keeps one failed server from failing or zeroing every summary", async () => { + const stale = snapshot("2026-01-01T00:00:00.000Z", { + cpuPercent: 9, + containers: 1, + }); + await recordResourceMetricSnapshot("service", "remote-service", stale); + + const stats: DockerStatsRow[] = [ + { + ID: "abc123", + Name: "app-web.1.task", + CPUPerc: "12.5%", + MemUsage: "64MiB / 512MiB", + BlockIO: "1MiB / 2MiB", + NetIO: "3MiB / 4MiB", + }, + ]; + const dockerClient: ResourceMetricDockerClient = { + getStats: vi.fn(async (serverId?: string) => { + if (serverId === "server-1") { + throw new Error("remote stats failed"); + } + return stats; + }), + listContainers: vi.fn(async (serverId?: string | null) => { + if (serverId === "server-1") { + throw new Error("remote docker ps failed"); + } + return [ + container({ + id: "abc123", + name: "app-web.1.task", + swarmServiceName: "app-web", + }), + ]; + }), + }; + + const summaries = await collectResourceMetricsForServices( + [ + service({ serviceId: "local-service", appName: "app-web" }), + service({ + serviceId: "remote-service", + appName: "remote-app", + serverId: "server-1", + }), + ], + dockerClient, + ); + + expect(summaries["local-service"]?.current).toMatchObject({ + containers: 1, + cpuPercent: 12.5, + memoryBytes: 64 * 1024 * 1024, + memoryLimitBytes: 512 * 1024 * 1024, + blockReadBytes: 1024 * 1024, + blockWriteBytes: 2 * 1024 * 1024, + networkRxBytes: 3 * 1024 * 1024, + networkTxBytes: 4 * 1024 * 1024, + }); + expect(summaries["remote-service"]).toEqual({ + current: stale, + history: [stale], + unavailable: true, + }); + }); + + it("does not match a stats row with an empty ID to every container", async () => { + const dockerClient: ResourceMetricDockerClient = { + getStats: vi.fn(async () => [ + { + ID: "", + Name: "other-container", + CPUPerc: "99%", + }, + ]), + listContainers: vi.fn(async () => [ + container({ + id: "abc123", + name: "app-web.1.task", + swarmServiceName: "app-web", + }), + ]), + }; + + const summaries = await collectResourceMetricsForServices( + [service({ serviceId: "local-service", appName: "app-web" })], + dockerClient, + ); + + expect(summaries["local-service"]?.current).toMatchObject({ + containers: 0, + cpuPercent: 0, + }); + }); + + it("serializes concurrent history writes so samples are not lost", async () => { + await Promise.all( + Array.from({ length: 5 }, (_, index) => + recordResourceMetricSnapshot( + "service", + "history-service", + snapshot(`2026-01-01T00:0${index}:00.000Z`, { + cpuPercent: index, + containers: index + 1, + }), + ), + ), + ); + + const history = await readResourceMetricHistory( + "service", + "history-service", + ); + expect(history.map((entry) => entry.cpuPercent)).toEqual([0, 1, 2, 3, 4]); + expect(history.map((entry) => entry.containers)).toEqual([1, 2, 3, 4, 5]); + }); +}); diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 2a518e5525..7020cbd6e1 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -12,6 +12,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { ResourceUsageStrip } from "@/components/dashboard/resource-metrics/usage-strip"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; @@ -65,6 +66,17 @@ export const ShowProjects = () => { const { data: permissions } = api.user.getPermissions.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); const { data: availableTags } = api.tag.all.useQuery(); + const projectIds = useMemo( + () => data?.map((project) => project.projectId) ?? [], + [data], + ); + const { data: resourceMetrics } = api.project.resourceMetrics.useQuery( + { projectIds }, + { + enabled: projectIds.length > 0 && isCloud === false, + refetchInterval: 30_000, + }, + ); const [searchQuery, setSearchQuery] = useState( router.isReady && typeof router.query.q === "string" ? router.query.q : "", @@ -491,6 +503,14 @@ export const ShowProjects = () => { + + +
diff --git a/apps/dokploy/components/dashboard/resource-metrics/usage-strip.tsx b/apps/dokploy/components/dashboard/resource-metrics/usage-strip.tsx new file mode 100644 index 0000000000..754005c226 --- /dev/null +++ b/apps/dokploy/components/dashboard/resource-metrics/usage-strip.tsx @@ -0,0 +1,197 @@ +import { + Activity, + Cpu, + HardDrive, + type LucideIcon, + MemoryStick, + Network, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { RouterOutputs } from "@/utils/api"; + +type ResourceMetricsSummary = + RouterOutputs["project"]["resourceMetrics"]["services"][string]; + +interface Props { + metrics?: ResourceMetricsSummary; + className?: string; + compact?: boolean; +} + +const formatBytes = (bytes?: number) => { + if (!bytes || bytes <= 0) return "0B"; + + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)}${units[unitIndex]}`; +}; + +const formatPercent = (value?: number) => { + if (!value || value <= 0) return "0%"; + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)}%`; +}; + +const MetricSparkline = ({ + values, + className, +}: { + values: number[]; + className?: string; +}) => { + if (values.length < 2) { + return
; + } + + const width = 64; + const height = 20; + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = Math.max(max - min, 1); + const points = values + .map((value, index) => { + const x = (index / (values.length - 1)) * width; + const y = height - ((value - min) / range) * height; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + + return ( + + ); +}; + +const MetricPill = ({ + icon: Icon, + label, + value, + title, +}: { + icon: LucideIcon; + label: string; + value: string; + title: string; +}) => ( + + + + {label} + + {value} + +); + +export const ResourceUsageStrip = ({ + metrics, + className, + compact = false, +}: Props) => { + const current = metrics?.current; + const cpuHistory = + metrics?.history + .slice(-24) + .map((point: { cpuPercent: number }) => point.cpuPercent) ?? []; + + if (!current) { + if (metrics?.unavailable) { + return ( +
+ + Metrics unavailable +
+ ); + } + + return null; + } + + if (current.containers === 0) { + return ( +
+ + No running containers +
+ ); + } + + return ( +
+
+ +
+ {metrics?.unavailable && ( + + + Stale + + )} + + + + +
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index 50ac027703..78cecd7132 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -38,6 +38,7 @@ import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/adva import { DuplicateProject } from "@/components/dashboard/project/duplicate-project"; import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables"; import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment"; +import { ResourceUsageStrip } from "@/components/dashboard/resource-metrics/usage-strip"; import { LibsqlIcon, MariadbIcon, @@ -300,6 +301,7 @@ const EnvironmentPage = ( const { projectId, environmentId } = props; const { data: auth } = api.user.get.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: environments } = api.environment.byProjectId.useQuery({ projectId: projectId, @@ -414,6 +416,13 @@ const EnvironmentPage = ( (currentEnvironment.libsql?.length || 0) === 0); const applications = extractServicesFromEnvironment(currentEnvironment); + const { data: resourceMetrics } = api.project.resourceMetrics.useQuery( + { projectIds: [projectId], environmentId }, + { + enabled: !!projectId && !!environmentId && isCloud === false, + refetchInterval: 30_000, + }, + ); const [searchQuery, setSearchQuery] = useState(""); const serviceTypes = [ @@ -1670,6 +1679,15 @@ const EnvironmentPage = ( {service.description} )} +
diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..fc53f23a51 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -1,4 +1,6 @@ import { + aggregateResourceMetricSnapshots, + collectResourceMetricsForServices, createApplication, createBackup, createCompose, @@ -28,6 +30,10 @@ import { findRedisById, findUserById, IS_CLOUD, + type ResourceMetricService, + type ResourceMetricSnapshot, + readResourceMetricHistory, + recordResourceMetricSnapshot, updateProjectById, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -66,6 +72,289 @@ import { } from "@/server/db/schema"; export const projectRouter = createTRPCRouter({ + resourceMetrics: withPermission("monitoring", "read") + .input( + z.object({ + projectIds: z.array(z.string()).max(100).optional(), + environmentId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (IS_CLOUD) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Functionality not available in cloud version", + }); + } + + if (input.projectIds?.length === 0) { + return { projects: {}, services: {} }; + } + + 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 { projects: {}, services: {} }; + } + } + + const projectFilters = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectIds?.length) { + projectFilters.push( + sql`${projects.projectId} IN (${sql.join( + input.projectIds.map((projectId) => sql`${projectId}`), + sql`, `, + )})`, + ); + } + if (!isPrivileged) { + projectFilters.push( + sql`${projects.projectId} IN (${sql.join( + accessedProjects.map((projectId) => sql`${projectId}`), + sql`, `, + )})`, + ); + } + + const environmentFilters = []; + if (input.environmentId) { + environmentFilters.push( + eq(environments.environmentId, input.environmentId), + ); + } + if (!isPrivileged) { + environmentFilters.push( + accessedEnvironments.length === 0 + ? sql`false` + : sql`${environments.environmentId} IN (${sql.join( + accessedEnvironments.map((envId) => sql`${envId}`), + sql`, `, + )})`, + ); + } + + const serviceFilter = (col: AnyPgColumn) => + isPrivileged ? undefined : buildServiceFilter(col, accessedServices); + + const rows = await db.query.projects.findMany({ + where: and(...projectFilters), + columns: { projectId: true }, + with: { + environments: { + where: + environmentFilters.length > 0 + ? and(...environmentFilters) + : undefined, + columns: { environmentId: true }, + with: { + applications: { + where: serviceFilter(applications.applicationId), + columns: { + applicationId: true, + appName: true, + serverId: true, + }, + }, + compose: { + where: serviceFilter(compose.composeId), + columns: { + composeId: true, + appName: true, + serverId: true, + }, + }, + libsql: { + where: serviceFilter(libsql.libsqlId), + columns: { + libsqlId: true, + appName: true, + serverId: true, + }, + }, + mariadb: { + where: serviceFilter(mariadb.mariadbId), + columns: { + mariadbId: true, + appName: true, + serverId: true, + }, + }, + mongo: { + where: serviceFilter(mongo.mongoId), + columns: { + mongoId: true, + appName: true, + serverId: true, + }, + }, + mysql: { + where: serviceFilter(mysql.mysqlId), + columns: { + mysqlId: true, + appName: true, + serverId: true, + }, + }, + postgres: { + where: serviceFilter(postgres.postgresId), + columns: { + postgresId: true, + appName: true, + serverId: true, + }, + }, + redis: { + where: serviceFilter(redis.redisId), + columns: { + redisId: true, + appName: true, + serverId: true, + }, + }, + }, + }, + }, + }); + + const services: ResourceMetricService[] = []; + const servicesByProject = new Map(); + const addService = ( + projectId: string, + service: ResourceMetricService, + ) => { + if (!service.appName) return; + services.push(service); + servicesByProject.set(projectId, [ + ...(servicesByProject.get(projectId) ?? []), + service.serviceId, + ]); + }; + + for (const project of rows) { + servicesByProject.set(project.projectId, []); + for (const environment of project.environments) { + for (const item of environment.applications) { + addService(project.projectId, { + serviceId: item.applicationId, + type: "application", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.compose) { + addService(project.projectId, { + serviceId: item.composeId, + type: "compose", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.libsql) { + addService(project.projectId, { + serviceId: item.libsqlId, + type: "libsql", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.mariadb) { + addService(project.projectId, { + serviceId: item.mariadbId, + type: "mariadb", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.mongo) { + addService(project.projectId, { + serviceId: item.mongoId, + type: "mongo", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.mysql) { + addService(project.projectId, { + serviceId: item.mysqlId, + type: "mysql", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.postgres) { + addService(project.projectId, { + serviceId: item.postgresId, + type: "postgres", + appName: item.appName, + serverId: item.serverId, + }); + } + for (const item of environment.redis) { + addService(project.projectId, { + serviceId: item.redisId, + type: "redis", + appName: item.appName, + serverId: item.serverId, + }); + } + } + } + + const serviceMetrics = await collectResourceMetricsForServices(services); + const projectMetrics: Record = + {}; + + for (const [projectId, serviceIds] of servicesByProject) { + const serviceSummaries = serviceIds + .map((serviceId) => serviceMetrics[serviceId]) + .filter((summary): summary is (typeof serviceMetrics)[string] => + Boolean(summary), + ); + const hasUnavailableService = serviceSummaries.some( + (summary) => summary.unavailable, + ); + const snapshots = serviceSummaries + .map((summary) => summary.current) + .filter((snapshot): snapshot is ResourceMetricSnapshot => + Boolean(snapshot), + ); + + const current = + snapshots.length > 0 + ? aggregateResourceMetricSnapshots(snapshots) + : null; + const history = current + ? await recordResourceMetricSnapshot("project", projectId, current) + : await readResourceMetricHistory("project", projectId); + + projectMetrics[projectId] = { + current: current ?? history.at(-1) ?? null, + history, + unavailable: hasUnavailableService, + }; + } + + return { + projects: projectMetrics, + services: serviceMetrics, + }; + }), + create: protectedProcedure .input(apiCreateProject) .mutation(async ({ ctx, input }) => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7bda4615ad..1fa13a7c9a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -41,6 +41,7 @@ export * from "./services/proprietary/sso"; export * from "./services/redirect"; export * from "./services/redis"; export * from "./services/registry"; +export * from "./services/resource-metrics"; export * from "./services/rollbacks"; export * from "./services/schedule"; export * from "./services/security"; diff --git a/packages/server/src/services/resource-metrics.ts b/packages/server/src/services/resource-metrics.ts new file mode 100644 index 0000000000..b1e737b63a --- /dev/null +++ b/packages/server/src/services/resource-metrics.ts @@ -0,0 +1,411 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { paths } from "../constants"; +import { execAsync, execAsyncRemote } from "../utils/process/execAsync"; + +export type ResourceMetricServiceType = + | "application" + | "compose" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; + +export type ResourceMetricService = { + serviceId: string; + type: ResourceMetricServiceType; + appName: string; + serverId?: string | null; +}; + +export type ResourceMetricSnapshot = { + time: string; + cpuPercent: number; + memoryBytes: number; + memoryLimitBytes: number; + blockReadBytes: number; + blockWriteBytes: number; + networkRxBytes: number; + networkTxBytes: number; + containers: number; +}; + +export type ResourceMetricSummary = { + current: ResourceMetricSnapshot | null; + history: ResourceMetricSnapshot[]; + unavailable?: boolean; +}; + +export type DockerStatsRow = { + BlockIO?: string; + CPUPerc?: string; + ID?: string; + MemUsage?: string; + Name?: string; + NetIO?: string; +}; + +export type DockerContainerLabels = { + id: string; + name: string; + swarmServiceName: string; + composeProject: string; + stackNamespace: string; +}; + +export type ResourceMetricDockerClient = { + getStats: (serverId?: string) => Promise; + listContainers: ( + serverId?: string | null, + ) => Promise; +}; + +const HISTORY_LIMIT = 120; +const MIN_SAMPLE_INTERVAL_MS = 30_000; +const dockerStatsCommand = + 'docker stats --no-stream --format \'{"BlockIO":"{{.BlockIO}}","CPUPerc":"{{.CPUPerc}}","Container":"{{.Container}}","ID":"{{.ID}}","MemPerc":"{{.MemPerc}}","MemUsage":"{{.MemUsage}}","Name":"{{.Name}}","NetIO":"{{.NetIO}}"}\''; +const containerLabelsCommand = + 'docker ps --format \'{{.ID}}\\t{{.Names}}\\t{{.Label "com.docker.swarm.service.name"}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.stack.namespace"}}\''; +const historyWriteLocks = new Map>(); + +const emptySnapshot = (): ResourceMetricSnapshot => ({ + time: new Date().toISOString(), + cpuPercent: 0, + memoryBytes: 0, + memoryLimitBytes: 0, + blockReadBytes: 0, + blockWriteBytes: 0, + networkRxBytes: 0, + networkTxBytes: 0, + containers: 0, +}); + +const parsePercent = (value?: string) => { + const parsed = Number.parseFloat(value?.replace("%", "") ?? "0"); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const parseBytes = (value?: string) => { + if (!value) return 0; + const normalized = value.trim().replace(",", ".").toLowerCase(); + const match = normalized.match(/^([0-9.]+)\s*([kmgtp]?i?b?)?$/); + if (!match) return 0; + + const amount = Number.parseFloat(match[1] ?? "0"); + if (!Number.isFinite(amount)) return 0; + + const unit = match[2] || "b"; + const multipliers: Record = { + b: 1, + kb: 1000, + mb: 1000 ** 2, + gb: 1000 ** 3, + tb: 1000 ** 4, + pb: 1000 ** 5, + kib: 1024, + mib: 1024 ** 2, + gib: 1024 ** 3, + tib: 1024 ** 4, + pib: 1024 ** 5, + }; + + return amount * (multipliers[unit] ?? 1); +}; + +const parsePair = (value?: string) => { + const [left, right] = value?.split("/") ?? []; + return { + left: parseBytes(left), + right: parseBytes(right), + }; +}; + +export const parseDockerStatsOutput = (stdout: string) => { + if (!stdout.trim()) return []; + + return stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line) as DockerStatsRow); +}; + +export const parseDockerContainerLabelsOutput = (stdout: string) => { + if (!stdout.trim()) return []; + + return stdout + .trim() + .split("\n") + .map((line) => { + const [id, name, swarmServiceName, composeProject, stackNamespace] = + line.split("\t"); + return { + id: id ?? "", + name: name ?? "", + swarmServiceName: swarmServiceName ?? "", + composeProject: composeProject ?? "", + stackNamespace: stackNamespace ?? "", + }; + }); +}; + +const findStatsForContainer = ( + stats: DockerStatsRow[], + containerId: string, + containerName: string, +) => + stats.find((item) => { + const id = item.ID ?? ""; + const name = item.Name ?? ""; + const matchesId = + id.length > 0 && + (id === containerId || + containerId.startsWith(id) || + id.startsWith(containerId)); + const matchesName = name.length > 0 && name === containerName; + + return matchesId || matchesName; + }); + +const runDockerCommand = async ( + serverId: string | null | undefined, + command: string, +) => { + const result = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return result.stdout; +}; + +const listContainerStats = async (serverId?: string | null) => { + return parseDockerStatsOutput( + await runDockerCommand(serverId, dockerStatsCommand), + ); +}; + +const listContainerLabels = async (serverId?: string | null) => { + return parseDockerContainerLabelsOutput( + await runDockerCommand(serverId, containerLabelsCommand), + ); +}; + +export const serviceOwnsContainer = ( + service: ResourceMetricService, + container: DockerContainerLabels, +) => { + if (service.type === "compose") { + return ( + container.composeProject === service.appName || + container.stackNamespace === service.appName + ); + } + + return ( + container.swarmServiceName === service.appName || + container.name.startsWith(`${service.appName}.`) + ); +}; + +export const aggregateDockerStatsRows = (rows: DockerStatsRow[]) => { + const snapshot = emptySnapshot(); + snapshot.containers = rows.length; + + for (const row of rows) { + const memory = parsePair(row.MemUsage); + const block = parsePair(row.BlockIO); + const network = parsePair(row.NetIO); + + snapshot.cpuPercent += parsePercent(row.CPUPerc); + snapshot.memoryBytes += memory.left; + snapshot.memoryLimitBytes += memory.right; + snapshot.blockReadBytes += block.left; + snapshot.blockWriteBytes += block.right; + snapshot.networkRxBytes += network.left; + snapshot.networkTxBytes += network.right; + } + + return snapshot; +}; + +const defaultDockerClient: ResourceMetricDockerClient = { + getStats: listContainerStats, + listContainers: listContainerLabels, +}; + +const historyPath = (scope: "project" | "service", id: string) => { + const { MONITORING_PATH } = paths(); + return path.join(MONITORING_PATH, "resources", scope, `${id}.json`); +}; + +export const readResourceMetricHistory = async ( + scope: "project" | "service", + id: string, +) => { + try { + const data = await fs.readFile(historyPath(scope, id), "utf-8"); + return JSON.parse(data) as ResourceMetricSnapshot[]; + } catch { + return []; + } +}; + +const writeResourceMetricHistory = async ( + filePath: string, + history: ResourceMetricSnapshot[], +) => { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`; + + try { + await fs.writeFile(tempPath, JSON.stringify(history)); + await fs.rename(tempPath, filePath); + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +}; + +const withHistoryWriteLock = async ( + filePath: string, + operation: () => Promise, +) => { + const previous = historyWriteLocks.get(filePath) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const tracked = current.finally(() => { + if (historyWriteLocks.get(filePath) === tracked) { + historyWriteLocks.delete(filePath); + } + }); + + historyWriteLocks.set(filePath, tracked); + return current; +}; + +export const recordResourceMetricSnapshot = async ( + scope: "project" | "service", + id: string, + snapshot: ResourceMetricSnapshot, +) => { + const filePath = historyPath(scope, id); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + return withHistoryWriteLock(filePath, async () => { + const history = await readResourceMetricHistory(scope, id); + const last = history.at(-1); + const snapshotTime = new Date(snapshot.time).getTime(); + const lastTime = last ? new Date(last.time).getTime() : Number.NaN; + const shouldReplaceLast = + last && + Number.isFinite(snapshotTime) && + Number.isFinite(lastTime) && + Math.abs(snapshotTime - lastTime) < MIN_SAMPLE_INTERVAL_MS; + + const nextHistory = shouldReplaceLast + ? [...history.slice(0, -1), snapshot] + : [...history, snapshot]; + const limitedHistory = [...nextHistory] + .sort( + (left, right) => + new Date(left.time).getTime() - new Date(right.time).getTime(), + ) + .slice(-HISTORY_LIMIT); + + await writeResourceMetricHistory(filePath, limitedHistory); + return limitedHistory; + }); +}; + +export const aggregateResourceMetricSnapshots = ( + snapshots: ResourceMetricSnapshot[], +) => { + const aggregate = emptySnapshot(); + aggregate.time = new Date().toISOString(); + + for (const snapshot of snapshots) { + aggregate.cpuPercent += snapshot.cpuPercent; + aggregate.memoryBytes += snapshot.memoryBytes; + aggregate.memoryLimitBytes += snapshot.memoryLimitBytes; + aggregate.blockReadBytes += snapshot.blockReadBytes; + aggregate.blockWriteBytes += snapshot.blockWriteBytes; + aggregate.networkRxBytes += snapshot.networkRxBytes; + aggregate.networkTxBytes += snapshot.networkTxBytes; + aggregate.containers += snapshot.containers; + } + + return aggregate; +}; + +export const collectResourceMetricsForServices = async ( + services: ResourceMetricService[], + dockerClient = defaultDockerClient, +) => { + const summaries: Record = {}; + const servicesByServer = new Map(); + + for (const service of services) { + const serverKey = service.serverId ?? "dokploy"; + servicesByServer.set(serverKey, [ + ...(servicesByServer.get(serverKey) ?? []), + service, + ]); + } + + for (const [serverKey, serverServices] of servicesByServer) { + const serverId = serverKey === "dokploy" ? undefined : serverKey; + let stats: DockerStatsRow[]; + let containers: DockerContainerLabels[]; + + try { + [stats, containers] = await Promise.all([ + dockerClient.getStats(serverId), + dockerClient.listContainers(serverId), + ]); + } catch (error) { + console.error("collectResourceMetricsForServices error:", { + serverId, + error, + }); + + for (const service of serverServices) { + const history = await readResourceMetricHistory( + "service", + service.serviceId, + ); + summaries[service.serviceId] = { + current: history.at(-1) ?? null, + history, + unavailable: true, + }; + } + continue; + } + + for (const service of serverServices) { + const serviceContainers = containers.filter((container) => + serviceOwnsContainer(service, container), + ); + const rows = serviceContainers + .map((container) => + findStatsForContainer(stats, container.id, container.name), + ) + .filter((row): row is DockerStatsRow => Boolean(row)); + + const current = aggregateDockerStatsRows(rows); + const history = await recordResourceMetricSnapshot( + "service", + service.serviceId, + current, + ); + + summaries[service.serviceId] = { + current, + history, + }; + } + } + + return summaries; +};