mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
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:
@@ -1,6 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -21,9 +24,24 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<div className="grid w-full gap-1">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
</div>
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<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>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'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 — 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'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> — 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) —
|
||||
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> — Check your
|
||||
project'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 — check deployment logs for
|
||||
errors
|
||||
</li>
|
||||
<li>
|
||||
Images can't be pulled on worker nodes — verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Node constraints prevent scheduling — check placement rules in
|
||||
your app'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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> — 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 “--” 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">
|
||||
— {svc.Error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal file
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal 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;
|
||||
}
|
||||
31
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal file
31
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal 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)}%`;
|
||||
};
|
||||
@@ -5,11 +5,33 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
findServerById,
|
||||
getAllContainerStats,
|
||||
getApplicationInfo,
|
||||
getNodeApplications,
|
||||
getNodeInfo,
|
||||
getSwarmNodes,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
import { containerIdRegex } from "./docker";
|
||||
@@ -54,4 +57,19 @@ export const swarmRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -412,7 +412,9 @@ export const getSwarmNodes = async (serverId?: string) => {
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
return nodesArray;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("getSwarmNodes error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
|
||||
@@ -463,6 +465,10 @@ export const getNodeApplications = async (serverId?: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const appArray = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
@@ -470,13 +476,19 @@ export const getNodeApplications = async (serverId?: string) => {
|
||||
.filter((service) => !service.Name.startsWith("dokploy-"));
|
||||
|
||||
return appArray;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("getNodeApplications error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getApplicationInfo = async (
|
||||
appNames: string[],
|
||||
serverId?: string,
|
||||
) => {
|
||||
if (appNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -497,13 +509,50 @@ export const getApplicationInfo = async (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const appArray = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user