mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge 9e9d197ff8 into 853ca33659
This commit is contained in:
commit
969deb2f8f
244
apps/dokploy/__test__/monitoring/resource-usage.test.ts
Normal file
244
apps/dokploy/__test__/monitoring/resource-usage.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 (
|
||||
|
||||
138
packages/server/src/services/resource-usage.ts
Normal file
138
packages/server/src/services/resource-usage.ts
Normal 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)),
|
||||
};
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user