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 54b7bace4e..7479128d95 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 @@ -1,7 +1,26 @@ import { formatMb } from "@dokploy/server/monitoring/units"; -import { useEffect, useState } from "react"; +import { FolderKanban } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { api } from "@/utils/api"; import { DockerBlockChart } from "./docker-block-chart"; import { DockerCpuChart } from "./docker-cpu-chart"; @@ -10,6 +29,8 @@ import { DockerDiskUsageChart } from "./docker-disk-usage-chart"; import { DockerMemoryChart } from "./docker-memory-chart"; import { DockerNetworkChart } from "./docker-network-chart"; +const ALL_SERVER = "all"; + const defaultData = { cpu: { value: "0%", @@ -53,8 +74,8 @@ export interface DockerStats { }; memory: { value: { - used: number; - total: number; + used: number | string; + total: number | string; }; time: string; }; @@ -116,39 +137,61 @@ export const convertMemoryToBytes = ( } }; +const resetAccumulativeData = (): DockerStatsJSON => ({ + cpu: [], + memory: [], + block: [], + network: [], + disk: [], +}); + export const ContainerFreeMonitoring = ({ appName, appType = "application", }: Props) => { + const isServerView = appName === "dokploy"; + const [selectedProjectId, setSelectedProjectId] = useState(ALL_SERVER); + const isProjectView = isServerView && selectedProjectId !== ALL_SERVER; + + const { data: projects } = api.project.all.useQuery(undefined, { + enabled: isServerView, + }); + const { data } = api.application.readAppMonitoring.useQuery( { appName }, { refetchOnWindowFocus: false, + enabled: !isProjectView, }, ); - const [accumulativeData, setAccumulativeData] = useState({ - cpu: [], - memory: [], - block: [], - network: [], - disk: [], - }); + + const { data: projectStats } = api.project.resourceStats.useQuery( + { projectId: selectedProjectId }, + { + enabled: isProjectView, + refetchInterval: 2000, + refetchOnWindowFocus: false, + }, + ); + + const [accumulativeData, setAccumulativeData] = useState( + resetAccumulativeData, + ); const [currentData, setCurrentData] = useState(defaultData); + const lastProjectSampleRef = useRef(""); + + const selectedProjectName = + projects?.find((project) => project.projectId === selectedProjectId) + ?.name ?? projectStats?.projectName; useEffect(() => { setCurrentData(defaultData); - - setAccumulativeData({ - cpu: [], - memory: [], - block: [], - network: [], - disk: [], - }); - }, [appName]); + setAccumulativeData(resetAccumulativeData()); + lastProjectSampleRef.current = ""; + }, [appName, selectedProjectId]); useEffect(() => { - if (!data) return; + if (isProjectView || !data) return; setCurrentData({ cpu: data.cpu[data.cpu.length - 1] ?? currentData.cpu, @@ -164,9 +207,38 @@ export const ContainerFreeMonitoring = ({ memory: data?.memory || [], network: data?.network || [], }); - }, [data]); + }, [data, isProjectView]); + + useEffect(() => { + if (!isProjectView || !projectStats?.aggregated) return; + + const sampleTime = projectStats.aggregated.cpu.time; + if (lastProjectSampleRef.current === sampleTime) return; + lastProjectSampleRef.current = sampleTime; + + const nextData: DockerStats = { + cpu: projectStats.aggregated.cpu, + memory: projectStats.aggregated.memory, + block: projectStats.aggregated.block, + network: projectStats.aggregated.network, + disk: defaultData.disk, + }; + + setCurrentData(nextData); + + const MAX_DATA_POINTS = 300; + setAccumulativeData((prevData) => ({ + cpu: [...prevData.cpu, nextData.cpu].slice(-MAX_DATA_POINTS), + memory: [...prevData.memory, nextData.memory].slice(-MAX_DATA_POINTS), + block: [...prevData.block, nextData.block].slice(-MAX_DATA_POINTS), + network: [...prevData.network, nextData.network].slice(-MAX_DATA_POINTS), + disk: prevData.disk, + })); + }, [projectStats, isProjectView]); useEffect(() => { + if (isProjectView) return; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; const ws = new WebSocket(wsUrl); @@ -175,7 +247,7 @@ export const ContainerFreeMonitoring = ({ const value = JSON.parse(e.data); if (!value) return; - const data = { + const nextData = { cpu: value.data.cpu ?? currentData.cpu, memory: value.data.memory ?? currentData.memory, block: value.data.block ?? currentData.block, @@ -183,15 +255,17 @@ export const ContainerFreeMonitoring = ({ network: value.data.network ?? currentData.network, }; - setCurrentData(data); + setCurrentData(nextData); const MAX_DATA_POINTS = 300; setAccumulativeData((prevData) => ({ - cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS), - memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS), - block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS), - network: [...prevData.network, data.network].slice(-MAX_DATA_POINTS), - disk: [...prevData.disk, data.disk].slice(-MAX_DATA_POINTS), + cpu: [...prevData.cpu, nextData.cpu].slice(-MAX_DATA_POINTS), + memory: [...prevData.memory, nextData.memory].slice(-MAX_DATA_POINTS), + block: [...prevData.block, nextData.block].slice(-MAX_DATA_POINTS), + network: [...prevData.network, nextData.network].slice( + -MAX_DATA_POINTS, + ), + disk: [...prevData.disk, nextData.disk].slice(-MAX_DATA_POINTS), })); }; @@ -200,17 +274,55 @@ export const ContainerFreeMonitoring = ({ }; return () => ws.close(); - }, [appName]); + }, [appName, appType, isProjectView]); + + const memoryUsedLabel = String(currentData.memory.value.used ?? "0"); + const memoryTotalLabel = String(currentData.memory.value.total ?? "0"); + const memoryProgress = + (convertMemoryToBytes(memoryUsedLabel) / + (convertMemoryToBytes(memoryTotalLabel) || 1)) * + 100; return (
-
+

Monitoring

- Watch the usage of your server in the current app + {isProjectView + ? `Resource usage for ${selectedProjectName || "selected project"}` + : "Watch the usage of your server in the current app"}

+ {isServerView && ( +
+ +
+ )}
@@ -224,9 +336,11 @@ export const ContainerFreeMonitoring = ({ Used: {String(currentData.cpu.value ?? "0%")} @@ -241,30 +355,19 @@ export const ContainerFreeMonitoring = ({
- {`Used: ${currentData.memory.value.used} / Limit: ${currentData.memory.value.total} `} + {`Used: ${memoryUsedLabel} / Limit: ${memoryTotalLabel} `} - +
- {appName === "dokploy" && ( + {!isProjectView && appName === "dokploy" && ( Disk Space @@ -286,7 +389,7 @@ export const ContainerFreeMonitoring = ({ )} - {appName === "dokploy" && ( + {!isProjectView && appName === "dokploy" && ( @@ -326,6 +429,64 @@ export const ContainerFreeMonitoring = ({
+ + {isProjectView && ( + + +
+ + Services breakdown + +

+ Live CPU and memory usage by service in this project +

+
+
+ + {!projectStats?.services?.length ? ( +

+ No services found in this project. +

+ ) : ( +
+ + + + Service + Type + Containers + CPU + Memory + + + + {projectStats.services.map((service) => ( + + + {service.name} + + + + {service.type} + + + {service.containerCount} + {service.cpuPerc.toFixed(2)}% + + {service.memUsed} + {service.containerCount > 0 + ? ` / ${service.memLimit}` + : ""} + + + ))} + +
+
+ )} +
+
+ )}
); }; diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..0a1ab5c419 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -27,6 +27,7 @@ import { findProjectById, findRedisById, findUserById, + getProjectResourceStats, IS_CLOUD, updateProjectById, } from "@dokploy/server"; @@ -629,6 +630,49 @@ export const projectRouter = createTRPCRouter({ }; }), + resourceStats: withPermission("monitoring", "read") + .input(apiFindOneProject) + .query(async ({ input, ctx }) => { + if (IS_CLOUD) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Functionality not available in cloud version", + }); + } + + const project = await findProjectById(input.projectId); + if (project.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this project", + }); + } + + const isPrivileged = + ctx.user.role === "owner" || ctx.user.role === "admin"; + + if (!isPrivileged) { + const { accessedProjects, accessedServices } = + await findMemberByUserId( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + + if (!accessedProjects.includes(input.projectId)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to this project", + }); + } + + return await getProjectResourceStats(input.projectId, { + accessedServices, + }); + } + + return await getProjectResourceStats(input.projectId); + }), + search: protectedProcedure .input( z.object({ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e81ebba2a6..edf9b455d5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,6 +6,7 @@ export * from "./db/validations/domain"; export * from "./db/validations/index"; export * from "./lib/auth"; export * from "./lib/logger"; +export * from "./monitoring/project-stats"; export * from "./monitoring/utils"; export * from "./services/admin"; export * from "./services/application"; diff --git a/packages/server/src/monitoring/project-stats.ts b/packages/server/src/monitoring/project-stats.ts new file mode 100644 index 0000000000..3d18f6100c --- /dev/null +++ b/packages/server/src/monitoring/project-stats.ts @@ -0,0 +1,449 @@ +import { getAllContainerStats } from "../services/docker"; +import { findProjectById } from "../services/project"; +import type { Container } from "./utils"; + +export type ProjectServiceType = + | "application" + | "compose" + | "mariadb" + | "postgres" + | "mysql" + | "mongo" + | "redis" + | "libsql"; + +export interface ProjectServiceRef { + id: string; + name: string; + appName: string; + type: ProjectServiceType; + serverId: string | null; +} + +export interface ProjectServiceStats extends ProjectServiceRef { + cpuPerc: number; + memUsed: string; + memLimit: string; + memUsedBytes: number; + memLimitBytes: number; + containerCount: number; + blockReadMb: number; + blockWriteMb: number; + netInputMb: number; + netOutputMb: number; +} + +export interface ProjectResourceStats { + projectId: string; + projectName: string; + aggregated: { + cpu: { value: string; time: string }; + memory: { + value: { used: string; total: string }; + time: string; + }; + block: { + value: { readMb: number; writeMb: number }; + time: string; + }; + network: { + value: { inputMb: number; outputMb: number }; + time: string; + }; + }; + services: ProjectServiceStats[]; +} + +type ContainerWithServer = Container & { + serverId: string | null; +}; + +const UNIT_TO_BYTES: Record = { + b: 1, + kb: 1000, + mb: 1000 ** 2, + gb: 1000 ** 3, + tb: 1000 ** 4, + kib: 1024, + mib: 1024 ** 2, + gib: 1024 ** 3, + tib: 1024 ** 4, +}; + +export const parseDockerSizeToBytes = (value: string | undefined): number => { + if (!value || typeof value !== "string") return 0; + const trimmed = value.trim(); + if (!trimmed || trimmed === "--") return 0; + + const match = trimmed.match(/^([\d.]+)\s*([a-zA-Z]+)$/); + if (!match) { + const asNumber = Number.parseFloat(trimmed); + return Number.isFinite(asNumber) ? asNumber : 0; + } + + const amount = Number.parseFloat(match[1] || "0"); + const unit = (match[2] || "").toLowerCase(); + const multiplier = UNIT_TO_BYTES[unit] ?? 1; + return Number.isFinite(amount) ? amount * multiplier : 0; +}; + +export const formatBytesAsDockerSize = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0B"; + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)}GiB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(2)}MiB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)}KiB`; + return `${Math.round(bytes)}B`; +}; + +const parseCpuPercent = (value: string | undefined): number => { + if (!value) return 0; + const parsed = Number.parseFloat(String(value).replace("%", "")); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const parseIoPairToMb = ( + value: string | undefined, +): { left: number; right: number } => { + if (!value) return { left: 0, right: 0 }; + const [leftRaw, rightRaw] = value.split("/").map((part) => part.trim()); + return { + left: parseDockerSizeToBytes(leftRaw) / (1000 * 1000), + right: parseDockerSizeToBytes(rightRaw) / (1000 * 1000), + }; +}; + +const parseMemUsage = ( + value: string | undefined, +): { usedBytes: number; limitBytes: number } => { + if (!value) return { usedBytes: 0, limitBytes: 0 }; + const [usedRaw, limitRaw] = value.split("/").map((part) => part.trim()); + return { + usedBytes: parseDockerSizeToBytes(usedRaw), + limitBytes: parseDockerSizeToBytes(limitRaw), + }; +}; + +const pushService = ( + services: ProjectServiceRef[], + service: ProjectServiceRef, + allow: (id: string) => boolean, +) => { + if (!allow(service.id)) return; + services.push(service); +}; + +export const collectProjectServices = ( + project: Awaited>, + accessedServices?: string[], +): ProjectServiceRef[] => { + const allow = (id: string) => + !accessedServices || accessedServices.includes(id); + + const services: ProjectServiceRef[] = []; + + for (const environment of project.environments) { + for (const item of environment.applications) { + pushService( + services, + { + id: item.applicationId, + name: item.name, + appName: item.appName, + type: "application", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.compose) { + pushService( + services, + { + id: item.composeId, + name: item.name, + appName: item.appName, + type: "compose", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.mariadb) { + pushService( + services, + { + id: item.mariadbId, + name: item.name, + appName: item.appName, + type: "mariadb", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.postgres) { + pushService( + services, + { + id: item.postgresId, + name: item.name, + appName: item.appName, + type: "postgres", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.mysql) { + pushService( + services, + { + id: item.mysqlId, + name: item.name, + appName: item.appName, + type: "mysql", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.mongo) { + pushService( + services, + { + id: item.mongoId, + name: item.name, + appName: item.appName, + type: "mongo", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.redis) { + pushService( + services, + { + id: item.redisId, + name: item.name, + appName: item.appName, + type: "redis", + serverId: item.serverId ?? null, + }, + allow, + ); + } + for (const item of environment.libsql) { + pushService( + services, + { + id: item.libsqlId, + name: item.name, + appName: item.appName, + type: "libsql", + serverId: item.serverId ?? null, + }, + allow, + ); + } + } + + return services; +}; + +/** + * Score how specifically a container belongs to a service. + * Longer appName matches win so `myapp-api` is not attributed to `myapp`. + * Hyphen prefixes are intentionally excluded because Dokploy app names themselves + * commonly contain hyphens (`myapp-api`), which would create false ownership. + * Returns -1 when there is no valid ownership match. + */ +export const getContainerServiceMatchScore = ( + containerName: string, + appName: string, +): number => { + const name = containerName.toLowerCase(); + const normalizedAppName = appName.toLowerCase(); + if (!normalizedAppName || !name) return -1; + + if (name === normalizedAppName) { + return normalizedAppName.length * 1000; + } + + // Swarm task names: appName.1.hash + if (name.startsWith(`${normalizedAppName}.`)) { + return normalizedAppName.length; + } + + // Compose project containers: appName_service_1 + if (name.startsWith(`${normalizedAppName}_`)) { + return normalizedAppName.length; + } + + return -1; +}; + +export const findBestMatchingService = ( + containerName: string, + services: T[], + serverId: string | null, +): T | undefined => { + let best: T | undefined; + let bestScore = -1; + + for (const service of services) { + if ((service.serverId ?? null) !== serverId) continue; + const score = getContainerServiceMatchScore(containerName, service.appName); + if (score > bestScore) { + bestScore = score; + best = service; + } + } + + return best; +}; + +export const aggregateProjectContainerStats = ( + services: ProjectServiceRef[], + containers: ContainerWithServer[], + now = new Date().toISOString(), +): Pick => { + const serviceStats: ProjectServiceStats[] = services.map((service) => ({ + ...service, + cpuPerc: 0, + memUsed: "0B", + memLimit: "0B", + memUsedBytes: 0, + memLimitBytes: 0, + containerCount: 0, + blockReadMb: 0, + blockWriteMb: 0, + netInputMb: 0, + netOutputMb: 0, + })); + + let totalCpu = 0; + let totalMemUsed = 0; + let maxMemLimit = 0; + let totalBlockRead = 0; + let totalBlockWrite = 0; + let totalNetIn = 0; + let totalNetOut = 0; + + for (const container of containers) { + const matched = findBestMatchingService( + container.Name || "", + serviceStats, + container.serverId, + ); + if (!matched) continue; + + const cpu = parseCpuPercent(container.CPUPerc); + const memory = parseMemUsage(container.MemUsage); + const block = parseIoPairToMb(container.BlockIO); + const network = parseIoPairToMb(container.NetIO); + + matched.cpuPerc += cpu; + matched.memUsedBytes += memory.usedBytes; + matched.memLimitBytes = Math.max(matched.memLimitBytes, memory.limitBytes); + matched.containerCount += 1; + matched.blockReadMb += block.left; + matched.blockWriteMb += block.right; + matched.netInputMb += network.left; + matched.netOutputMb += network.right; + + totalCpu += cpu; + totalMemUsed += memory.usedBytes; + maxMemLimit = Math.max(maxMemLimit, memory.limitBytes); + totalBlockRead += block.left; + totalBlockWrite += block.right; + totalNetIn += network.left; + totalNetOut += network.right; + } + + for (const service of serviceStats) { + service.memUsed = formatBytesAsDockerSize(service.memUsedBytes); + service.memLimit = formatBytesAsDockerSize(service.memLimitBytes); + service.cpuPerc = Number(service.cpuPerc.toFixed(2)); + service.blockReadMb = Number(service.blockReadMb.toFixed(2)); + service.blockWriteMb = Number(service.blockWriteMb.toFixed(2)); + service.netInputMb = Number(service.netInputMb.toFixed(2)); + service.netOutputMb = Number(service.netOutputMb.toFixed(2)); + } + + serviceStats.sort( + (a, b) => b.cpuPerc - a.cpuPerc || b.memUsedBytes - a.memUsedBytes, + ); + + return { + aggregated: { + cpu: { + value: `${totalCpu.toFixed(2)}%`, + time: now, + }, + memory: { + value: { + used: formatBytesAsDockerSize(totalMemUsed), + total: formatBytesAsDockerSize(maxMemLimit), + }, + time: now, + }, + block: { + value: { + readMb: Number(totalBlockRead.toFixed(2)), + writeMb: Number(totalBlockWrite.toFixed(2)), + }, + time: now, + }, + network: { + value: { + inputMb: Number(totalNetIn.toFixed(2)), + outputMb: Number(totalNetOut.toFixed(2)), + }, + time: now, + }, + }, + services: serviceStats, + }; +}; + +const collectContainersForServers = async ( + serverIds: Array, +): Promise => { + if (serverIds.length === 0) return []; + + const batches = await Promise.all( + serverIds.map(async (serverId) => { + const stats = (await getAllContainerStats( + serverId ?? undefined, + )) as Container[]; + return stats.map((stat) => ({ + ...stat, + serverId, + })); + }), + ); + + return batches.flat(); +}; + +export const getProjectResourceStats = async ( + projectId: string, + options?: { accessedServices?: string[] }, +): Promise => { + const project = await findProjectById(projectId); + const services = collectProjectServices(project, options?.accessedServices); + const serverIds = [...new Set(services.map((service) => service.serverId))]; + const containers = await collectContainersForServers(serverIds); + const { aggregated, services: serviceStats } = aggregateProjectContainerStats( + services, + containers, + ); + + return { + projectId: project.projectId, + projectName: project.name, + aggregated, + services: serviceStats, + }; +};