From fd3b8bda026fb57620b578db25cc16ee6d3916a4 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Fri, 2 Jan 2026 19:40:00 +0100 Subject: [PATCH 1/9] Enviroment variable patch --- src/app/environment/page.tsx | 92 +++++++++++++++----------- src/components/dialogs/AddVariable.tsx | 16 +++-- src/core/application.ts | 5 +- src/lib/types.ts | 3 +- 4 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index 9bb5d2a..d051c0b 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -33,16 +33,6 @@ export default function EnvironmentPage() { fetch(); }, []); - const currentGroup = envGroups[selectedApp]; - - const toggleSecret = (key: string) => { - setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const maskValue = (value: string) => { - return "•".repeat(Math.min(value.length, 24)); - }; - const handleSave = async () => { setIsSaving(true); try { @@ -54,6 +44,12 @@ export default function EnvironmentPage() { } }; + const currentGroup = envGroups[selectedApp] || { + id: "", + name: "", + variables: {}, + }; + return (

- {currentGroup?.variables.length} variables •{" "} - {currentGroup?.variables.filter((v) => v.secret).length} secrets + {Object.keys(currentGroup?.variables).length} variables •{" "} + { + Object.values(currentGroup?.variables).filter((v) => v.secret) + .length + }{" "} + secrets

@@ -129,9 +129,9 @@ export default function EnvironmentPage() { {/* Rows */}
- {currentGroup?.variables.map((variable, i) => ( + {Object.entries(currentGroup?.variables).map(([key, variable]) => (
{variable.secret && } - {variable.key} + {key}
{/* Value */}
{ + const newValue = e.target.value; + + setEnvGroups((prev) => ({ + ...prev, + [selectedApp]: { + ...prev[selectedApp], + variables: { + ...prev[selectedApp].variables, + [key]: { + ...prev[selectedApp].variables[key], + value: newValue, + }, + }, + }, + })); + }} className="font-mono text-xs h-8 bg-background border-border" />
@@ -168,9 +178,14 @@ export default function EnvironmentPage() { + + +
diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx new file mode 100644 index 0000000..541c5ec --- /dev/null +++ b/src/components/dialogs/DeployApp.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState, ReactNode, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getLiveNodes } from "@/lib/dockship/node"; +import { Combobox } from "../ui/combobox"; +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 }) { + const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); + const [apps, setApps] = useState([]); + + const [name, setName] = useState(""); + const [app, setApp] = useState(""); + const [node, setNode] = useState(""); + + useEffect(() => { + const fetchNodes = async () => { + setNodeList(await getLiveNodes()); + setApps(await getApplications()); + }; + fetchNodes(); + }, []); + + const deploy = async () => { + console.log(name, app, node); + await deployApp(name, app, node); + }; + + return ( + + {children} + + + + Deploy Application + + +
+
+ + setName(e.target.value)} + placeholder="My app" + /> +
+ +
+ + ({ + label: app.name, + value: app.id, + }))} + placeholder="Select App..." + listPlaceholder="No apps found :(" + /> +
+ +
+ + node.liveData) + .map((node) => ({ label: node.name, value: node.node_id }))} + placeholder="Select Node..." + listPlaceholder="No nodes found :(" + /> +
+
+ + + + + + + + + +
+
+ ); +} diff --git a/src/core/deployment.ts b/src/core/deployment.ts index 30284cd..60641f0 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -3,51 +3,111 @@ import Docker from "dockerode"; import { Application, Deployment } from "@/lib/types"; import { CPUusage } from "@/lib/server/calc"; +import path from "path"; +import fs from "fs"; const docker = new Docker(); +function sanitizeString(str: string): string { + if (!str) return ""; + return ( + str[0].replace(/[^a-zA-Z0-9]/g, "-") + + str.slice(1).replace(/[^a-zA-Z0-9_.-]/g, "-") + ); +} + export async function getDeployments( apps: Application[] ): Promise<(null | Deployment)[]> { - const containers = await docker.listContainers({ all: true }); - return await Promise.all( apps .map((app) => app.deployments) .flat() - .map(async (imageId) => { - const containerInfo = containers.find( - (container) => container.ImageID === imageId - ); - - if (!containerInfo) return null; - - const container = docker.getContainer(containerInfo.Id); + .map(async (deployId) => { + const container = docker.getContainer(deployId); + const containerInfo = await container.inspect(); let cpu = 0; let memory = 0; let ports: Set<[number, number]> = new Set(); - if (containerInfo.State === "running") { + if (containerInfo.State.Running) { const stats = await container.stats({ stream: false }); memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100; cpu += CPUusage(stats); } - containerInfo.Ports?.forEach((p) => { - if (p.PublicPort && p.PrivatePort) { - ports.add([p.PublicPort, p.PrivatePort]); + 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]); + } } - }); + } return { - image: containerInfo.ImageID, + image: containerInfo.Image, container: containerInfo.Id, cpu, memory, - status: containerInfo.State === "running" ? "running" : "stopped", + status: containerInfo.State.Running ? "running" : "stopped", ports: [...ports], }; }) ); } + +export async function deployApp( + name: string, + appId: string +): Promise { + const appPath = path.join("data", "apps", appId, "repo"); + const imageTag = `dockship/${appId}:latest`; + + try { + // Check if app exists + fs.accessSync(appPath); + } catch { + console.error("Repo does not exist:", appPath); + return undefined; + } + + try { + const stream = await docker.buildImage( + { + context: appPath, + src: ["Dockerfile", "."], + }, + { t: imageTag } + ); + + await new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err: Error | null) => + err ? reject(err) : resolve() + ); + }); + + const container = await docker.createContainer({ + Image: imageTag, + name: sanitizeString(name), + Tty: true, + HostConfig: { + RestartPolicy: { Name: "unless-stopped" }, + PortBindings: { + "3000/tcp": [{ HostPort: "3800" }], + }, + }, + }); + + await container.start(); + + return container.id; + } catch (err) { + console.error("Failed to deploy app:", err); + return undefined; + } +} diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 5176b54..0f6155e 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -20,3 +20,25 @@ export async function getDeployments(): Promise<(null | Deployment)[]> { ) ).flat(); } + +export async function deployApp( + name: string, + appId: string, + nodeId: string +): Promise { + const nodes = await getNodes(); + + const node = nodes.find((n) => n.node_id === nodeId); + + if (!node) throw new Error("Node not found"); + + return await ( + await axios.post( + `http://${node.ip}:3000/api/deploy`, + { name, appId }, + { + headers: { Authorization: `ApiKey ${node.key}` }, + } + ) + ).data.deployId; +} From 5ff7e6dd1eea2541e3aac820d42470dc710a79f2 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Fri, 2 Jan 2026 23:40:48 +0100 Subject: [PATCH 7/9] Deployments patch --- src/app/deployments/page.tsx | 186 +++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 74 deletions(-) diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index 382185e..e269250 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -1,120 +1,158 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Rocket, - RotateCcw, - Eye, - MoreVertical, - Plus, - GitBranch, -} from "lucide-react"; -import { useEffect, useState } from "react"; +import { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react"; import { Application, Deployment } from "@/lib/types"; import { getDeployments } from "@/lib/dockship/deploy"; import { getApplications } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; import DeployAppDialog from "@/components/dialogs/DeployApp"; +const statusDot = { + running: "bg-chart-2", + pending: "bg-chart-1", + stopped: "bg-slate-400 dark:bg-slate-600", +} as const; + export default function DeploymentsPage() { const [applications, setApplications] = useState([]); const [deployments, setDeployments] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - const fetch = async () => { - setApplications(await getApplications()); - setDeployments((await getDeployments()).filter((d) => d !== null)); + let mounted = true; + + async function fetchData() { + try { + const [apps, deps] = await Promise.all([ + getApplications(), + getDeployments(), + ]); + + if (!mounted) return; + + setApplications(apps); + setDeployments(deps.filter((d) => d !== null)); + } catch (err) { + console.error("Failed to load deployments:", err); + } finally { + if (mounted) setLoading(false); + } + } + + fetchData(); + return () => { + mounted = false; }; - fetch(); }, []); + const appNameByContainer = useMemo(() => { + const map = new Map(); + for (const app of applications) { + for (const container of app.deployments) { + map.set(container, app.name); + } + } + return map; + }, [applications]); + return ( {/* Header */} -
- {deployments.length} deployments +
+ + {loading ? "Loading…" : `${deployments.length} deployments`} + -
-
- {deployments.map((dep) => ( -
-
- -
-
- { - applications.find((app) => - app.deployments.includes(dep.image) - )?.name - } -
-
- {/* {dep.version} */} + {/* Desktop */} +
+ {deployments.map((dep) => { + const appName = appNameByContainer.get(dep.container) ?? "Unknown app"; + + return ( +
+
+ +
+
{appName}
+
+ {dep.image} +
+ +
+ + + +
-
- {/* {dep.time} */} -
-
- ))} + ); + })}
- {/* Mobile cards */} + {/* Mobile */}
- {deployments.map((dep) => ( -
-
-
- - {dep.image} + {deployments.map((dep) => { + const appName = + appNameByContainer.get(dep.container) ?? "Unknown app"; + + return ( +
+
+
+ + {appName} +
+ + {dep.status} +
- {dep.status} -
- {/*
-
- By -
{dep.deployedBy}
-
-
*/} - -
- - - +
+ + + +
-
- ))} + ); + })}
); From 8265f123751f1929d128bdd5aeca58b817da5375 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Fri, 2 Jan 2026 23:54:05 +0100 Subject: [PATCH 8/9] Recent deployments patch --- src/app/overview/page.tsx | 66 +++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx index 97d5447..868b291 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -17,47 +17,24 @@ import { import { Progress } from "@/components/ui/progress"; import Link from "next/link"; import { getLiveNodes } from "@/lib/dockship/node"; -import { useEffect, useState } from "react"; -import { Application, Docker, Node, NodeLiveData } from "@/lib/types"; +import { useEffect, useMemo, useState } from "react"; +import { + Application, + Deployment, + Docker, + Node, + NodeLiveData, +} from "@/lib/types"; import { getDocker } from "@/lib/dockship/docker"; import { getApplications } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; - -const recentDeployments = [ - { - id: "dep-001", - app: "api-gateway", - version: "v2.4.1", - status: "running" as const, - time: "2m ago", - }, - { - id: "dep-002", - app: "auth-service", - version: "v1.8.0", - status: "running" as const, - time: "15m ago", - }, - { - id: "dep-003", - app: "worker-queue", - version: "v3.1.2", - status: "pending" as const, - time: "32m ago", - }, - { - id: "dep-004", - app: "metrics-collector", - version: "v1.2.0", - status: "error" as const, - time: "1h ago", - }, -]; +import { getDeployments } from "@/lib/dockship/deploy"; export default function OverviewPage() { const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); const [dockerNodes, setDockerNodes] = useState([]); const [apps, setApplications] = useState([]); + const [deployments, setDeployments] = useState([]); useEffect(() => { const fetchNodes = async () => { @@ -65,6 +42,7 @@ export default function OverviewPage() { setNodes(nodes); setDockerNodes(await getDocker(nodes)); setApplications(await getApplications()); + setDeployments((await getDeployments()).filter((d) => d !== null)); }; fetchNodes(); const interval = setInterval(fetchNodes, 3000); @@ -72,6 +50,16 @@ export default function OverviewPage() { return () => clearInterval(interval); }, []); + const appNameByContainer = useMemo(() => { + const map = new Map(); + for (const app of apps) { + for (const container of app.deployments) { + map.set(container, app.name); + } + } + return map; + }, [apps]); + return ( {/* Stats Grid */} @@ -176,9 +164,9 @@ export default function OverviewPage() {
- {recentDeployments.map((dep) => ( + {deployments.slice(-5).map((dep) => (
@@ -191,14 +179,16 @@ export default function OverviewPage() { )} />
-
{dep.app}
+
+ {appNameByContainer.get(dep.container)} +
- {dep.version} + {dep.image.slice(7, 19)}
- {dep.time} + {/* {dep.time} */}
))} From e95dc669136eaac3e96ef57516996cd1a0e0ffbb Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 00:12:30 +0100 Subject: [PATCH 9/9] Patched a few things with deployment --- package-lock.json | 28 ++++++++ package.json | 1 + src/app/applications/page.tsx | 9 ++- src/app/deployments/page.tsx | 5 +- src/components/dialogs/DeployApp.tsx | 10 ++- src/core/deployment.ts | 95 ++++++++++++++++++---------- 6 files changed, 109 insertions(+), 39 deletions(-) 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 ); }