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/api/applications/route.ts b/src/app/api/applications/route.ts index 85d40a9..6dab73d 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -1,11 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { - installApp, - getApplications, - removeApp, -} from "@/core/server/application"; +import { installApp, getApplications, removeApp } from "@/core/application"; export async function GET(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/api/container/route.ts b/src/app/api/container/route.ts index 0651447..8deb02f 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, removeContainer } from "@/core/server/docker"; +import { getDocker, removeContainer } from "@/core/docker"; import { Docker } from "@/lib/types"; export async function GET(req: NextRequest): Promise { diff --git a/src/app/api/container/start/route.ts b/src/app/api/container/start/route.ts index b0475c6..d593d28 100644 --- a/src/app/api/container/start/route.ts +++ b/src/app/api/container/start/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../../auth"; import { StatusCodes } from "http-status-codes"; -import { startContainer, stopContainer } from "@/core/server/docker"; +import { startContainer, stopContainer } from "@/core/docker"; export async function POST(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/api/container/stats/route.ts b/src/app/api/container/stats/route.ts index f775bf8..324b83a 100644 --- a/src/app/api/container/stats/route.ts +++ b/src/app/api/container/stats/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../../auth"; import { StatusCodes } from "http-status-codes"; -import { getContainerStats } from "@/core/server/docker"; +import { getContainerStats } from "@/core/docker"; export async function GET(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts new file mode 100644 index 0000000..6898a0c --- /dev/null +++ b/src/app/api/deploy/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import testAuth from "../auth"; +import { StatusCodes } from "http-status-codes"; +import { deployApp, getDeployments } from "@/core/deployment"; +import { getApplications } from "@/core/application"; + +export async function GET(req: NextRequest): Promise { + const auth = await testAuth(req); + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + const apps = await getApplications(); + + const applications = await getDeployments(apps); + + return NextResponse.json({ + message: "ok", + applications, + }); +} + +export async function POST(req: NextRequest): Promise { + const auth = await testAuth(req); + const { name, appId }: { name: string; appId: string } = await req.json(); + + if (auth) { + return NextResponse.json( + { message: auth }, + { status: StatusCodes.UNAUTHORIZED } + ); + } + + if (!name || !appId) { + return NextResponse.json( + { message: "Body missing name, appId, nodeId" }, + { status: StatusCodes.BAD_REQUEST } + ); + } + + const deployId = await deployApp(name, appId); + + return NextResponse.json({ + message: "ok", + deployId, + }); +} diff --git a/src/app/api/env/route.ts b/src/app/api/env/route.ts index 249ce8b..bff743a 100644 --- a/src/app/api/env/route.ts +++ b/src/app/api/env/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { getEnv, setEnv } from "@/core/server/application"; +import { getEnv, setEnv } from "@/core/application"; export async function GET(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts index 23dd21e..2c4d920 100644 --- a/src/app/api/logs/route.ts +++ b/src/app/api/logs/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { getAllContainerLogs } from "@/core/server/docker"; +import { getAllContainerLogs } from "@/core/docker"; export async function GET(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 2003549..8fa0c73 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -14,9 +14,10 @@ import { Button } from "@/components/ui/button"; import { MoreVertical, Plus, Rocket } from "lucide-react"; import InstallApplicationDialog from "@/components/dialogs/InstallApplication"; import { useEffect, useState } from "react"; -import { getApplications, removeApp } from "@/core/application"; +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 ebd8cbe..70c3922 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -1,118 +1,159 @@ "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 "@/core/server/deployment"; -import { getApplications } from "@/core/application"; +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 () => { - const apps = await getApplications(); - setApplications(apps); - setDeployments((await getDeployments(apps)).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} */} -
-
-
-
- {/* {dep.time} */} -
-
- ))} -
+ {/* Desktop */} +
+ {deployments.map((dep) => { + const appName = + appNameByContainer.get(dep.container) ?? "Unknown app"; - {/* Mobile cards */} -
- {deployments.map((dep) => ( -
-
-
- - {dep.image} -
- {dep.status} -
- - {/*
-
- By -
{dep.deployedBy}
+ return ( +
+
+ +
+
{appName}
+
+ {dep.image}
-
*/} +
+
-
- - - +
+ + + +
-
- ))} + ); + })} +
+ + {/* Mobile */} +
+ {deployments.map((dep) => { + const appName = + appNameByContainer.get(dep.container) ?? "Unknown app"; + + return ( +
+
+
+ + {appName} +
+ + {dep.status} + +
+ +
+ + + +
+
+ ); + })}
); diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index 9bb5d2a..2db907e 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Trash2, Eye, EyeOff, Copy, Save, Lock, Loader2 } from "lucide-react"; import { Env } from "@/lib/types"; -import { getEnv, setEnv } from "@/core/application"; +import { getEnv, setEnv } from "@/lib/dockship/application"; import { AddVariableDialog } from "@/components/dialogs/AddVariable"; import { DropdownMenu, @@ -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/InstallApplication.tsx b/src/components/dialogs/InstallApplication.tsx index 3260ba7..47b242c 100644 --- a/src/components/dialogs/InstallApplication.tsx +++ b/src/components/dialogs/InstallApplication.tsx @@ -13,10 +13,10 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { getLiveNodes } from "@/core/node"; +import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; import { Node, NodeLiveData } from "@/lib/types"; -import { installApp } from "@/core/application"; +import { installApp } from "@/lib/dockship/application"; export default function InstallApplicationDialog({ children, diff --git a/src/core/application.ts b/src/core/application.ts index 1499eef..6d5b58b 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -1,85 +1,128 @@ "use server"; -import { verifySession } from "./auth/session"; -import { getNodes } from "./node"; -import axios from "axios"; -import { Env, EnvVariable, Application } from "@/lib/types"; +import Docker from "dockerode"; +import { randomUUID } from "crypto"; +import fs from "fs"; +import path from "path"; +import { exec } from "child_process"; +import util from "util"; +import { Application, Env, EnvVariable } from "@/lib/types"; -export async function installApp(name: string, repo: string, nodeId: string) { - if (await verifySession()) { - throw new Error("Unauthorized"); - } - - const nodes = await getNodes(); - - const targetNode = nodes.find((node) => node.node_id === nodeId); - - return await ( - await axios.post( - `http://${targetNode?.ip}:3000/api/applications`, - { name, repo, nodeId }, - { - headers: { Authorization: `ApiKey ${targetNode?.key}` }, - } - ) - ).data.applications; -} +const docker = new Docker(); +const execAsync = util.promisify(exec); +const DATA_DIR = path.join(process.cwd(), "data", "apps"); 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 + return fs + .readdirSync(DATA_DIR) + .map((appId) => + JSON.parse( + fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString() ) - ) - ).flat(); + ); +} + +export async function installApp(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 + fs.mkdirSync(repoDir, { recursive: true }); + + // Write app.json + const appConfig: Application = { + id: appId, + name, + repo, + nodeId, + createdAt: new Date().toISOString(), + deployments: [], + }; + + fs.writeFileSync( + path.join(appDir, "app.json"), + JSON.stringify(appConfig, null, 2) + ); + + // Write env.json + fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], 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 { + fs.accessSync(dockerfilePath); + } catch { + fs.rmSync(appDir, { recursive: true }); + throw new Error("Dockerfile not found in repository root"); + } + + return { + success: true, + appId, + }; } export async function getEnv(): Promise> { - const nodes = await getNodes(); + const apps = fs.readdirSync(DATA_DIR); - const envRecords = await Promise.all( - nodes.map(async (n) => { - const response = await axios.get(`http://${n.ip}:3000/api/env`, { - headers: { Authorization: `ApiKey ${n.key}` }, - }); - return response.data as Record; - }) - ); + return apps.reduce((acc, appId) => { + const appPath = path.join(DATA_DIR, appId, "app.json"); + const name = JSON.parse(fs.readFileSync(appPath).toString()).name; - const merged: Record = Object.assign({}, ...envRecords); - - return merged; + const envPath = path.join(DATA_DIR, appId, "env.json"); + if (fs.existsSync(envPath)) { + const envData: Record = JSON.parse( + fs.readFileSync(envPath, "utf-8") + ); + acc[appId] = { variables: envData, id: appId, name }; + } + return acc; + }, {} as Record); } export async function setEnv(appId: string, variables: EnvVariable[]) { - const nodes = await getNodes(); + const apps = fs.readdirSync(DATA_DIR); - nodes.forEach(async (n) => { - await axios.post( - `http://${n.ip}:3000/api/env`, - { appId, variables }, - { - headers: { Authorization: `ApiKey ${n.key}` }, - } - ); + apps.forEach((id) => { + if (id === appId) { + const envPath = path.join(DATA_DIR, appId, "env.json"); + + fs.writeFileSync(envPath, JSON.stringify(variables)); + } }); } export async function removeApp(appId: string) { - const nodes = await getNodes(); + const appPath = path.join(DATA_DIR, appId); - nodes.forEach(async (n) => { - await axios.delete(`http://${n.ip}:3000/api/applications`, { - params: { appId }, - headers: { Authorization: `ApiKey ${n.key}` }, - }); + const appMeta: Application = JSON.parse( + fs.readFileSync(path.join(appPath, "app.json")).toString() + ); + + appMeta.deployments.forEach(async (imgId) => { + // Remove containers + (await docker.listContainers({ all: true })) + .filter((con) => con.ImageID === imgId) + .forEach((con) => { + const container = docker.getContainer(con.Id); + container.stop(); + container.remove(); + }); + + // Remove image + const image = docker.getImage(imgId); + image.remove(); }); + + fs.rmSync(appPath, { recursive: true }); } diff --git a/src/core/auth/session.ts b/src/core/auth/session.ts index eb0eda5..0852287 100644 --- a/src/core/auth/session.ts +++ b/src/core/auth/session.ts @@ -1,13 +1,14 @@ "use server"; import { read, write } from "@/app/api/file"; import axios from "axios"; +import { createHash } from "crypto"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; type Users = Record; export async function getSession(): Promise< - undefined | { user: { id: number }; accessToken: string } + undefined | { userId: string; accessToken: string } > { const c = await cookies(); const accessToken = c.get("github_access")?.value; @@ -16,22 +17,32 @@ export async function getSession(): Promise< redirect("/"); } - // Test the token - try { - const user = ( - await axios.get("https://api.github.com/user", { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - ).data; + const tokens: Record = await read( + () => ({}), + "data", + "sessions.json" + ); + const tokenHash = createHash("sha256").update(accessToken).digest("hex"); + + try { + const userId: string = + tokens[tokenHash] || + ( + await axios.get("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + ).data.id; + + // Test the token const users = Object.keys( await read(() => ({}), "data", "users.json") ); - if (!users.includes(String(user.id))) { + if (!users.includes(String(userId))) { if (users.length === 0) { await write( - { [String(user.id)]: { roles: ["admin"] } } as Users, // First user is always an admin + { [userId]: { roles: ["admin"] } } as Users, // First user is always an admin "data", "users.json" ); @@ -40,7 +51,9 @@ export async function getSession(): Promise< } } - return { user, accessToken }; + await write({ ...tokens, [tokenHash]: userId }, "data", "sessions.json"); + + return { userId, accessToken }; } catch { redirect("/"); } diff --git a/src/core/deployment.ts b/src/core/deployment.ts new file mode 100644 index 0000000..cd7c007 --- /dev/null +++ b/src/core/deployment.ts @@ -0,0 +1,144 @@ +"use server"; + +import Docker from "dockerode"; +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(); + +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 { + // Limit concurrent Docker calls (adjust if needed) + const limit = pLimit(5); + + const deploymentIds = apps.flatMap((app) => app.deployments); + + 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 mapping of mappings) { + 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.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 + ); +} + +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/core/docker.ts b/src/core/docker.ts index 8906e56..e8e203a 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -1,110 +1,112 @@ "use server"; -import axios from "axios"; -import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types"; -import { verifySession } from "./auth/session"; -import { getNodes } from "./node"; +import { CPUusage } from "@/lib/server/calc"; +import { Log, SimpleStats } from "@/lib/types"; +import Docker from "dockerode"; -export async function getDocker(nodes: Node[]): Promise { - if (await verifySession()) return []; +const docker = new Docker(); - return await Promise.all( - nodes.map(async (node) => { - const data = await ( - await axios.get(`http://${node.ip}:3000/api/container/`, { - headers: { Authorization: `ApiKey ${node.key}` }, - }) - ).data; - - return { ...node, containers: data.containers, version: data.version }; - }) - ); +export async function getDocker() { + const version = await docker.version(); + const containers = await docker.listContainers({ all: true }); + return { version, containers }; } export async function getContainerStats( containerId: string ): Promise { - const nodes = await getNodes(); + const container = docker.getContainer(containerId); - 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?.stats - ) - ) - ).find((s) => s); + const data = await container.inspect(); + + if (!data.State.Running) { + return; + } + + const stats = await container.stats({ stream: false }); + + return { + cpu: CPUusage(stats), + memory: (stats.memory_stats.usage / stats.memory_stats.limit) * 100, + }; } 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 - ); + await docker.getContainer(containerId).start(); } 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 - ); + await docker.getContainer(containerId).stop(); } export async function removeContainer(containerId: string) { - const nodes = await getNodes(); + await docker.getContainer(containerId).remove(); +} - nodes.forEach( - async (n) => - await ( - await axios.delete(`http://${n.ip}:3000/api/container`, { - headers: { Authorization: `ApiKey ${n.key}` }, - params: { containerId }, - }) - ).data - ); +function inferLevel(message: string): "info" | "warn" | "error" { + const msg = message.toLowerCase(); + + if (msg.includes("error") || msg.includes("fatal")) return "error"; + if (msg.includes("warn")) return "warn"; + + return "info"; +} + +function demuxLogs(buffer: Buffer): string { + let offset = 0; + let output = ""; + + while (offset < buffer.length) { + const streamType = buffer[offset]; + const length = buffer.readUInt32BE(offset + 4); + offset += 8; + + output += buffer.slice(offset, offset + length).toString("utf8"); + offset += length; + } + + return output; +} + +async function getContainerLogs( + containerId: string, + name: string +): Promise { + const container = docker.getContainer(containerId); + + const raw = await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + }); + + const text = demuxLogs(raw); + + return text + .split("\n") + .filter(Boolean) + .map((line) => { + const firstSpace = line.indexOf(" "); + const timestamp = line.slice(0, firstSpace); + const message = line.slice(firstSpace + 1); + + return { + timestamp, + level: inferLevel(message), + source: name, + message, + } satisfies Log; + }); } export async function getAllContainerLogs(): Promise { - const nodes = await getNodes(); + const containers = await docker.listContainers({ all: true }); - return ( - await Promise.all( - nodes.map( - async (n) => - await ( - await axios.get(`http://${n.ip}:3000/api/logs`, { - headers: { Authorization: `ApiKey ${n.key}` }, - }) - ).data - ) - ) - ) + const logs = await Promise.all( + containers.map((c) => getContainerLogs(c.Id, c.Names[0].replace("/", ""))) + ); + + return logs .flat() - .sort( - (a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); } diff --git a/src/core/server/application.ts b/src/core/server/application.ts deleted file mode 100644 index f6224d7..0000000 --- a/src/core/server/application.ts +++ /dev/null @@ -1,128 +0,0 @@ -"use server"; - -import Docker from "dockerode"; -import { randomUUID } from "crypto"; -import fs from "fs"; -import path from "path"; -import { exec } from "child_process"; -import util from "util"; -import { Application, Env, EnvVariable } 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 { - return fs - .readdirSync(DATA_DIR) - .map((appId) => - JSON.parse( - fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString() - ) - ); -} - -export async function installApp(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 - fs.mkdirSync(repoDir, { recursive: true }); - - // Write app.json - const appConfig: Application = { - id: appId, - name, - repo, - nodeId, - createdAt: new Date().toISOString(), - deployments: [], - }; - - fs.writeFileSync( - path.join(appDir, "app.json"), - JSON.stringify(appConfig, null, 2) - ); - - // Write env.json - fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], 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 { - fs.accessSync(dockerfilePath); - } catch { - fs.rmSync(appDir, { recursive: true }); - throw new Error("Dockerfile not found in repository root"); - } - - return { - success: true, - appId, - }; -} - -export async function getEnv(): Promise> { - const apps = fs.readdirSync(DATA_DIR); - - return apps.reduce((acc, appId) => { - const appPath = path.join(DATA_DIR, appId, "app.json"); - const name = JSON.parse(fs.readFileSync(appPath).toString()).name; - - const envPath = path.join(DATA_DIR, appId, "env.json"); - if (fs.existsSync(envPath)) { - const envData: EnvVariable[] = JSON.parse( - fs.readFileSync(envPath, "utf-8") - ); - acc[appId] = { variables: envData, id: appId, name }; - } - return acc; - }, {} as Record); -} - -export async function setEnv(appId: string, variables: EnvVariable[]) { - const apps = fs.readdirSync(DATA_DIR); - - apps.forEach((id) => { - if (id === appId) { - const envPath = path.join(DATA_DIR, appId, "env.json"); - - fs.writeFileSync(envPath, JSON.stringify(variables)); - } - }); -} - -export async function removeApp(appId: string) { - const appPath = path.join(DATA_DIR, appId); - - const appMeta: Application = JSON.parse( - fs.readFileSync(path.join(appPath, "app.json")).toString() - ); - - appMeta.deployments.forEach(async (imgId) => { - // Remove containers - (await docker.listContainers({ all: true })) - .filter((con) => con.ImageID === imgId) - .forEach((con) => { - const container = docker.getContainer(con.Id); - container.stop(); - container.remove(); - }); - - // Remove image - const image = docker.getImage(imgId); - image.remove(); - }); - - fs.rmSync(appPath, { recursive: true }); -} diff --git a/src/core/server/deployment.ts b/src/core/server/deployment.ts deleted file mode 100644 index 30284cd..0000000 --- a/src/core/server/deployment.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use server"; - -import Docker from "dockerode"; -import { Application, Deployment } from "@/lib/types"; -import { CPUusage } from "@/lib/server/calc"; - -const docker = new Docker(); - -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); - - let cpu = 0; - let memory = 0; - let ports: Set<[number, number]> = new Set(); - - 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]); - } - }); - - return { - image: containerInfo.ImageID, - container: containerInfo.Id, - cpu, - memory, - status: containerInfo.State === "running" ? "running" : "stopped", - ports: [...ports], - }; - }) - ); -} diff --git a/src/core/server/docker.ts b/src/core/server/docker.ts deleted file mode 100644 index e8e203a..0000000 --- a/src/core/server/docker.ts +++ /dev/null @@ -1,112 +0,0 @@ -"use server"; - -import { CPUusage } from "@/lib/server/calc"; -import { Log, SimpleStats } from "@/lib/types"; -import Docker from "dockerode"; - -const docker = new Docker(); - -export async function getDocker() { - const version = await docker.version(); - const containers = await docker.listContainers({ all: true }); - return { version, containers }; -} - -export async function getContainerStats( - containerId: string -): Promise { - const container = docker.getContainer(containerId); - - const data = await container.inspect(); - - if (!data.State.Running) { - return; - } - - const stats = await container.stats({ stream: false }); - - return { - cpu: CPUusage(stats), - memory: (stats.memory_stats.usage / stats.memory_stats.limit) * 100, - }; -} - -export async function startContainer(containerId: string) { - await docker.getContainer(containerId).start(); -} - -export async function stopContainer(containerId: string) { - await docker.getContainer(containerId).stop(); -} - -export async function removeContainer(containerId: string) { - await docker.getContainer(containerId).remove(); -} - -function inferLevel(message: string): "info" | "warn" | "error" { - const msg = message.toLowerCase(); - - if (msg.includes("error") || msg.includes("fatal")) return "error"; - if (msg.includes("warn")) return "warn"; - - return "info"; -} - -function demuxLogs(buffer: Buffer): string { - let offset = 0; - let output = ""; - - while (offset < buffer.length) { - const streamType = buffer[offset]; - const length = buffer.readUInt32BE(offset + 4); - offset += 8; - - output += buffer.slice(offset, offset + length).toString("utf8"); - offset += length; - } - - return output; -} - -async function getContainerLogs( - containerId: string, - name: string -): Promise { - const container = docker.getContainer(containerId); - - const raw = await container.logs({ - stdout: true, - stderr: true, - timestamps: true, - }); - - const text = demuxLogs(raw); - - return text - .split("\n") - .filter(Boolean) - .map((line) => { - const firstSpace = line.indexOf(" "); - const timestamp = line.slice(0, firstSpace); - const message = line.slice(firstSpace + 1); - - return { - timestamp, - level: inferLevel(message), - source: name, - message, - } satisfies Log; - }); -} - -export async function getAllContainerLogs(): Promise { - const containers = await docker.listContainers({ all: true }); - - const logs = await Promise.all( - containers.map((c) => getContainerLogs(c.Id, c.Names[0].replace("/", ""))) - ); - - return logs - .flat() - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); -} diff --git a/src/lib/dockship/application.ts b/src/lib/dockship/application.ts new file mode 100644 index 0000000..9ac56ea --- /dev/null +++ b/src/lib/dockship/application.ts @@ -0,0 +1,88 @@ +"use server"; + +import { verifySession } from "../../core/auth/session"; +import { getNodes } from "@/lib/dockship/node"; +import axios from "axios"; +import { Env, EnvVariable, Application } from "@/lib/types"; + +export async function installApp(name: string, repo: string, nodeId: string) { + if (await verifySession()) { + throw new Error("Unauthorized"); + } + + const nodes = await getNodes(); + + const targetNode = nodes.find((node) => node.node_id === nodeId); + + 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 { + 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(); +} + +export async function getEnv(): Promise> { + const nodes = await getNodes(); + + const envRecords = await Promise.all( + nodes.map(async (n) => { + const response = await axios.get(`http://${n.ip}:3000/api/env`, { + headers: { Authorization: `ApiKey ${n.key}` }, + }); + return response.data as Record; + }) + ); + + const merged: Record = Object.assign({}, ...envRecords); + + return merged; +} + +export async function setEnv( + appId: string, + variables: Record +) { + const nodes = await getNodes(); + + nodes.forEach(async (n) => { + await axios.post( + `http://${n.ip}:3000/api/env`, + { appId, variables }, + { + headers: { Authorization: `ApiKey ${n.key}` }, + } + ); + }); +} + +export async function removeApp(appId: string) { + const nodes = await getNodes(); + + nodes.forEach(async (n) => { + await axios.delete(`http://${n.ip}:3000/api/applications`, { + params: { appId }, + headers: { Authorization: `ApiKey ${n.key}` }, + }); + }); +} diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts new file mode 100644 index 0000000..0f6155e --- /dev/null +++ b/src/lib/dockship/deploy.ts @@ -0,0 +1,44 @@ +"use server"; + +import { Deployment } from "@/lib/types"; +import { getNodes } from "./node"; +import axios from "axios"; + +export async function getDeployments(): Promise<(null | Deployment)[]> { + const nodes = await getNodes(); + + return ( + await Promise.all( + nodes.map( + async (n) => + await ( + await axios.get(`http://${n.ip}:3000/api/deploy`, { + headers: { Authorization: `ApiKey ${n.key}` }, + }) + ).data.applications + ) + ) + ).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; +} diff --git a/src/lib/dockship/docker.ts b/src/lib/dockship/docker.ts new file mode 100644 index 0000000..a827927 --- /dev/null +++ b/src/lib/dockship/docker.ts @@ -0,0 +1,110 @@ +"use server"; + +import axios from "axios"; +import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types"; +import { verifySession } from "../../core/auth/session"; +import { getNodes } from "./node"; + +export async function getDocker(nodes: Node[]): Promise { + if (await verifySession()) return []; + + return await Promise.all( + nodes.map(async (node) => { + const data = await ( + await axios.get(`http://${node.ip}:3000/api/container/`, { + headers: { Authorization: `ApiKey ${node.key}` }, + }) + ).data; + + return { ...node, containers: data.containers, version: data.version }; + }) + ); +} + +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?.stats + ) + ) + ).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 + ); +} + +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 + ); +} + +export async function getAllContainerLogs(): Promise { + const nodes = await getNodes(); + + return ( + await Promise.all( + nodes.map( + async (n) => + await ( + await axios.get(`http://${n.ip}:3000/api/logs`, { + headers: { Authorization: `ApiKey ${n.key}` }, + }) + ).data + ) + ) + ) + .flat() + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); +} diff --git a/src/core/metrics.ts b/src/lib/dockship/metrics.ts similarity index 100% rename from src/core/metrics.ts rename to src/lib/dockship/metrics.ts diff --git a/src/core/node.ts b/src/lib/dockship/node.ts similarity index 95% rename from src/core/node.ts rename to src/lib/dockship/node.ts index 98ab7a6..17229e0 100644 --- a/src/core/node.ts +++ b/src/lib/dockship/node.ts @@ -3,7 +3,7 @@ import { read, write } from "@/app/api/file"; import { Node, NodeLiveData } from "@/lib/types"; import axios from "axios"; -import { verifySession } from "./auth/session"; +import { verifySession } from "../../core/auth/session"; export async function getNodes(): Promise { if (await verifySession()) return []; diff --git a/src/core/auth/redirect.ts b/src/lib/dockship/redirect.ts similarity index 100% rename from src/core/auth/redirect.ts rename to src/lib/dockship/redirect.ts diff --git a/src/lib/types.ts b/src/lib/types.ts index 776f1c5..0691c4a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -79,7 +79,6 @@ export interface Log { } export interface EnvVariable { - key: string; value: string; secret: boolean; } @@ -87,5 +86,5 @@ export interface EnvVariable { export interface Env { id: string; name: string; - variables: EnvVariable[]; + variables: Record; }