Merge pull request #3633 from physikal/claude/swarm-container-breakdown-VJhK7

feat(swarm): add container breakdown by node with live metrics
This commit is contained in:
Mauricio Siu
2026-04-04 21:26:53 -06:00
committed by GitHub
11 changed files with 1122 additions and 7 deletions

View File

@@ -1,6 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "../../swarm/monitoring-card"; import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props { interface Props {
@@ -21,9 +24,24 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-7xl "> <DialogContent className="sm:max-w-7xl ">
<div className="grid w-full gap-1"> <Tabs defaultValue="overview">
<SwarmMonitorCard serverId={serverId} /> <TabsList>
</div> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,98 @@
import { AlertCircle, HardDrive, Network } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { TableCell, TableRow } from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ContainerInfo, ContainerStat } from "./types";
import { formatCpu, formatIOValue, formatMemUsage } from "./utils";
interface ContainerRowProps {
container: ContainerInfo;
stat: ContainerStat | undefined;
}
export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
const isRunning = container.CurrentState.startsWith("Running");
const hasError = container.Error && container.Error.trim() !== "";
const stateBadge = (
<Badge
variant={hasError ? "destructive" : isRunning ? "default" : "destructive"}
>
{container.CurrentState}
</Badge>
);
return (
<TableRow>
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{container.Name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[230px]">
{container.Image}
</span>
</div>
</TableCell>
<TableCell>
{hasError ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 cursor-help">
{stateBadge}
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs font-medium">Error:</p>
<p className="text-xs">{container.Error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
stateBadge
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<span className="text-sm font-medium">{formatCpu(stat.CPUPerc)}</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<span className="text-sm font-medium">
{formatMemUsage(stat.MemUsage)}
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<div className="flex items-center justify-end gap-1.5">
<HardDrive className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{formatIOValue(stat.BlockIO)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
<TableCell className="text-right">
{stat ? (
<div className="flex items-center justify-end gap-1.5">
<Network className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{formatIOValue(stat.NetIO)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</TableCell>
</TableRow>
);
};

View File

@@ -0,0 +1,277 @@
import {
AlertCircle,
AlertTriangle,
ExternalLink,
Info,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import type { ContainerInfo } from "./types";
export const DocLinks = () => (
<div className="flex flex-col gap-1 pt-2 border-t mt-2">
<p className="text-xs font-medium text-muted-foreground">
Helpful resources:
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<a
href="https://docs.dokploy.com/docs/core"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Dokploy Documentation
<ExternalLink className="h-3 w-3" />
</a>
<a
href="https://docs.docker.com/engine/swarm/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Docker Swarm Guide
<ExternalLink className="h-3 w-3" />
</a>
<Link
href="/dashboard/settings/cluster"
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
>
Cluster Settings
</Link>
</div>
</div>
);
interface SwarmNotAvailableProps {
errorMessage?: string;
onRetry: () => void;
}
export const SwarmNotAvailable = ({
errorMessage,
onRetry,
}: SwarmNotAvailableProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Swarm Not Available</AlertTitle>
<AlertDescription>
Could not reach Docker Swarm.{" "}
{errorMessage && (
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
)}
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This feature requires Docker Swarm to be initialized and active. To get
started:
</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>
Initialize Swarm on your server:{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
docker swarm init
</code>
</li>
<li>
Verify it&apos;s active:{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
docker info | grep Swarm
</code>
</li>
<li>
Check the{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>{" "}
page to manage your swarm nodes
</li>
</ol>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
interface ServicesErrorProps {
errorMessage?: string;
onRetry: () => void;
}
export const ServicesError = ({
errorMessage,
onRetry,
}: ServicesErrorProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Failed to Load Services</AlertTitle>
<AlertDescription>
Swarm is reachable but service listing failed.{" "}
{errorMessage && (
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
)}
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>This could be caused by:</p>
<ul className="list-disc list-inside space-y-1 ml-1">
<li>Permission issues running Docker commands on the server</li>
<li>Docker daemon not responding</li>
<li>
Network connectivity issues to a remote server &mdash; check{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>
</li>
</ul>
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
interface NoServicesProps {
nodeCount: number;
onRefresh: () => void;
}
export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>No Swarm Services Found</AlertTitle>
<AlertDescription>
Docker Swarm is active with <strong>{nodeCount} node(s)</strong>, but
there are no application services running in the swarm.
</AlertDescription>
</Alert>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This view shows containers deployed as <strong>Swarm services</strong>.
Standalone or Docker Compose containers won&apos;t appear here.
</p>
<p>To see containers in this view, make sure your applications are:</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>
<strong>Deployed as Swarm services</strong> &mdash; Applications in
Dokploy deploy to Swarm by default. Docker Compose projects need to
use{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">Stack</code>{" "}
type (not{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
Docker Compose
</code>
) to run as Swarm services.
</li>
<li>
<strong>Using a registry</strong> (for multi-node setups) &mdash;
Worker nodes need to pull images from a shared registry. Configure one
in{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
Cluster Settings
</Link>
.
</li>
<li>
<strong>Successfully built and deployed</strong> &mdash; Check your
project&apos;s deployment logs for errors.
</li>
</ol>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
);
interface NoRunningContainersProps {
serviceCount: number;
containers: ContainerInfo[];
onRefresh: () => void;
}
export const NoRunningContainers = ({
serviceCount,
containers,
onRefresh,
}: NoRunningContainersProps) => {
const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== "");
return (
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>No Running Containers</AlertTitle>
<AlertDescription>
Found <strong>{serviceCount} service(s)</strong> in the swarm, but
none have running containers.
</AlertDescription>
</Alert>
{hasErrors && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Container Errors Detected</AlertTitle>
<AlertDescription>
<ul className="list-disc list-inside space-y-1 mt-1">
{containers
.filter((c) => c.Error && c.Error.trim() !== "")
.slice(0, 5)
.map((c) => (
<li key={c.ID} className="text-xs">
<strong>{c.Name}</strong>: {c.Error}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="space-y-3 text-sm text-muted-foreground">
<p>This can happen when:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li>Services are scaled to 0 replicas</li>
<li>
Containers are failing to start &mdash; check deployment logs for
errors
</li>
<li>
Images can&apos;t be pulled on worker nodes &mdash; verify your{" "}
<Link
href="/dashboard/settings/cluster"
className="text-primary underline underline-offset-4"
>
registry configuration
</Link>
</li>
<li>
Node constraints prevent scheduling &mdash; check placement rules in
your app&apos;s Cluster settings
</li>
</ul>
<DocLinks />
</div>
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
);
};

View File

@@ -0,0 +1,128 @@
import { ChevronDown, ChevronRight, Server } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContainerRow } from "./container-row";
import type { ContainerStat, NodeGroup } from "./types";
interface NodeSectionProps {
group: NodeGroup;
isExpanded: boolean;
onToggleNode: (nodeName: string) => void;
findStatsForContainer: (taskName: string) => ContainerStat | undefined;
}
export const NodeSection = ({
group,
isExpanded,
onToggleNode,
findStatsForContainer,
}: NodeSectionProps) => {
const runningCount = group.containers.filter((c) =>
c.CurrentState.startsWith("Running"),
).length;
const nodeDown =
group.nodeStatus &&
(group.nodeStatus.Status !== "Ready" ||
group.nodeStatus.Availability !== "Active");
return (
<Collapsible
open={isExpanded}
onOpenChange={() => onToggleNode(group.nodeName)}
>
<Card className="bg-background">
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors rounded-t-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div className="relative">
<Server className="h-5 w-5 text-muted-foreground" />
{nodeDown && (
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</div>
<CardTitle className="text-base">{group.nodeName}</CardTitle>
{group.nodeStatus && (
<Badge
variant={
group.nodeStatus.ManagerStatus === "Leader"
? "default"
: group.nodeStatus.ManagerStatus === "Reachable"
? "secondary"
: "outline"
}
className="text-[10px]"
>
{group.nodeStatus.ManagerStatus || "Worker"}
</Badge>
)}
<Badge variant="secondary">
{group.containers.length} container
{group.containers.length !== 1 ? "s" : ""}
</Badge>
{nodeDown ? (
<Badge variant="destructive">
{group.nodeStatus?.Status} /{" "}
{group.nodeStatus?.Availability}
</Badge>
) : runningCount === group.containers.length ? (
<Badge variant="default">All Running</Badge>
) : (
<Badge variant="orange">
{runningCount}/{group.containers.length} Running
</Badge>
)}
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Container</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">CPU</TableHead>
<TableHead className="text-right">Memory</TableHead>
<TableHead className="text-right">Block I/O</TableHead>
<TableHead className="text-right">Network I/O</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.containers.map((container) => {
const stat = findStatsForContainer(container.Name);
return (
<ContainerRow
key={container.ID}
container={container}
stat={stat}
/>
);
})}
</TableBody>
</Table>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
};

View File

@@ -0,0 +1,371 @@
import {
AlertTriangle,
Container,
Info,
Loader2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api";
import {
NoRunningContainers,
NoServices,
ServicesError,
SwarmNotAvailable,
} from "./empty-states";
import { NodeSection } from "./node-section";
import { SummaryCards } from "./summary-cards";
import type { ContainerInfo, ContainerStat, SwarmNode } from "./types";
interface Props {
serverId?: string;
}
export const ShowSwarmContainers = ({ serverId }: Props) => {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const {
data: nodes,
isLoading: nodesLoading,
isError: nodesError,
error: nodesErrorDetail,
refetch: refetchNodes,
} = api.swarm.getNodes.useQuery({ serverId });
const {
data: nodeApps,
isLoading: appsLoading,
isError: appsError,
error: appsErrorDetail,
refetch: refetchApps,
} = api.swarm.getNodeApps.useQuery(
{ serverId },
{ enabled: !nodesError && nodes !== undefined },
);
const applicationList =
nodeApps && nodeApps.length > 0
? nodeApps.map((app: { Name: string }) => app.Name)
: [];
const {
data: appDetails,
isLoading: detailsLoading,
refetch: refetchDetails,
} = api.swarm.getAppInfos.useQuery(
{ appName: applicationList, serverId },
{ enabled: applicationList.length > 0 },
);
const { data: stats, isLoading: statsLoading } =
api.swarm.getContainerStats.useQuery(
{ serverId },
{
refetchInterval: 5000,
enabled: applicationList.length > 0 && !nodesError && !appsError,
},
);
const isLoading =
nodesLoading ||
appsLoading ||
(applicationList.length > 0 && detailsLoading);
// Build container list
const containers: ContainerInfo[] = [];
if (nodeApps && appDetails) {
for (const app of nodeApps) {
const details =
appDetails?.filter((detail: { Name: string }) =>
detail.Name.startsWith(`${app.Name}.`),
) || [];
if (details.length === 0) {
containers.push({
...app,
CurrentState: "N/A",
DesiredState: "N/A",
Error: "",
Node: "N/A",
ID: app.ID,
});
} else {
for (const detail of details) {
containers.push({
Name: detail.Name,
Image: detail.Image || app.Image,
CurrentState: detail.CurrentState,
DesiredState: detail.DesiredState,
Error: detail.Error,
Node: detail.Node,
Ports: detail.Ports || app.Ports,
ID: detail.ID,
});
}
}
}
}
const runningContainers = containers.filter(
(c) =>
c.Node !== "N/A" &&
(c.DesiredState === "Running" || c.CurrentState.startsWith("Running")),
);
const unscheduledServices = containers.filter((c) => c.Node === "N/A");
const downNodes = (nodes ?? []).filter(
(n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active",
);
const isMultiNode = (nodes?.length ?? 0) > 1;
const nodeStatusMap = new Map<string, SwarmNode>();
if (nodes) {
for (const node of nodes) {
nodeStatusMap.set(node.Hostname, node);
}
}
const statsMap = new Map<string, ContainerStat>();
if (stats) {
for (const stat of stats) {
statsMap.set(stat.Name, stat);
}
}
const findStatsForContainer = (
taskName: string,
): ContainerStat | undefined => {
for (const [containerName, stat] of statsMap) {
if (containerName.startsWith(`${taskName}.`)) {
return stat;
}
}
return undefined;
};
useEffect(() => {
if (runningContainers.length > 0 && expandedNodes.size === 0) {
const nodeNames = new Set<string>();
for (const c of runningContainers) {
if (c.Node) {
nodeNames.add(c.Node);
}
}
setExpandedNodes(nodeNames);
}
}, [runningContainers.length]);
const toggleNode = (nodeName: string) => {
setExpandedNodes((prev: Set<string>) => {
const next = new Set(prev);
if (next.has(nodeName)) {
next.delete(nodeName);
} else {
next.add(nodeName);
}
return next;
});
};
const handleRefresh = () => {
refetchApps();
refetchDetails();
};
// Build node groups
const nodeMap = new Map<string, ContainerInfo[]>();
for (const c of runningContainers) {
const nodeName = c.Node || "Unknown";
if (!nodeMap.has(nodeName)) {
nodeMap.set(nodeName, []);
}
nodeMap.get(nodeName)!.push(c);
}
const nodeGroups = [];
for (const [nodeName, nodeContainers] of nodeMap) {
nodeGroups.push({
nodeName,
containers: nodeContainers,
nodeStatus: nodeStatusMap.get(nodeName),
});
}
nodeGroups.sort((a, b) => a.nodeName.localeCompare(b.nodeName));
if (isLoading) {
return (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[40vh]">
<span>Loading containers...</span>
<Loader2 className="animate-spin size-4" />
</div>
);
}
if (nodesError) {
return (
<SwarmNotAvailable
errorMessage={nodesErrorDetail?.message}
onRetry={() => refetchNodes()}
/>
);
}
if (!nodesError && nodes === undefined) {
return (
<SwarmNotAvailable
errorMessage="Docker Swarm may not be initialized — docker node ls returned no data."
onRetry={() => refetchNodes()}
/>
);
}
const isRealAppsError =
appsError && !appsErrorDetail?.message?.includes("data is undefined");
if (isRealAppsError) {
return (
<ServicesError
errorMessage={appsErrorDetail?.message}
onRetry={() => refetchApps()}
/>
);
}
if (!nodeApps || nodeApps.length === 0) {
return (
<NoServices
nodeCount={nodes?.length ?? 0}
onRefresh={() => refetchApps()}
/>
);
}
if (runningContainers.length === 0) {
return (
<NoRunningContainers
serviceCount={nodeApps.length}
containers={containers}
onRefresh={handleRefresh}
/>
);
}
return (
<div className="flex flex-col gap-4">
<header className="flex items-center flex-wrap gap-4 justify-between">
<div className="space-y-1">
<CardTitle className="text-xl flex flex-row gap-2">
<Container className="size-6 text-muted-foreground self-center" />
Container Breakdown by Node
</CardTitle>
<p className="text-sm text-muted-foreground">
Showing containers across {nodes?.length ?? 0} swarm node(s)
{statsLoading ? "" : " (metrics refresh every 5s)"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</header>
<SummaryCards
nodeCount={nodes?.length ?? 0}
downNodeCount={downNodes.length}
serviceCount={nodeApps?.length ?? 0}
unscheduledCount={unscheduledServices.length}
runningContainerCount={runningContainers.length}
/>
{downNodes.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{downNodes.length} Node(s) Unavailable</AlertTitle>
<AlertDescription>
<p className="mb-2">
The following nodes are not ready or have been drained. Containers
scheduled on these nodes may not be running.
</p>
<ul className="list-disc list-inside space-y-1 text-xs">
{downNodes.map((node: SwarmNode) => (
<li key={node.ID}>
<strong>{node.Hostname}</strong> &mdash; Status: {node.Status}
, Availability: {node.Availability}
{node.ManagerStatus && ` (${node.ManagerStatus})`}
</li>
))}
</ul>
<p className="mt-2 text-xs">
Manage nodes in{" "}
<Link
href="/dashboard/settings/cluster"
className="underline underline-offset-4"
>
Cluster Settings
</Link>
</p>
</AlertDescription>
</Alert>
)}
{isMultiNode && (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Multi-Node Metrics Note</AlertTitle>
<AlertDescription>
CPU, memory, and I/O metrics are collected from the manager node via{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
docker stats
</code>
. Containers running on worker nodes will show &ldquo;--&rdquo; for
metrics.
</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4">
{nodeGroups.map((group) => (
<NodeSection
key={group.nodeName}
group={group}
isExpanded={expandedNodes.has(group.nodeName)}
onToggleNode={toggleNode}
findStatsForContainer={findStatsForContainer}
/>
))}
</div>
{unscheduledServices.length > 0 && (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>
{unscheduledServices.length} Service(s) With No Running Tasks
</AlertTitle>
<AlertDescription>
<p className="mb-2">
These services exist in the swarm but have no running containers.
They may be scaled to 0 replicas or failing to start.
</p>
<ul className="list-disc list-inside space-y-1 text-xs">
{unscheduledServices.map((svc) => (
<li key={svc.ID}>
<strong>{svc.Name}</strong>
{svc.Error && svc.Error.trim() !== "" && (
<span className="text-destructive ml-1">
&mdash; {svc.Error}
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { Container, Cpu, Server } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface SummaryCardsProps {
nodeCount: number;
downNodeCount: number;
serviceCount: number;
unscheduledCount: number;
runningContainerCount: number;
}
export const SummaryCards = ({
nodeCount,
downNodeCount,
serviceCount,
unscheduledCount,
runningContainerCount,
}: SummaryCardsProps) => (
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Swarm Nodes</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{nodeCount}</div>
{downNodeCount > 0 && (
<p className="text-xs text-destructive mt-1">
{downNodeCount} node(s) down or drained
</p>
)}
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Services</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Cpu className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{serviceCount}</div>
{unscheduledCount > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{unscheduledCount} with no running tasks
</p>
)}
</CardContent>
</Card>
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Running Containers
</CardTitle>
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
<Container className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runningContainerCount}</div>
</CardContent>
</Card>
</div>
);

View File

@@ -0,0 +1,35 @@
export interface ContainerStat {
BlockIO: string;
CPUPerc: string;
Container: string;
ID: string;
MemPerc: string;
MemUsage: string;
Name: string;
NetIO: string;
}
export interface ContainerInfo {
Name: string;
Image: string;
Node: string;
CurrentState: string;
DesiredState: string;
Ports: string;
Error: string;
ID: string;
}
export interface SwarmNode {
ID: string;
Hostname: string;
Status: string;
Availability: string;
ManagerStatus: string;
}
export interface NodeGroup {
nodeName: string;
containers: ContainerInfo[];
nodeStatus?: SwarmNode;
}

View File

@@ -0,0 +1,31 @@
/** Round a value+unit string like "2.711MiB" → "2.7 MiB" */
export const formatSizeValue = (raw: string): string => {
const match = raw.match(/^([\d.]+)\s*([A-Za-z]+)$/);
if (!match?.[1] || !match[2]) return raw;
const num = Number.parseFloat(match[1]);
const unit = match[2];
if (Number.isNaN(num)) return raw;
const rounded = num >= 1 ? num.toFixed(1) : num.toFixed(2);
return `${rounded} ${unit}`;
};
/** Format "2.711MiB / 7.609GiB" → "2.7 MiB / 7.6 GiB" */
export const formatMemUsage = (raw: string): string => {
const [left, right] = raw.split("/").map((s) => s.trim());
if (!left || !right) return raw;
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
};
/** Format "978B / 252B" → "978 B / 252 B" */
export const formatIOValue = (raw: string): string => {
const [left, right] = raw.split("/").map((s) => s.trim());
if (!left || !right) return raw;
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
};
/** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */
export const formatCpu = (raw: string): string => {
const num = Number.parseFloat(raw.replace("%", ""));
if (Number.isNaN(num)) return raw;
return `${num.toFixed(1)}%`;
};

View File

@@ -5,11 +5,33 @@ import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card"; import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
const Dashboard = () => { const Dashboard = () => {
return <SwarmMonitorCard />; return (
<div className="space-y-4">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<SwarmMonitorCard />
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
);
}; };
export default Dashboard; export default Dashboard;

View File

@@ -1,9 +1,12 @@
import { import {
findServerById,
getAllContainerStats,
getApplicationInfo, getApplicationInfo,
getNodeApplications, getNodeApplications,
getNodeInfo, getNodeInfo,
getSwarmNodes, getSwarmNodes,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, withPermission } from "../trpc"; import { createTRPCRouter, withPermission } from "../trpc";
import { containerIdRegex } from "./docker"; import { containerIdRegex } from "./docker";
@@ -54,4 +57,19 @@ export const swarmRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getApplicationInfo(input.appName, input.serverId); return await getApplicationInfo(input.appName, input.serverId);
}), }),
getContainerStats: withPermission("server", "read")
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getAllContainerStats(input.serverId);
}),
}); });

View File

@@ -412,7 +412,9 @@ export const getSwarmNodes = async (serverId?: string) => {
.split("\n") .split("\n")
.map((line) => JSON.parse(line)); .map((line) => JSON.parse(line));
return nodesArray; return nodesArray;
} catch {} } catch (error) {
console.error("getSwarmNodes error:", error);
}
}; };
export const getNodeInfo = async (nodeId: string, serverId?: string) => { export const getNodeInfo = async (nodeId: string, serverId?: string) => {
@@ -463,6 +465,10 @@ export const getNodeApplications = async (serverId?: string) => {
return; return;
} }
if (!stdout.trim()) {
return [];
}
const appArray = stdout const appArray = stdout
.trim() .trim()
.split("\n") .split("\n")
@@ -470,13 +476,19 @@ export const getNodeApplications = async (serverId?: string) => {
.filter((service) => !service.Name.startsWith("dokploy-")); .filter((service) => !service.Name.startsWith("dokploy-"));
return appArray; return appArray;
} catch {} } catch (error) {
console.error("getNodeApplications error:", error);
return [];
}
}; };
export const getApplicationInfo = async ( export const getApplicationInfo = async (
appNames: string[], appNames: string[],
serverId?: string, serverId?: string,
) => { ) => {
if (appNames.length === 0) {
return [];
}
try { try {
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
@@ -497,13 +509,50 @@ export const getApplicationInfo = async (
return; return;
} }
if (!stdout.trim()) {
return [];
}
const appArray = stdout const appArray = stdout
.trim() .trim()
.split("\n") .split("\n")
.map((line) => JSON.parse(line)); .map((line) => JSON.parse(line));
return appArray; return appArray;
} catch {} } catch (error) {
console.error("getApplicationInfo error:", error);
return [];
}
};
export const getAllContainerStats = async (serverId?: string) => {
try {
let stdout = "";
const command =
'docker stats --no-stream --format \'{"BlockIO":"{{.BlockIO}}","CPUPerc":"{{.CPUPerc}}","Container":"{{.Container}}","ID":"{{.ID}}","MemPerc":"{{.MemPerc}}","MemUsage":"{{.MemUsage}}","Name":"{{.Name}}","NetIO":"{{.NetIO}}"}\'';
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout.trim()) {
return [];
}
const stats = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return stats;
} catch (error) {
console.error("getAllContainerStats error:", error);
return [];
}
}; };
export const uploadFileToContainer = async ( export const uploadFileToContainer = async (