From 7dd0dca73c991a2292641ba51806b26fb5ac2436 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 13:40:36 +0100 Subject: [PATCH 1/6] Container stats and actions --- .../api/{get-docker => container}/route.ts | 0 src/app/api/container/start/route.ts | 54 ++++++++++++++++++ src/app/api/container/stats/route.ts | 33 +++++++++++ src/app/containers/page.tsx | 55 +++++++++++------- src/app/overview/page.tsx | 6 +- src/core/docker.ts | 56 ++++++++++++++++++- src/core/server/docker.ts | 18 +++++- 7 files changed, 198 insertions(+), 24 deletions(-) rename src/app/api/{get-docker => container}/route.ts (100%) create mode 100644 src/app/api/container/start/route.ts create mode 100644 src/app/api/container/stats/route.ts diff --git a/src/app/api/get-docker/route.ts b/src/app/api/container/route.ts similarity index 100% rename from src/app/api/get-docker/route.ts rename to src/app/api/container/route.ts diff --git a/src/app/api/container/start/route.ts b/src/app/api/container/start/route.ts new file mode 100644 index 0000000..b0475c6 --- /dev/null +++ b/src/app/api/container/start/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import testAuth from "../../auth"; +import { StatusCodes } from "http-status-codes"; +import { startContainer, stopContainer } from "@/core/server/docker"; + +export async function POST(req: NextRequest): Promise { + const auth = await testAuth(req); + const id = req.nextUrl.searchParams.get("containerId"); + + if (!id) { + return NextResponse.json( + { message: "Invalid containerId param" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + await startContainer(id); + + return NextResponse.json({ + message: "ok", + }); +} + +export async function DELETE(req: NextRequest): Promise { + const auth = await testAuth(req); + const id = req.nextUrl.searchParams.get("containerId"); + + if (!id) { + return NextResponse.json( + { message: "Invalid containerId param" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + await stopContainer(id); + + return NextResponse.json({ + message: "ok", + }); +} diff --git a/src/app/api/container/stats/route.ts b/src/app/api/container/stats/route.ts new file mode 100644 index 0000000..e30ce89 --- /dev/null +++ b/src/app/api/container/stats/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import testAuth from "../../auth"; +import { StatusCodes } from "http-status-codes"; +import { getContainerStats } from "@/core/server/docker"; + +export async function GET(req: NextRequest): Promise { + const auth = await testAuth(req); + const id = req.nextUrl.searchParams.get("containerId"); + + if (!id) { + return NextResponse.json( + { message: "Invalid containerId param" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + const stats = await getContainerStats(id); + + return NextResponse.json({ + message: "ok", + stats: { + cpu_stats: stats?.cpu_stats, + memory_stats: stats?.memory_stats, + }, + }); +} diff --git a/src/app/containers/page.tsx b/src/app/containers/page.tsx index 60c3886..f0fccef 100644 --- a/src/app/containers/page.tsx +++ b/src/app/containers/page.tsx @@ -4,18 +4,17 @@ import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - MoreVertical, - Play, - Square, - Trash2, - Container, -} from "lucide-react"; +import { MoreVertical, Play, Square, Trash2, Container } from "lucide-react"; import { useEffect, useState } from "react"; -import { Docker } from "@/lib/types"; import { getDocker } from "@/core/docker"; import { getNodes } from "@/core/node"; import { ContainerInfo } from "dockerode"; +import { formatBytes } from "@/lib/format"; +import { + getContainerStats, + startContainer, + stopContainer, +} from "@/core/docker"; type ContainerSummary = { id: string; @@ -24,16 +23,16 @@ type ContainerSummary = { node: string; status: "running" | "stopped"; uptime: string; - cpu: string; + cpu: number; memory: string; }; -function summarizeContainer( +async function summarizeContainer( container: ContainerInfo, nodeName: string -): ContainerSummary { +): Promise { // 1. Shorten the long container ID - const shortId = container.Id.slice(0, 12); + const shortId = container.Id; // 2. Clean the name const name = container.Names[0]?.replace(/^\//, "") ?? "unknown"; @@ -44,9 +43,13 @@ function summarizeContainer( // 4. Compute uptime from Status string (if available) const uptime = container.Status?.replace(/^Up\s*/, "") ?? "unknown"; - // 5. Placeholder for CPU and memory - const cpu = "4%"; - const memory = "64MB / 128MB"; + const stats = await getContainerStats(container.Id); + + // 5. CPU and memory + const cpu = stats?.cpu_stats?.cpu_usage?.total_usage || 0; + const memory = `${formatBytes( + stats?.memory_stats?.usage || 0 + )} / ${formatBytes(stats?.memory_stats?.max_usage || 0)}`; return { id: shortId, @@ -69,8 +72,12 @@ export default function ContainersPage() { const dockerNodes = await getDocker(nodes); setContainers( - nodes.flatMap((node, i) => - dockerNodes[i].containers.map((c) => summarizeContainer(c, node.name)) + await Promise.all( + nodes.flatMap((node, i) => + dockerNodes[i].containers.map((c) => + summarizeContainer(c, node.name) + ) + ) ) ); }; @@ -131,7 +138,7 @@ export default function ContainersPage() { {container.name}
- {container.id} + {container.id.slice(0, 12)}
@@ -160,11 +167,19 @@ export default function ContainersPage() {
{container.status === "running" ? ( - ) : ( - )} diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx index 20c683c..e6007c9 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -120,7 +120,11 @@ export default function OverviewPage() { /> sum + n.containers.length, 0)} + value={dockerNodes.reduce( + (sum, n) => + sum + n.containers.filter((c) => c.State === "running").length, + 0 + )} subtitle="running" icon={} /> diff --git a/src/core/docker.ts b/src/core/docker.ts index 6ee9137..051031f 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -3,6 +3,8 @@ import axios from "axios"; import { Docker, Node } from "@/lib/types"; import { verifySession } from "./auth/session"; +import { ContainerStats } from "dockerode"; +import { getNodes } from "./node"; export async function getDocker(nodes: Node[]): Promise { if (await verifySession()) return []; @@ -10,7 +12,7 @@ export async function getDocker(nodes: Node[]): Promise { return await Promise.all( nodes.map(async (node) => { const data = await ( - await axios.get(`http://${node.ip}:3000/api/get-docker`, { + await axios.get(`http://${node.ip}:3000/api/container/`, { headers: { Authorization: `ApiKey ${node.key}` }, }) ).data; @@ -19,3 +21,55 @@ export async function getDocker(nodes: Node[]): Promise { }) ); } + +export async function getContainerStats( + containerId: string +): Promise { + const nodes = await getNodes(); + + return ( + await Promise.all( + nodes.map( + async (n) => + await ( + await axios.get(`http://${n.ip}:3000/api/container/stats`, { + headers: { Authorization: `ApiKey ${n.key}` }, + params: { containerId }, + }) + ).data + ) + ) + ).find((s) => s); +} + +export async function startContainer(containerId: string) { + const nodes = await getNodes(); + + nodes.forEach( + async (n) => + await ( + await axios.post( + `http://${n.ip}:3000/api/container/start`, + { containerId }, + { + headers: { Authorization: `ApiKey ${n.key}` }, + params: { containerId }, + } + ) + ).data + ); +} + +export async function stopContainer(containerId: string) { + const nodes = await getNodes(); + + nodes.forEach( + async (n) => + await ( + await axios.delete(`http://${n.ip}:3000/api/container/start`, { + headers: { Authorization: `ApiKey ${n.key}` }, + params: { containerId }, + }) + ).data + ); +} diff --git a/src/core/server/docker.ts b/src/core/server/docker.ts index fdb79a2..cc2b768 100644 --- a/src/core/server/docker.ts +++ b/src/core/server/docker.ts @@ -1,11 +1,25 @@ "use server"; -import Docker from "dockerode"; +import Docker, { ContainerStats } from "dockerode"; const docker = new Docker(); export async function getDocker() { const version = await docker.version(); - const containers = await docker.listContainers(); + const containers = await docker.listContainers({ all: true }); return { version, containers }; } + +export async function getContainerStats( + containerId: string +): Promise { + return await (await docker.getContainer(containerId)).stats(); +} + +export async function startContainer(containerId: string) { + await docker.getContainer(containerId).start(); +} + +export async function stopContainer(containerId: string) { + await docker.getContainer(containerId).stop(); +} From c1eacf996d9a9435533dd0db309003212ef4ee57 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 13:55:34 +0100 Subject: [PATCH 2/6] Remove container --- package-lock.json | 85 ++++++++++++++++ package.json | 1 + src/app/api/container/route.ts | 27 ++++- src/app/containers/page.tsx | 50 ++++++++- src/components/ui/alert-dialog.tsx | 157 +++++++++++++++++++++++++++++ src/core/docker.ts | 14 +++ src/core/server/docker.ts | 4 + 7 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 src/components/ui/alert-dialog.tsx diff --git a/package-lock.json b/package-lock.json index 7b5a641..2dc78fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "dockship", "version": "0.1.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", @@ -948,6 +949,90 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", diff --git a/package.json b/package.json index bd67553..59f20b5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", diff --git a/src/app/api/container/route.ts b/src/app/api/container/route.ts index 4e847f0..0651447 100644 --- a/src/app/api/container/route.ts +++ b/src/app/api/container/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { getDocker } from "@/core/server/docker"; +import { getDocker, removeContainer } from "@/core/server/docker"; import { Docker } from "@/lib/types"; export async function GET(req: NextRequest): Promise { @@ -22,3 +22,28 @@ export async function GET(req: NextRequest): Promise { containers: docker.containers, } as Docker); } + +export async function DELETE(req: NextRequest): Promise { + const auth = await testAuth(req); + const id = req.nextUrl.searchParams.get("containerId"); + + if (!id) { + return NextResponse.json( + { message: "Invalid containerId param" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + await removeContainer(id); + + return NextResponse.json({ + message: "ok", + }); +} diff --git a/src/app/containers/page.tsx b/src/app/containers/page.tsx index f0fccef..5b5e2aa 100644 --- a/src/app/containers/page.tsx +++ b/src/app/containers/page.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { MoreVertical, Play, Square, Trash2, Container } from "lucide-react"; import { useEffect, useState } from "react"; -import { getDocker } from "@/core/docker"; +import { getDocker, removeContainer } from "@/core/docker"; import { getNodes } from "@/core/node"; import { ContainerInfo } from "dockerode"; import { formatBytes } from "@/lib/format"; @@ -15,6 +15,17 @@ import { startContainer, stopContainer, } from "@/core/docker"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; type ContainerSummary = { id: string; @@ -63,6 +74,39 @@ async function summarizeContainer( }; } +function RemoveDialog({ container }: { container: ContainerSummary }) { + return ( + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently remove the + container. + + + + + Cancel + + removeContainer(container.id)} + > + Delete + + + + + ); +} + export default function ContainersPage() { const [containers, setContainers] = useState([]); @@ -183,9 +227,7 @@ export default function ContainersPage() { )} - + diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/core/docker.ts b/src/core/docker.ts index 051031f..bf8e6c7 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -73,3 +73,17 @@ export async function stopContainer(containerId: string) { ).data ); } + +export async function removeContainer(containerId: string) { + const nodes = await getNodes(); + + nodes.forEach( + async (n) => + await ( + await axios.delete(`http://${n.ip}:3000/api/container`, { + headers: { Authorization: `ApiKey ${n.key}` }, + params: { containerId }, + }) + ).data + ); +} diff --git a/src/core/server/docker.ts b/src/core/server/docker.ts index cc2b768..2013235 100644 --- a/src/core/server/docker.ts +++ b/src/core/server/docker.ts @@ -23,3 +23,7 @@ export async function startContainer(containerId: string) { export async function stopContainer(containerId: string) { await docker.getContainer(containerId).stop(); } + +export async function removeContainer(containerId: string) { + await docker.getContainer(containerId).remove(); +} From fb05b455d5291e548387a16cb461cf8b6374cb33 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 14:31:26 +0100 Subject: [PATCH 3/6] Applications --- src/app/api/applications/route.ts | 22 +++++++ src/app/applications/page.tsx | 96 ++++++------------------------- src/core/application.ts | 20 +++++++ src/core/server/application.ts | 56 ++++++++++++++++++ src/core/server/docker.ts | 2 +- src/lib/types.ts | 13 +++++ 6 files changed, 129 insertions(+), 80 deletions(-) create mode 100644 src/app/api/applications/route.ts create mode 100644 src/core/server/application.ts diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts new file mode 100644 index 0000000..4c7d444 --- /dev/null +++ b/src/app/api/applications/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import testAuth from "../auth"; +import { StatusCodes } from "http-status-codes"; +import { getApplications } from "@/core/server/application"; + +export async function GET(req: NextRequest): Promise { + const auth = await testAuth(req); + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + const applications = await getApplications(); + + return NextResponse.json({ + message: "ok", + applications, + }); +} diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 1edffb2..ca959e5 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -1,89 +1,27 @@ "use client"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Play, Square, RotateCcw, MoreVertical, Plus } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import DeployApplication from "@/components/dialogs/DeployApplication"; - -const applications = [ - { - id: "app-001", - name: "api-gateway", - image: "dockship/api-gateway:v2.4.1", - containers: 3, - replicas: "3/3", - cpu: 45, - memory: 62, - network: "12.4 MB/s", - status: "running" as const, - ports: ["80:8080", "443:8443"], - }, - { - id: "app-002", - name: "auth-service", - image: "dockship/auth:v1.8.0", - containers: 2, - replicas: "2/2", - cpu: 28, - memory: 45, - network: "3.2 MB/s", - status: "running" as const, - ports: ["9000:9000"], - }, - { - id: "app-003", - name: "postgres-db", - image: "postgres:15-alpine", - containers: 1, - replicas: "1/1", - cpu: 65, - memory: 78, - network: "8.1 MB/s", - status: "running" as const, - ports: ["5432:5432"], - }, - { - id: "app-004", - name: "redis-cache", - image: "redis:7-alpine", - containers: 1, - replicas: "1/1", - cpu: 12, - memory: 34, - network: "1.8 MB/s", - status: "running" as const, - ports: ["6379:6379"], - }, - { - id: "app-005", - name: "worker-queue", - image: "dockship/worker:v3.1.2", - containers: 4, - replicas: "3/4", - cpu: 55, - memory: 48, - network: "5.6 MB/s", - status: "pending" as const, - ports: [], - }, - { - id: "app-006", - name: "metrics-collector", - image: "dockship/metrics:v1.2.0", - containers: 2, - replicas: "0/2", - cpu: 0, - memory: 0, - network: "0 MB/s", - status: "error" as const, - ports: ["9090:9090"], - }, -]; +import { useEffect, useState } from "react"; +import { Application } from "@/lib/types"; +import { getApplications } from "@/core/application"; export default function ApplicationsPage() { + const [applications, setApplications] = useState([]); + + useEffect(() => { + const fetch = async () => { + setApplications(await getApplications()); + }; + + fetch(); + }, []); + return (
- {applications.map((app) => ( - + {applications.map((app, i) => ( +
{/* Status & Name */} @@ -166,7 +104,7 @@ export default function ApplicationsPage() { Ports
- {app.ports.length > 0 ? ( + {app.ports?.length > 0 ? ( app.ports.map((port) => ( {port} diff --git a/src/core/application.ts b/src/core/application.ts index 710446a..9139abd 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -6,6 +6,9 @@ import fs from "fs/promises"; import path from "path"; import { exec } from "child_process"; import util from "util"; +import { getNodes } from "./node"; +import axios from "axios"; +import { Application } from "@/lib/types"; const execAsync = util.promisify(exec); @@ -70,3 +73,20 @@ export async function deployNewApp(name: string, repo: string, nodeId: string) { image: imageTag, }; } + +export async function getApplications(): Promise { + const nodes = await getNodes(); + + return ( + await Promise.all( + nodes.map( + async (n) => + await ( + await axios.get(`http://${n.ip}:3000/api/applications`, { + headers: { Authorization: `ApiKey ${n.key}` }, + }) + ).data.applications + ) + ) + ).flat(); +} diff --git a/src/core/server/application.ts b/src/core/server/application.ts new file mode 100644 index 0000000..61b39ab --- /dev/null +++ b/src/core/server/application.ts @@ -0,0 +1,56 @@ +"use server"; + +import { Application } from "@/lib/types"; +import Docker from "dockerode"; + +const docker = new Docker(); + +export async function getApplications(): Promise { + const images = await docker.listImages(); + const containers = await docker.listContainers({ all: true }); + + const apps: Application[] = []; + + for (const image of images) { + const imageName = image.RepoTags?.[0]; + if (!imageName) continue; + + const relatedContainers = containers.filter((c) => c.Image === imageName); + + let totalCpu = 0; + let totalMem = 0; + let running = 0; + const ports = new Set(); + + for (const containerInfo of relatedContainers) { + const container = docker.getContainer(containerInfo.Id); + + if (containerInfo.State === "running") running++; + + containerInfo.Ports?.forEach((p) => { + if (p.PublicPort && p.PrivatePort) { + ports.add(`${p.PublicPort}:${p.PrivatePort}`); + } + }); + + if (containerInfo.State === "running") { + const stats = await container.stats({ stream: false }); + } + } + + apps.push({ + id: image.Id.slice(7, 15), + name: imageName.split("/").pop()?.split(":")[0] ?? imageName, + image: imageName, + containers: relatedContainers.length, + replicas: `${running}/${relatedContainers.length}`, + cpu: Math.round(totalCpu), + memory: Math.round(totalMem), + network: "—", + status: running > 0 ? ("running" as const) : ("stopped" as const), + ports: [...ports], + }); + } + + return apps; +} diff --git a/src/core/server/docker.ts b/src/core/server/docker.ts index 2013235..b1e9f20 100644 --- a/src/core/server/docker.ts +++ b/src/core/server/docker.ts @@ -13,7 +13,7 @@ export async function getDocker() { export async function getContainerStats( containerId: string ): Promise { - return await (await docker.getContainer(containerId)).stats(); + return await docker.getContainer(containerId).stats(); } export async function startContainer(containerId: string) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 1cca76c..8ce361f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -47,3 +47,16 @@ export interface Docker { version: string; containers: ContainerInfo[]; } + +export interface Application { + id: string; + name: string; + image: string; + containers: number; + replicas: string; + cpu: number; + memory: number; + network: string; + status: "running" | "stopped"; + ports: string[]; +} From a0e04f7fbea197557ee020ea63b91a77b752eb3e Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 14:46:57 +0100 Subject: [PATCH 4/6] Deploy to node --- src/app/api/applications/route.ts | 29 ++++++++++++- src/core/application.ts | 71 +++++-------------------------- src/core/server/application.ts | 66 +++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 62 deletions(-) diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index 4c7d444..e72fd43 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { getApplications } from "@/core/server/application"; +import { deployNewApp, getApplications } from "@/core/server/application"; export async function GET(req: NextRequest): Promise { const auth = await testAuth(req); @@ -20,3 +20,30 @@ export async function GET(req: NextRequest): Promise { applications, }); } + +export async function POST(req: NextRequest): Promise { + const auth = await testAuth(req); + const { name, repo, nodeId }: { name: string; repo: string; nodeId: string } = + await req.json(); + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + if ((!name && !repo) || !nodeId) { + return NextResponse.json( + { message: "Body missing name, repo, nodeId" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + const res = await deployNewApp(name, repo, nodeId); + + return NextResponse.json({ + message: "ok", + ...res, + }); +} diff --git a/src/core/application.ts b/src/core/application.ts index 9139abd..664ebf8 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -1,77 +1,28 @@ "use server"; import { verifySession } from "./auth/session"; -import { randomUUID } from "crypto"; -import fs from "fs/promises"; -import path from "path"; -import { exec } from "child_process"; -import util from "util"; import { getNodes } from "./node"; import axios from "axios"; import { Application } from "@/lib/types"; -const execAsync = util.promisify(exec); - -const DATA_DIR = path.join(process.cwd(), "data", "apps"); - export async function deployNewApp(name: string, repo: string, nodeId: string) { if (await verifySession()) { throw new Error("Unauthorized"); } - // Create app ID - const appId = `app_${randomUUID()}`; - const appDir = path.join(DATA_DIR, appId); - const repoDir = path.join(appDir, "repo"); + const nodes = await getNodes(); - // Create directories - await fs.mkdir(repoDir, { recursive: true }); + const targetNode = nodes.find((node) => node.node_id === nodeId); - // Write app.json - const appConfig = { - id: appId, - name, - repo, - nodeId, - createdAt: new Date().toISOString(), - }; - - await fs.writeFile( - path.join(appDir, "app.json"), - JSON.stringify(appConfig, null, 2) - ); - - // Clone repository - try { - await execAsync(`git clone ${repo} ${repoDir}`); - } catch (err) { - throw new Error("Failed to clone repository"); - } - - // Validate Dockerfile - const dockerfilePath = path.join(repoDir, "Dockerfile"); - - try { - await fs.access(dockerfilePath); - } catch { - throw new Error("Dockerfile not found in repository root"); - } - - const imageTag = `dockship/${appId}:latest`; - - try { - await execAsync(`docker build -t ${imageTag} .`, { - cwd: repoDir, - }); - } catch { - throw new Error("Docker build failed"); - } - - return { - success: true, - appId, - image: imageTag, - }; + return await ( + await axios.post( + `http://${targetNode?.ip}:3000/api/applications`, + { name, repo, nodeId }, + { + headers: { Authorization: `ApiKey ${targetNode?.key}` }, + } + ) + ).data.applications; } export async function getApplications(): Promise { diff --git a/src/core/server/application.ts b/src/core/server/application.ts index 61b39ab..f2db803 100644 --- a/src/core/server/application.ts +++ b/src/core/server/application.ts @@ -1,9 +1,17 @@ "use server"; -import { Application } from "@/lib/types"; import Docker from "dockerode"; +import { randomUUID } from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { exec } from "child_process"; +import util from "util"; +import axios from "axios"; +import { Application } from "@/lib/types"; const docker = new Docker(); +const execAsync = util.promisify(exec); +const DATA_DIR = path.join(process.cwd(), "data", "apps"); export async function getApplications(): Promise { const images = await docker.listImages(); @@ -54,3 +62,59 @@ export async function getApplications(): Promise { return apps; } + +export async function deployNewApp(name: string, repo: string, nodeId: string) { + // Create app ID + const appId = `app_${randomUUID()}`; + const appDir = path.join(DATA_DIR, appId); + const repoDir = path.join(appDir, "repo"); + + // Create directories + await fs.mkdir(repoDir, { recursive: true }); + + // Write app.json + const appConfig = { + id: appId, + name, + repo, + nodeId, + createdAt: new Date().toISOString(), + }; + + await fs.writeFile( + path.join(appDir, "app.json"), + JSON.stringify(appConfig, null, 2) + ); + + // Clone repository + try { + await execAsync(`git clone ${repo} ${repoDir}`); + } catch (err) { + throw new Error("Failed to clone repository"); + } + + // Validate Dockerfile + const dockerfilePath = path.join(repoDir, "Dockerfile"); + + try { + await fs.access(dockerfilePath); + } catch { + throw new Error("Dockerfile not found in repository root"); + } + + const imageTag = `dockship/${appId}:latest`; + + try { + await execAsync(`docker build -t ${imageTag} .`, { + cwd: repoDir, + }); + } catch { + throw new Error("Docker build failed"); + } + + return { + success: true, + appId, + image: imageTag, + }; +} From e14c801d07e310739b048e983e6bc504e50062b9 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 14:56:24 +0100 Subject: [PATCH 5/6] Deploy API fix --- src/app/api/applications/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index e72fd43..c7e0e6a 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -33,7 +33,7 @@ export async function POST(req: NextRequest): Promise { ); } - if ((!name && !repo) || !nodeId) { + if (!name || !repo || !nodeId) { return NextResponse.json( { message: "Body missing name, repo, nodeId" }, { status: StatusCodes.BAD_REQUEST } From 791a61390209d863614bc1607f7912d24640b995 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Tue, 30 Dec 2025 15:32:22 +0100 Subject: [PATCH 6/6] Container monitoring --- src/app/monitoring/page.tsx | 67 +++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index ead99e9..baa6c34 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -9,19 +9,10 @@ import { getMetrics } from "@/core/metrics"; import { useEffect, useState } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import RadialChart from "@/components/RadialChart"; -import { getLiveNodes } from "@/core/node"; +import { getLiveNodes, getNodes } from "@/core/node"; import { average } from "@/lib/format"; - -const containerMetrics = [ - { name: "api-gateway-1", cpu: 45, memory: 62, network: "12.4 MB/s" }, - { name: "api-gateway-2", cpu: 52, memory: 58, network: "10.2 MB/s" }, - { name: "api-gateway-3", cpu: 38, memory: 55, network: "8.8 MB/s" }, - { name: "auth-service-1", cpu: 28, memory: 45, network: "3.2 MB/s" }, - { name: "auth-service-2", cpu: 32, memory: 48, network: "2.8 MB/s" }, - { name: "postgres-db-1", cpu: 65, memory: 78, network: "8.1 MB/s" }, - { name: "redis-cache-1", cpu: 12, memory: 34, network: "1.8 MB/s" }, - { name: "worker-queue-1", cpu: 55, memory: 48, network: "5.6 MB/s" }, -]; +import { ContainerStats } from "dockerode"; +import { getContainerStats, getDocker } from "@/core/docker"; const cpuChartConfig = { cpu: { label: "CPU Usage", color: "var(--chart-1)" }, @@ -51,6 +42,9 @@ export default function MonitoringPage() { cpuUsage: number; ramUsage: number; }>(); + const [containerStats, SetContainerStats] = useState< + (ContainerStats & { name: string })[] + >([]); useEffect(() => { const fetchNodes = async () => { @@ -77,6 +71,32 @@ export default function MonitoringPage() { return () => clearInterval(i); }, []); + useEffect(() => { + async function fetchContainerMetrics() { + const nodes = await getNodes(); + const dockerNodes = await getDocker(nodes); + + SetContainerStats( + ( + await Promise.all( + dockerNodes.flatMap((node) => + node.containers.map(async (c) => { + const stats = await getContainerStats(c.Id); + + if (stats === undefined) return undefined; + + return { ...stats, name: c.Names[0] }; + }) + ) + ) + ).filter((s): s is ContainerStats & { name: string } => s !== undefined) + ); + + console.log(containerStats); + } + fetchContainerMetrics(); + }, []); + return (
- {containerMetrics.map((container) => ( + {containerStats.map((container) => (
CPU - {container.cpu}% + + {container?.cpu_stats?.cpu_usage?.total_usage}% +
- +
Memory - {container.memory}% + {container?.memory_stats?.usage}%
- +
Network - {container.network} + + {container?.networks && + container?.networks[0]?.tx_packets} +