Skip to content

Commit c4c5051

Browse files
committed
feat(project): add service resource monitoring tab
1 parent dcb638d commit c4c5051

9 files changed

Lines changed: 353 additions & 89 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import {
2+
Activity,
3+
Cpu,
4+
HardDrive,
5+
type LucideIcon,
6+
MemoryStick,
7+
Network,
8+
} from "lucide-react";
9+
import {
10+
Card,
11+
CardContent,
12+
CardDescription,
13+
CardHeader,
14+
CardTitle,
15+
} from "@/components/ui/card";
16+
import { Progress } from "@/components/ui/progress";
17+
import { cn } from "@/lib/utils";
18+
import { api, type RouterOutputs } from "@/utils/api";
19+
20+
type RouteParam = string | string[] | undefined;
21+
type ResourceMetricsSummary =
22+
RouterOutputs["project"]["resourceMetrics"]["services"][string];
23+
type ResourceMetricSnapshot = NonNullable<ResourceMetricsSummary["current"]>;
24+
25+
interface Props {
26+
projectId: RouteParam;
27+
environmentId: RouteParam;
28+
serviceId?: string;
29+
className?: string;
30+
}
31+
32+
const firstRouteParam = (value: RouteParam) =>
33+
Array.isArray(value) ? value[0] : value;
34+
35+
const formatBytes = (bytes?: number) => {
36+
if (!bytes || bytes <= 0) return "0B";
37+
38+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
39+
let value = bytes;
40+
let unitIndex = 0;
41+
42+
while (value >= 1024 && unitIndex < units.length - 1) {
43+
value /= 1024;
44+
unitIndex++;
45+
}
46+
47+
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)}${units[unitIndex]}`;
48+
};
49+
50+
const formatPercent = (value?: number) => {
51+
if (!value || value <= 0) return "0%";
52+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)}%`;
53+
};
54+
55+
const boundedPercent = (value: number) =>
56+
Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
57+
58+
const Sparkline = ({
59+
values,
60+
className,
61+
}: {
62+
values: number[];
63+
className?: string;
64+
}) => {
65+
if (values.length < 2) {
66+
return <div className={cn("h-12 rounded-md bg-muted/40", className)} />;
67+
}
68+
69+
const width = 160;
70+
const height = 48;
71+
const max = Math.max(...values, 1);
72+
const min = Math.min(...values, 0);
73+
const range = Math.max(max - min, 1);
74+
const points = values
75+
.map((value, index) => {
76+
const x = (index / (values.length - 1)) * width;
77+
const y = height - ((value - min) / range) * height;
78+
return `${x.toFixed(2)},${y.toFixed(2)}`;
79+
})
80+
.join(" ");
81+
82+
return (
83+
<svg
84+
viewBox={`0 0 ${width} ${height}`}
85+
className={cn("h-12 w-full overflow-visible", className)}
86+
aria-hidden="true"
87+
>
88+
<polyline
89+
points={points}
90+
fill="none"
91+
stroke="currentColor"
92+
strokeWidth="2"
93+
strokeLinecap="round"
94+
strokeLinejoin="round"
95+
vectorEffect="non-scaling-stroke"
96+
/>
97+
</svg>
98+
);
99+
};
100+
101+
const MetricCard = ({
102+
icon: Icon,
103+
label,
104+
value,
105+
description,
106+
progress,
107+
values,
108+
}: {
109+
icon: LucideIcon;
110+
label: string;
111+
value: string;
112+
description: string;
113+
progress?: number;
114+
values: number[];
115+
}) => (
116+
<Card className="bg-background">
117+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
118+
<CardTitle className="font-medium text-sm">{label}</CardTitle>
119+
<Icon className="size-4 text-muted-foreground" />
120+
</CardHeader>
121+
<CardContent className="space-y-3">
122+
<div className="font-mono text-2xl">{value}</div>
123+
{typeof progress === "number" && (
124+
<Progress value={boundedPercent(progress)} className="h-2" />
125+
)}
126+
<Sparkline values={values} className="text-primary" />
127+
<p className="text-muted-foreground text-xs">{description}</p>
128+
</CardContent>
129+
</Card>
130+
);
131+
132+
const getHistoryValues = (
133+
history: ResourceMetricSnapshot[],
134+
selector: (snapshot: ResourceMetricSnapshot) => number,
135+
) => history.slice(-60).map(selector);
136+
137+
export const ServiceMonitoringPanel = ({
138+
projectId,
139+
environmentId,
140+
serviceId,
141+
className,
142+
}: Props) => {
143+
const resolvedProjectId = firstRouteParam(projectId);
144+
const resolvedEnvironmentId = firstRouteParam(environmentId);
145+
const { data: isCloud } = api.settings.isCloud.useQuery();
146+
const { data, isLoading } = api.project.resourceMetrics.useQuery(
147+
{
148+
projectIds: resolvedProjectId ? [resolvedProjectId] : [],
149+
environmentId: resolvedEnvironmentId,
150+
},
151+
{
152+
enabled:
153+
!!resolvedProjectId &&
154+
!!resolvedEnvironmentId &&
155+
!!serviceId &&
156+
isCloud === false,
157+
refetchInterval: 30_000,
158+
},
159+
);
160+
161+
if (isCloud !== false || !serviceId) {
162+
return null;
163+
}
164+
165+
const metrics = data?.services[serviceId];
166+
const current = metrics?.current;
167+
const history = metrics?.history ?? [];
168+
const memoryPercent =
169+
current?.memoryLimitBytes && current.memoryLimitBytes > 0
170+
? (current.memoryBytes / current.memoryLimitBytes) * 100
171+
: 0;
172+
173+
return (
174+
<Card className={cn("bg-background", className)}>
175+
<CardHeader>
176+
<CardTitle className="flex items-center gap-2 text-xl">
177+
<Activity className="size-5 text-muted-foreground" />
178+
Resource Monitoring
179+
</CardTitle>
180+
<CardDescription>
181+
CPU, memory, Docker block I/O, and network usage for this service.
182+
</CardDescription>
183+
</CardHeader>
184+
<CardContent className="space-y-4">
185+
{!current && isLoading ? (
186+
<div className="rounded-lg border border-dashed p-6 text-muted-foreground text-sm">
187+
Loading resource metrics...
188+
</div>
189+
) : current ? (
190+
<>
191+
<div className="flex flex-wrap gap-2 text-muted-foreground text-xs">
192+
<span>{current.containers} running container(s)</span>
193+
<span>
194+
Last sample:{" "}
195+
{current.time
196+
? new Date(current.time).toLocaleTimeString()
197+
: "never"}
198+
</span>
199+
<span>{history.length} retained samples</span>
200+
</div>
201+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
202+
<MetricCard
203+
icon={Cpu}
204+
label="CPU"
205+
value={formatPercent(current.cpuPercent)}
206+
description="Current CPU across running containers"
207+
progress={current.cpuPercent}
208+
values={getHistoryValues(
209+
history,
210+
(snapshot) => snapshot.cpuPercent,
211+
)}
212+
/>
213+
<MetricCard
214+
icon={MemoryStick}
215+
label="Memory"
216+
value={formatBytes(current.memoryBytes)}
217+
description={
218+
current.memoryLimitBytes
219+
? `${formatPercent(memoryPercent)} of ${formatBytes(current.memoryLimitBytes)} limit`
220+
: "Current memory usage"
221+
}
222+
progress={memoryPercent}
223+
values={getHistoryValues(
224+
history,
225+
(snapshot) => snapshot.memoryBytes,
226+
)}
227+
/>
228+
<MetricCard
229+
icon={HardDrive}
230+
label="Disk I/O"
231+
value={`${formatBytes(current.blockReadBytes)} / ${formatBytes(current.blockWriteBytes)}`}
232+
description="Read / write from Docker block I/O stats"
233+
values={getHistoryValues(
234+
history,
235+
(snapshot) =>
236+
snapshot.blockReadBytes + snapshot.blockWriteBytes,
237+
)}
238+
/>
239+
<MetricCard
240+
icon={Network}
241+
label="Network"
242+
value={`${formatBytes(current.networkRxBytes)} / ${formatBytes(current.networkTxBytes)}`}
243+
description="Input / output from Docker network stats"
244+
values={getHistoryValues(
245+
history,
246+
(snapshot) =>
247+
snapshot.networkRxBytes + snapshot.networkTxBytes,
248+
)}
249+
/>
250+
</div>
251+
</>
252+
) : (
253+
<div className="rounded-lg border border-dashed p-6 text-muted-foreground text-sm">
254+
No resource metrics have been collected yet.
255+
</div>
256+
)}
257+
</CardContent>
258+
</Card>
259+
);
260+
};

apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac
3535
import { DeleteService } from "@/components/dashboard/compose/delete-service";
3636
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
3737
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
38+
import { ServiceMonitoringPanel } from "@/components/dashboard/resource-metrics/service-monitoring-panel";
3839
import { ServiceResourceUsage } from "@/components/dashboard/resource-metrics/service-usage";
3940
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
4041
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -273,12 +274,9 @@ const Service = (
273274
{data?.sourceType !== "docker" && (
274275
<TabsTrigger value="patches">Patches</TabsTrigger>
275276
)}
276-
{permissions?.monitoring.read &&
277-
((data?.serverId && isCloud) || !data?.server) && (
278-
<TabsTrigger value="monitoring">
279-
Monitoring
280-
</TabsTrigger>
281-
)}
277+
{permissions?.monitoring.read && (
278+
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
279+
)}
282280
{permissions?.service.create && (
283281
<TabsTrigger value="advanced">Advanced</TabsTrigger>
284282
)}
@@ -302,6 +300,11 @@ const Service = (
302300
<TabsContent value="monitoring">
303301
<div className="pt-2.5">
304302
<div className="flex flex-col gap-4 border rounded-lg p-6">
303+
<ServiceMonitoringPanel
304+
projectId={projectId}
305+
environmentId={environmentId}
306+
serviceId={applicationId}
307+
/>
305308
{data?.serverId && isCloud ? (
306309
<ContainerPaidMonitoring
307310
appName={data?.appName || ""}
@@ -310,7 +313,7 @@ const Service = (
310313
data?.server?.metricsConfig?.server?.token || ""
311314
}
312315
/>
313-
) : (
316+
) : !data?.serverId ? (
314317
<>
315318
{/* {monitoring?.enabledFeatures &&
316319
isCloud &&
@@ -342,7 +345,7 @@ const Service = (
342345
</div>
343346
{/* )} */}
344347
</>
345-
)}
348+
) : null}
346349
</div>
347350
</div>
348351
</TabsContent>

apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
3131
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
3232
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
3333
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
34+
import { ServiceMonitoringPanel } from "@/components/dashboard/resource-metrics/service-monitoring-panel";
3435
import { ServiceResourceUsage } from "@/components/dashboard/resource-metrics/service-usage";
3536
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
3637
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
@@ -262,12 +263,9 @@ const Service = (
262263
{data?.sourceType !== "raw" && (
263264
<TabsTrigger value="patches">Patches</TabsTrigger>
264265
)}
265-
{permissions?.monitoring.read &&
266-
((data?.serverId && isCloud) || !data?.server) && (
267-
<TabsTrigger value="monitoring">
268-
Monitoring
269-
</TabsTrigger>
270-
)}
266+
{permissions?.monitoring.read && (
267+
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
268+
)}
271269
{permissions?.service.create && (
272270
<TabsTrigger value="advanced">Advanced</TabsTrigger>
273271
)}
@@ -328,6 +326,12 @@ const Service = (
328326
<TabsContent value="monitoring">
329327
<div className="pt-2.5">
330328
<div className="flex flex-col border rounded-lg ">
329+
<ServiceMonitoringPanel
330+
projectId={projectId}
331+
environmentId={environmentId}
332+
serviceId={composeId}
333+
className="m-6 mb-0"
334+
/>
331335
{data?.serverId && isCloud ? (
332336
<ComposePaidMonitoring
333337
serverId={data?.serverId || ""}

0 commit comments

Comments
 (0)