This commit is contained in:
Shuvo 2026-09-11 13:11:54 -04:00 committed by GitHub
commit 969deb2f8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1098 additions and 47 deletions

View File

@ -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<ContainerWithLabels[]>>(),
getAllContainerStats:
vi.fn<(serverId?: string) => Promise<Record<string, string>[]>>(),
}));
vi.mock("@dokploy/server/services/docker", async (importOriginal) => ({
...(await importOriginal<typeof import("@dokploy/server/services/docker")>()),
getAllContainersWithLabels,
getAllContainerStats,
}));
const baseService: Omit<ServiceDescriptor, "id" | "name" | "appName" | "type"> =
{
status: "done",
projectId: "project-1",
projectName: "Project",
environmentId: "env-1",
environmentName: "production",
};
const container = (
overrides: Partial<ContainerWithLabels>,
): ContainerWithLabels => ({
containerId: "container-id",
name: "container-name",
image: "some-image:latest",
state: "running",
labels: {},
sizeMb: 0,
virtualSizeMb: 0,
...overrides,
});
const stat = (overrides: Partial<Record<string, string>>) => ({
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,
});
});
});

View File

@ -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 (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{label}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
};
export const columns: ColumnDef<ServiceUsageRow>[] = [
{
accessorKey: "projectName",
header: sortableHeader("Project"),
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.projectName}</span>
<span className="text-xs text-muted-foreground">
{row.original.environmentName}
</span>
</div>
),
},
{
accessorKey: "name",
header: sortableHeader("Service"),
cell: ({ row }) => {
const Icon = SERVICE_ICONS[row.original.type];
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{row.original.name}</span>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status as
| "running"
| "error"
| "done"
| "idle"
| null;
return <StatusTooltip status={status} />;
},
},
{
id: "containers",
accessorFn: (row) => row.containers.length,
header: sortableHeader("Containers"),
cell: ({ row }) => (
<Badge variant="outline">{row.original.containers.length}</Badge>
),
},
{
accessorKey: "cpuPercent",
header: sortableHeader("CPU"),
cell: ({ row }) => {
const value = row.original.cpuPercent;
return (
<div className="flex flex-col gap-1 min-w-32">
<span className="text-sm">{value.toFixed(1)}%</span>
<Progress value={Math.min(value, 100)} className="h-1.5 w-32" />
</div>
);
},
},
{
accessorKey: "memUsedMb",
header: sortableHeader("Memory"),
cell: ({ row }) => {
const { memUsedMb, memLimitMb } = row.original;
const percentage = memLimitMb > 0 ? (memUsedMb / memLimitMb) * 100 : 0;
return (
<div className="flex flex-col gap-1 min-w-40">
<span className="text-sm">
{formatMb(memUsedMb)}
{memLimitMb > 0 ? ` / ${formatMb(memLimitMb)}` : ""}
</span>
<Progress value={Math.min(percentage, 100)} className="h-1.5 w-32" />
</div>
);
},
},
{
accessorKey: "diskUsedMb",
header: sortableHeader("Storage"),
cell: ({ row }) => (
<span className="text-sm">{formatMb(row.original.diskUsedMb)}</span>
),
},
];

View File

@ -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<SortingState>([
{ 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 (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Cpu className="size-6 text-muted-foreground self-center" />
Resource Usage
</CardTitle>
<CardDescription>
CPU, memory, and storage usage per project, application, and
container
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2 rounded-lg border px-4 py-2">
<Cpu className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Total CPU:
</span>
<span className="font-medium">{totals.cpu.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2 rounded-lg border px-4 py-2">
<MemoryStick className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Total Memory:
</span>
<span className="font-medium">{formatMb(totals.mem)}</span>
</div>
<div className="flex items-center gap-2 rounded-lg border px-4 py-2">
<HardDrive className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Total Storage:
</span>
<span className="font-medium">{formatMb(totals.disk)}</span>
</div>
<div className="flex items-center gap-2 rounded-lg border px-4 py-2">
<span className="text-sm text-muted-foreground">
Containers:
</span>
<span className="font-medium">{totals.containers}</span>
</div>
</div>
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by project, environment, or service..."
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
className="md:max-w-sm"
/>
<Button
variant="outline"
size="icon"
className="shrink-0 sm:ml-auto"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
/>
<span className="sr-only">Refresh</span>
</Button>
</div>
<div className="rounded-md border">
{isPending ? (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[40vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
</div>
) : data?.length === 0 ? (
<div className="flex-col gap-2 flex items-center justify-center h-[40vh]">
<span className="text-muted-foreground text-lg font-medium">
No services found on this server.
</span>
</div>
) : (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -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 (
<div className="space-y-4 pb-10">
{/* <AlertBlock>
You are watching the <strong>Free</strong> plan.{" "}
<a
href="https://dokploy.com#pricing"
target="_blank"
className="underline"
rel="noreferrer"
>
Upgrade
</a>{" "}
to get more features.
</AlertBlock> */}
{isPending ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
<div className="rounded-xl bg-background flex shadow-md px-4 w-full min-h-[50vh] justify-center items-center text-muted-foreground">
@ -43,41 +34,43 @@ const Dashboard = () => {
</div>
</Card>
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">Change Monitoring</Label>
<Switch
checked={toggleMonitoring}
onCheckedChange={setToggleMonitoring}
/>
</div>
)} */}
{toggleMonitoring ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<ShowPaidMonitoring
BASE_URL={
process.env.NODE_ENV === "production"
? `http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}/metrics`
: BASE_URL
}
token={
process.env.NODE_ENV === "production"
? monitoring?.metricsConfig?.server?.token
: DEFAULT_TOKEN
}
/>
</div>
</Card>
) : (
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md p-6">
<ContainerFreeMonitoring appName="dokploy" />
</div>
</Card>
)}
</>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="resource-usage">Resource Usage</TabsTrigger>
</TabsList>
<TabsContent value="overview">
{toggleMonitoring ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<ShowPaidMonitoring
BASE_URL={
process.env.NODE_ENV === "production"
? `http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}/metrics`
: BASE_URL
}
token={
process.env.NODE_ENV === "production"
? monitoring?.metricsConfig?.server?.token
: DEFAULT_TOKEN
}
/>
</div>
</Card>
) : (
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md p-6">
<ContainerFreeMonitoring appName="dokploy" />
</div>
</Card>
)}
</TabsContent>
<TabsContent value="resource-usage">
<ServerFilter>
{(serverId) => <ShowResourceUsage serverId={serverId} />}
</ServerFilter>
</TabsContent>
</Tabs>
)}
</div>
);

View File

@ -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({

View File

@ -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";

View File

@ -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<string, string>;
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<string, string> => {
const labels: Record<string, string> = {};
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<ContainerWithLabels[]> => {
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 (

View File

@ -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<string, Record<string, string>>,
): 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<ServiceUsage[]> => {
const [containers, stats] = await Promise.all([
getAllContainersWithLabels(serverId),
getAllContainerStats(serverId),
]);
const statsByName = new Map<string, Record<string, string>>(
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)),
};
});
};