diff --git a/package-lock.json b/package-lock.json index c15f50c..5f489f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lucide-react": "^0.562.0", "next": "16.1.1", "os-name": "^6.1.0", + "p-limit": "^7.2.0", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^2.15.4", @@ -4517,6 +4518,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz", + "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5348,6 +5364,18 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index a155f03..c2a0072 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "lucide-react": "^0.562.0", "next": "16.1.1", "os-name": "^6.1.0", + "p-limit": "^7.2.0", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^2.15.4", diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index c85bf59..8fa0c73 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -17,6 +17,7 @@ import { useEffect, useState } from "react"; import { getApplications, removeApp } from "@/lib/dockship/application"; import RemoveDialog from "@/components/dialogs/Remove"; import { Application } from "@/lib/types"; +import DeployAppDialog from "@/components/dialogs/DeployApp"; export default function ApplicationsPage() { const [applications, setApplications] = useState([]); @@ -75,9 +76,11 @@ export default function ApplicationsPage() { - + + + remove(app.id)} /> diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index e269250..70c3922 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -82,7 +82,8 @@ export default function DeploymentsPage() { {/* Desktop */}
{deployments.map((dep) => { - const appName = appNameByContainer.get(dep.container) ?? "Unknown app"; + const appName = + appNameByContainer.get(dep.container) ?? "Unknown app"; return (
+
diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx index 541c5ec..1b7688a 100644 --- a/src/components/dialogs/DeployApp.tsx +++ b/src/components/dialogs/DeployApp.tsx @@ -19,12 +19,18 @@ import { Application, Node, NodeLiveData } from "@/lib/types"; import { getApplications } from "@/core/application"; import { deployApp } from "@/lib/dockship/deploy"; -export default function DeployAppDialog({ children }: { children: ReactNode }) { +export default function DeployAppDialog({ + defaultApp, + children, +}: { + defaultApp?: string; + children: ReactNode; +}) { const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); const [apps, setApps] = useState([]); const [name, setName] = useState(""); - const [app, setApp] = useState(""); + const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); useEffect(() => { diff --git a/src/core/deployment.ts b/src/core/deployment.ts index 60641f0..cd7c007 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -5,6 +5,7 @@ import { Application, Deployment } from "@/lib/types"; import { CPUusage } from "@/lib/server/calc"; import path from "path"; import fs from "fs"; +import pLimit from "p-limit"; const docker = new Docker(); @@ -18,46 +19,76 @@ function sanitizeString(str: string): string { export async function getDeployments( apps: Application[] -): Promise<(null | Deployment)[]> { - return await Promise.all( - apps - .map((app) => app.deployments) - .flat() - .map(async (deployId) => { - const container = docker.getContainer(deployId); - const containerInfo = await container.inspect(); +): Promise { + // Limit concurrent Docker calls (adjust if needed) + const limit = pLimit(5); - let cpu = 0; - let memory = 0; - let ports: Set<[number, number]> = new Set(); + const deploymentIds = apps.flatMap((app) => app.deployments); - if (containerInfo.State.Running) { - const stats = await container.stats({ stream: false }); - memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100; - cpu += CPUusage(stats); - } + const results = await Promise.all( + deploymentIds.map((deployId) => + limit(async (): Promise => { + try { + const container = docker.getContainer(deployId); + const containerInfo = await container.inspect(); + + let cpu = 0; + let memory = 0; + + // Use Map to deduplicate ports reliably + const ports = new Map(); + + if (containerInfo.State?.Running) { + const stats = await container.stats({ stream: false }); + + const usage = stats.memory_stats?.usage ?? 0; + const limitMem = stats.memory_stats?.limit ?? 0; + + if (limitMem > 0) { + memory = (usage / limitMem) * 100; + } + + cpu = CPUusage(stats); + } + + const portMappings = containerInfo.NetworkSettings?.Ports ?? {}; + + for (const [key, mappings] of Object.entries(portMappings)) { + if (!mappings) continue; + + const privatePort = Number.parseInt(key.split("/")[0], 10); - for (const [key, mappings] of Object.entries( - containerInfo.NetworkSettings.Ports - )) { - const privatePort = parseInt(key.split("/")[0], 10); - if (mappings) { for (const mapping of mappings) { - const publicPort = parseInt(mapping.HostPort, 10); - ports.add([publicPort, privatePort]); + const publicPort = Number.parseInt(mapping.HostPort, 10); + + if (!Number.isNaN(publicPort) && !Number.isNaN(privatePort)) { + ports.set(publicPort, privatePort); + } } } - } - return { - image: containerInfo.Image, - container: containerInfo.Id, - cpu, - memory, - status: containerInfo.State.Running ? "running" : "stopped", - ports: [...ports], - }; + return { + image: containerInfo.Image, + container: containerInfo.Id, + cpu, + memory, + status: containerInfo.State?.Running ? "running" : "stopped", + ports: [...ports.entries()].map( + ([publicPort, privatePort]) => + [publicPort, privatePort] as [number, number] + ), + }; + } catch { + // Container may have stopped / been removed + return null; + } }) + ) + ); + + // Remove failed containers + return results.filter( + (deployment: any): deployment is Deployment => deployment !== null ); }