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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user