Merge pull request #11 from orus-dev/mvp-deployment

MVP deployment
This commit is contained in:
Klesti Selimaj
2026-01-03 00:27:51 +01:00
committed by GitHub
35 changed files with 1046 additions and 650 deletions
+28
View File
@@ -32,6 +32,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"os-name": "^6.1.0", "os-name": "^6.1.0",
"p-limit": "^7.2.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^2.15.4", "recharts": "^2.15.4",
@@ -4517,6 +4518,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5348,6 +5364,18 @@
"engines": { "engines": {
"node": ">=12" "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"
}
} }
} }
} }
+1
View File
@@ -32,6 +32,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"os-name": "^6.1.0", "os-name": "^6.1.0",
"p-limit": "^7.2.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^2.15.4", "recharts": "^2.15.4",
+1 -5
View File
@@ -1,11 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { import { installApp, getApplications, removeApp } from "@/core/application";
installApp,
getApplications,
removeApp,
} from "@/core/server/application";
export async function GET(req: NextRequest): Promise<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { getDocker, removeContainer } from "@/core/server/docker"; import { getDocker, removeContainer } from "@/core/docker";
import { Docker } from "@/lib/types"; import { Docker } from "@/lib/types";
export async function GET(req: NextRequest): Promise<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../../auth"; import testAuth from "../../auth";
import { StatusCodes } from "http-status-codes"; 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<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../../auth"; import testAuth from "../../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { getContainerStats } from "@/core/server/docker"; import { getContainerStats } from "@/core/docker";
export async function GET(req: NextRequest): Promise<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
+51
View File
@@ -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<NextResponse> {
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<NextResponse> {
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,
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; 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<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { getAllContainerLogs } from "@/core/server/docker"; import { getAllContainerLogs } from "@/core/docker";
export async function GET(req: NextRequest): Promise<NextResponse> { export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req); const auth = await testAuth(req);
+7 -4
View File
@@ -14,9 +14,10 @@ import { Button } from "@/components/ui/button";
import { MoreVertical, Plus, Rocket } from "lucide-react"; import { MoreVertical, Plus, Rocket } from "lucide-react";
import InstallApplicationDialog from "@/components/dialogs/InstallApplication"; import InstallApplicationDialog from "@/components/dialogs/InstallApplication";
import { useEffect, useState } from "react"; 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 RemoveDialog from "@/components/dialogs/Remove";
import { Application } from "@/lib/types"; import { Application } from "@/lib/types";
import DeployAppDialog from "@/components/dialogs/DeployApp";
export default function ApplicationsPage() { export default function ApplicationsPage() {
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
@@ -75,9 +76,11 @@ export default function ApplicationsPage() {
</CardContent> </CardContent>
<CardFooter className="justify-end"> <CardFooter className="justify-end">
<Button variant="ghost" size="icon-sm" aria-label="Deploy"> <DeployAppDialog defaultApp={app.id}>
<Rocket className="w-3 h-3" /> <Button variant="ghost" size="icon-sm" aria-label="Deploy">
</Button> <Rocket className="w-3 h-3" />
</Button>
</DeployAppDialog>
<RemoveDialog remove={() => remove(app.id)} /> <RemoveDialog remove={() => remove(app.id)} />
+126 -85
View File
@@ -1,118 +1,159 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react";
Rocket,
RotateCcw,
Eye,
MoreVertical,
Plus,
GitBranch,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Application, Deployment } from "@/lib/types"; import { Application, Deployment } from "@/lib/types";
import { getDeployments } from "@/core/server/deployment"; import { getDeployments } from "@/lib/dockship/deploy";
import { getApplications } from "@/core/application"; import { getApplications } from "@/lib/dockship/application";
import { cn } from "@/lib/utils"; 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() { export default function DeploymentsPage() {
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]); const [deployments, setDeployments] = useState<Deployment[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetch = async () => { let mounted = true;
const apps = await getApplications();
setApplications(apps); async function fetchData() {
setDeployments((await getDeployments(apps)).filter((d) => d !== null)); 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<string, string>();
for (const app of applications) {
for (const container of app.deployments) {
map.set(container, app.name);
}
}
return map;
}, [applications]);
return ( return (
<DashboardLayout <DashboardLayout
title="Deployments" title="Deployments"
subtitle="Deployment history and management" subtitle="Deployment history and management"
> >
{/* Header */} {/* Header */}
<div className="flex flex-row gap-3 items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Badge>{deployments.length} deployments</Badge> <Badge>
{loading ? "Loading…" : `${deployments.length} deployments`}
</Badge>
<Button size="sm" className="gap-2 self-auto"> <DeployAppDialog>
<Plus className="w-4 h-4" /> <Button size="sm" className="gap-2">
New Deployment <Plus className="w-4 h-4" />
</Button> New Deployment
</Button>
</DeployAppDialog>
</div> </div>
<div className="hidden md:visible space-y-3"> {/* Desktop */}
{deployments.map((dep) => ( <div className="hidden md:block space-y-3">
<div {deployments.map((dep) => {
key={dep.image} const appName =
className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0" appNameByContainer.get(dep.container) ?? "Unknown app";
>
<div className="flex items-center gap-3">
<span
className={cn(
"size-2 rounded-full",
(dep.status === "running" && "bg-chart-2") ||
(dep.status === "pending" && "bg-chart-1") ||
"bg-slate-400 dark:bg-slate-600"
)}
/>
<div>
<div className="font-mono text-sm">
{
applications.find((app) =>
app.deployments.includes(dep.image)
)?.name
}
</div>
<div className="text-xs text-muted-foreground">
{/* {dep.version} */}
</div>
</div>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1 sm:mt-0">
{/* <Clock className="w-3 h-3" /> {dep.time} */}
</div>
</div>
))}
</div>
{/* Mobile cards */} return (
<div className="md:hidden divide-y divide-border"> <div
{deployments.map((dep) => ( key={`${dep.image}-${dep.container ?? "latest"}`}
<div key={dep.image} className="p-4 space-y-3"> className="flex items-center justify-between py-2 border-b border-border last:border-0"
<div className="flex items-center justify-between"> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Rocket className="w-4 h-4 text-muted-foreground" /> <span
<span className="font-mono text-sm">{dep.image}</span> className={cn(
</div> "size-2 rounded-full",
{dep.status} statusDot[dep.status as keyof typeof statusDot] ??
</div> statusDot.stopped
)}
{/* <div className="grid grid-cols-2 gap-2 text-xs"> />
<div> <div>
<span className="text-muted-foreground">By</span> <div className="font-mono text-sm">{appName}</div>
<div className="truncate">{dep.deployedBy}</div> <div className="text-xs text-muted-foreground">
{dep.image}
</div> </div>
</div> */} </div>
</div>
<div className="flex justify-end gap-1 pt-2"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm"> <Button variant="ghost" size="icon-sm">
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
</Button> </Button>
<Button variant="ghost" size="icon-sm"> <Button variant="ghost" size="icon-sm">
<RotateCcw className="w-3 h-3" /> <RotateCcw className="w-3 h-3" />
</Button> </Button>
<Button variant="ghost" size="icon-sm"> <Button variant="ghost" size="icon-sm">
<MoreVertical className="w-3 h-3" /> <MoreVertical className="w-3 h-3" />
</Button> </Button>
</div>
</div> </div>
</div> );
))} })}
</div>
{/* Mobile */}
<div className="md:hidden divide-y divide-border">
{deployments.map((dep) => {
const appName =
appNameByContainer.get(dep.container) ?? "Unknown app";
return (
<div key={`${dep.container}-mobile`} className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Rocket className="w-4 h-4 text-muted-foreground" />
<span className="font-mono text-sm">{appName}</span>
</div>
<span className="text-xs text-muted-foreground">
{dep.status}
</span>
</div>
<div className="flex justify-end gap-1 pt-2">
<Button variant="ghost" size="icon-sm">
<Eye className="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon-sm">
<RotateCcw className="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon-sm">
<MoreVertical className="w-3 h-3" />
</Button>
</div>
</div>
);
})}
</div> </div>
</DashboardLayout> </DashboardLayout>
); );
+55 -39
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Trash2, Eye, EyeOff, Copy, Save, Lock, Loader2 } from "lucide-react"; import { Trash2, Eye, EyeOff, Copy, Save, Lock, Loader2 } from "lucide-react";
import { Env } from "@/lib/types"; 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 { AddVariableDialog } from "@/components/dialogs/AddVariable";
import { import {
DropdownMenu, DropdownMenu,
@@ -33,16 +33,6 @@ export default function EnvironmentPage() {
fetch(); 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 () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
@@ -54,6 +44,12 @@ export default function EnvironmentPage() {
} }
}; };
const currentGroup = envGroups[selectedApp] || {
id: "",
name: "",
variables: {},
};
return ( return (
<DashboardLayout <DashboardLayout
title="Environment" title="Environment"
@@ -90,8 +86,12 @@ export default function EnvironmentPage() {
</DropdownMenu> </DropdownMenu>
</CardTitle> </CardTitle>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{currentGroup?.variables.length} variables •{" "} {Object.keys(currentGroup?.variables).length} variables •{" "}
{currentGroup?.variables.filter((v) => v.secret).length} secrets {
Object.values(currentGroup?.variables).filter((v) => v.secret)
.length
}{" "}
secrets
</p> </p>
</div> </div>
@@ -129,9 +129,9 @@ export default function EnvironmentPage() {
{/* Rows */} {/* Rows */}
<div className="divide-y divide-border md:divide-none"> <div className="divide-y divide-border md:divide-none">
{currentGroup?.variables.map((variable, i) => ( {Object.entries(currentGroup?.variables).map(([key, variable]) => (
<div <div
key={variable.key} key={key}
className=" className="
py-4 md:py-3 py-4 md:py-3
flex flex-col gap-3 flex flex-col gap-3
@@ -141,23 +141,33 @@ export default function EnvironmentPage() {
{/* Key */} {/* Key */}
<div className="md:col-span-4 flex items-center gap-2"> <div className="md:col-span-4 flex items-center gap-2">
{variable.secret && <Lock className="w-3 h-3 text-warning" />} {variable.secret && <Lock className="w-3 h-3 text-warning" />}
<span className="font-mono text-sm">{variable.key}</span> <span className="font-mono text-sm">{key}</span>
</div> </div>
{/* Value */} {/* Value */}
<div className="md:col-span-6"> <div className="md:col-span-6">
<Input <Input
type={ type={
variable.secret && !showSecrets[variable.key] variable.secret && !showSecrets[key] ? "password" : "text"
? "password"
: "text"
} }
value={ value={variable.value}
variable.secret && !showSecrets[variable.key] onChange={(e) => {
? maskValue(variable.value) const newValue = e.target.value;
: variable.value
} setEnvGroups((prev) => ({
readOnly ...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" className="font-mono text-xs h-8 bg-background border-border"
/> />
</div> </div>
@@ -168,9 +178,14 @@ export default function EnvironmentPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => toggleSecret(variable.key)} onClick={() =>
setShowSecrets((prev) => ({
...prev,
[key]: !prev[key],
}))
}
> >
{showSecrets[variable.key] ? ( {showSecrets[key] ? (
<EyeOff className="w-3 h-3" /> <EyeOff className="w-3 h-3" />
) : ( ) : (
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
@@ -182,9 +197,7 @@ export default function EnvironmentPage() {
size="icon-sm" size="icon-sm"
className="hover:text-primary" className="hover:text-primary"
onClick={() => onClick={() =>
navigator.clipboard.writeText( navigator.clipboard.writeText(`${key}=${variable.value}`)
`${variable.key}=${variable.value}`
)
} }
> >
<Copy className="w-3 h-3" /> <Copy className="w-3 h-3" />
@@ -194,15 +207,18 @@ export default function EnvironmentPage() {
size="icon-sm" size="icon-sm"
className="hover:text-destructive" className="hover:text-destructive"
onClick={() => { onClick={() => {
setEnvGroups((env) => ({ setEnvGroups((prev) => {
...env, const { [key]: _, ...restVars } =
[selectedApp]: { prev[selectedApp].variables;
...env[selectedApp],
variables: env[selectedApp].variables.filter( return {
(_, iv) => iv !== i ...prev,
), [selectedApp]: {
}, ...prev[selectedApp],
})); variables: restVars,
},
};
});
}} }}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
+1 -1
View File
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Pause, Play, Download, Filter, RefreshCw } from "lucide-react"; import { Pause, Play, Download, Filter, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Log } from "@/lib/types"; import { Log } from "@/lib/types";
import { getAllContainerLogs } from "@/core/docker"; import { getAllContainerLogs } from "@/lib/dockship/docker";
export default function LogsPage() { export default function LogsPage() {
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
+3 -3
View File
@@ -5,13 +5,13 @@ import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig } from "@/components/ui/chart"; import { type ChartConfig } from "@/components/ui/chart";
import GradientAreaChart from "@/components/GradientAreaChart"; import GradientAreaChart from "@/components/GradientAreaChart";
import { getMetrics } from "@/core/metrics"; import { getMetrics } from "@/lib/dockship/metrics";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import RadialChart from "@/components/RadialChart"; import RadialChart from "@/components/RadialChart";
import { getLiveNodes, getNodes } from "@/core/node"; import { getLiveNodes, getNodes } from "@/lib/dockship/node";
import { average } from "@/lib/format"; import { average } from "@/lib/format";
import { getContainerStats, getDocker } from "@/core/docker"; import { getContainerStats, getDocker } from "@/lib/dockship/docker";
import { SimpleStats } from "@/lib/types"; import { SimpleStats } from "@/lib/types";
const cpuChartConfig = { const cpuChartConfig = {
+3 -3
View File
@@ -8,11 +8,11 @@ import { Server, Plus, Settings } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Docker, Node, NodeLiveData } from "@/lib/types"; import { Docker, Node, NodeLiveData } from "@/lib/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getLiveNodes } from "@/core/node"; import { getLiveNodes } from "@/lib/dockship/node";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import { setNodes as updateNodes } from "@/core/node"; import { setNodes as updateNodes } from "@/lib/dockship/node";
import AddNode from "@/components/dialogs/AddNode"; import AddNode from "@/components/dialogs/AddNode";
import { getDocker } from "@/core/docker"; import { getDocker } from "@/lib/dockship/docker";
import RemoveDialog from "@/components/dialogs/Remove"; import RemoveDialog from "@/components/dialogs/Remove";
function DiskUsage({ liveData }: NodeLiveData) { function DiskUsage({ liveData }: NodeLiveData) {
+32 -42
View File
@@ -16,48 +16,25 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import Link from "next/link"; import Link from "next/link";
import { getLiveNodes } from "@/core/node"; import { getLiveNodes } from "@/lib/dockship/node";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Application, Docker, Node, NodeLiveData } from "@/lib/types"; import {
import { getDocker } from "@/core/docker"; Application,
import { getApplications } from "@/core/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"; import { cn } from "@/lib/utils";
import { getDeployments } from "@/lib/dockship/deploy";
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",
},
];
export default function OverviewPage() { export default function OverviewPage() {
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]); const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
const [apps, setApplications] = useState<Application[]>([]); const [apps, setApplications] = useState<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]);
useEffect(() => { useEffect(() => {
const fetchNodes = async () => { const fetchNodes = async () => {
@@ -65,6 +42,7 @@ export default function OverviewPage() {
setNodes(nodes); setNodes(nodes);
setDockerNodes(await getDocker(nodes)); setDockerNodes(await getDocker(nodes));
setApplications(await getApplications()); setApplications(await getApplications());
setDeployments((await getDeployments()).filter((d) => d !== null));
}; };
fetchNodes(); fetchNodes();
const interval = setInterval(fetchNodes, 3000); const interval = setInterval(fetchNodes, 3000);
@@ -72,6 +50,16 @@ export default function OverviewPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const appNameByContainer = useMemo(() => {
const map = new Map<string, string>();
for (const app of apps) {
for (const container of app.deployments) {
map.set(container, app.name);
}
}
return map;
}, [apps]);
return ( return (
<DashboardLayout title="Overview" subtitle="System status and metrics"> <DashboardLayout title="Overview" subtitle="System status and metrics">
{/* Stats Grid */} {/* Stats Grid */}
@@ -137,7 +125,7 @@ export default function OverviewPage() {
) : ( ) : (
apps.slice(-5).map((app) => ( apps.slice(-5).map((app) => (
<div <div
key={app.name} key={app.id}
className=" className="
grid grid-cols-12 gap-4 py-3 items-center grid grid-cols-12 gap-4 py-3 items-center
text-sm text-sm
@@ -176,9 +164,9 @@ export default function OverviewPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{recentDeployments.map((dep) => ( {deployments.slice(-5).map((dep) => (
<div <div
key={dep.id} key={dep.container}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0" className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -191,14 +179,16 @@ export default function OverviewPage() {
)} )}
/> />
<div> <div>
<div className="font-mono text-sm">{dep.app}</div> <div className="font-mono text-sm">
{appNameByContainer.get(dep.container)}
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{dep.version} {dep.image.slice(7, 19)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1 sm:mt-0"> <div className="flex items-center gap-1 text-xs text-muted-foreground mt-1 sm:mt-0">
<Clock className="w-3 h-3" /> {dep.time} {/* <Clock className="w-3 h-3" /> {dep.time} */}
</div> </div>
</div> </div>
))} ))}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import redirectAuth from "@/core/auth/redirect"; import redirectAuth from "@/lib/dockship/redirect";
import { GithubIcon } from "lucide-react"; import { GithubIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
+1 -1
View File
@@ -18,7 +18,7 @@ import {
authNode, authNode,
getNodes, getNodes,
setNodes as updateNodes, setNodes as updateNodes,
} from "@/core/node"; } from "@/lib/dockship/node";
export default function AddNode({ children }: { children: ReactNode }) { export default function AddNode({ children }: { children: ReactNode }) {
const [step, setStep] = useState<1 | 2>(1); const [step, setStep] = useState<1 | 2>(1);
+11 -5
View File
@@ -34,12 +34,18 @@ export function AddVariableDialog({
const handleAdd = () => { const handleAdd = () => {
if (!key) return; if (!key) return;
const newVariable: EnvVariable = { key, value, secret };
setEnvGroups((env) => ({ setEnvGroups((prev) => ({
...env, ...prev,
[selectedApp]: { [selectedApp]: {
...currentGroup, ...prev[selectedApp],
variables: [...currentGroup.variables, newVariable], variables: {
...prev[selectedApp].variables,
[key]: {
value,
secret,
},
},
}, },
})); }));
+114
View File
@@ -0,0 +1,114 @@
"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({
defaultApp,
children,
}: {
defaultApp?: string;
children: ReactNode;
}) {
const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]);
const [apps, setApps] = useState<Application[]>([]);
const [name, setName] = useState("");
const [app, setApp] = useState(defaultApp || "");
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 (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>Deploy Application</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name">
Deployment name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My app"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="repo">
Application <span className="text-destructive">*</span>
</Label>
<Combobox
value={app}
setValue={setApp}
data={apps.map((app) => ({
label: app.name,
value: app.id,
}))}
placeholder="Select App..."
listPlaceholder="No apps found :("
/>
</div>
<div className="grid gap-3">
<Label htmlFor="node">
Node <span className="text-destructive">*</span>
</Label>
<Combobox
value={node}
setValue={setNode}
data={nodeList
.filter((node) => node.liveData)
.map((node) => ({ label: node.name, value: node.node_id }))}
placeholder="Select Node..."
listPlaceholder="No nodes found :("
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={deploy}>Deploy</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -13,10 +13,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getLiveNodes } from "@/core/node"; import { getLiveNodes } from "@/lib/dockship/node";
import { Combobox } from "../ui/combobox"; import { Combobox } from "../ui/combobox";
import { Node, NodeLiveData } from "@/lib/types"; import { Node, NodeLiveData } from "@/lib/types";
import { installApp } from "@/core/application"; import { installApp } from "@/lib/dockship/application";
export default function InstallApplicationDialog({ export default function InstallApplicationDialog({
children, children,
+106 -63
View File
@@ -1,85 +1,128 @@
"use server"; "use server";
import { verifySession } from "./auth/session"; import Docker from "dockerode";
import { getNodes } from "./node"; import { randomUUID } from "crypto";
import axios from "axios"; import fs from "fs";
import { Env, EnvVariable, Application } from "@/lib/types"; 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) { const docker = new Docker();
if (await verifySession()) { const execAsync = util.promisify(exec);
throw new Error("Unauthorized"); const DATA_DIR = path.join(process.cwd(), "data", "apps");
}
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<Application[]> { export async function getApplications(): Promise<Application[]> {
const nodes = await getNodes(); return fs
.readdirSync(DATA_DIR)
return ( .map((appId) =>
await Promise.all( JSON.parse(
nodes.map( fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
async (n) =>
await (
await axios.get(`http://${n.ip}:3000/api/applications`, {
headers: { Authorization: `ApiKey ${n.key}` },
})
).data.applications
) )
) );
).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<Record<string, Env>> { export async function getEnv(): Promise<Record<string, Env>> {
const nodes = await getNodes(); const apps = fs.readdirSync(DATA_DIR);
const envRecords = await Promise.all( return apps.reduce((acc, appId) => {
nodes.map(async (n) => { const appPath = path.join(DATA_DIR, appId, "app.json");
const response = await axios.get(`http://${n.ip}:3000/api/env`, { const name = JSON.parse(fs.readFileSync(appPath).toString()).name;
headers: { Authorization: `ApiKey ${n.key}` },
});
return response.data as Record<string, Env>;
})
);
const merged: Record<string, Env> = Object.assign({}, ...envRecords); const envPath = path.join(DATA_DIR, appId, "env.json");
if (fs.existsSync(envPath)) {
return merged; const envData: Record<string, EnvVariable> = JSON.parse(
fs.readFileSync(envPath, "utf-8")
);
acc[appId] = { variables: envData, id: appId, name };
}
return acc;
}, {} as Record<string, Env>);
} }
export async function setEnv(appId: string, variables: EnvVariable[]) { export async function setEnv(appId: string, variables: EnvVariable[]) {
const nodes = await getNodes(); const apps = fs.readdirSync(DATA_DIR);
nodes.forEach(async (n) => { apps.forEach((id) => {
await axios.post( if (id === appId) {
`http://${n.ip}:3000/api/env`, const envPath = path.join(DATA_DIR, appId, "env.json");
{ appId, variables },
{ fs.writeFileSync(envPath, JSON.stringify(variables));
headers: { Authorization: `ApiKey ${n.key}` }, }
}
);
}); });
} }
export async function removeApp(appId: string) { export async function removeApp(appId: string) {
const nodes = await getNodes(); const appPath = path.join(DATA_DIR, appId);
nodes.forEach(async (n) => { const appMeta: Application = JSON.parse(
await axios.delete(`http://${n.ip}:3000/api/applications`, { fs.readFileSync(path.join(appPath, "app.json")).toString()
params: { appId }, );
headers: { Authorization: `ApiKey ${n.key}` },
}); 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 });
} }
+24 -11
View File
@@ -1,13 +1,14 @@
"use server"; "use server";
import { read, write } from "@/app/api/file"; import { read, write } from "@/app/api/file";
import axios from "axios"; import axios from "axios";
import { createHash } from "crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
type Users = Record<string, { roles: string[] }>; type Users = Record<string, { roles: string[] }>;
export async function getSession(): Promise< export async function getSession(): Promise<
undefined | { user: { id: number }; accessToken: string } undefined | { userId: string; accessToken: string }
> { > {
const c = await cookies(); const c = await cookies();
const accessToken = c.get("github_access")?.value; const accessToken = c.get("github_access")?.value;
@@ -16,22 +17,32 @@ export async function getSession(): Promise<
redirect("/"); redirect("/");
} }
// Test the token const tokens: Record<string, string> = await read(
try { () => ({}),
const user = ( "data",
await axios.get("https://api.github.com/user", { "sessions.json"
headers: { Authorization: `Bearer ${accessToken}` }, );
})
).data;
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( const users = Object.keys(
await read<Users>(() => ({}), "data", "users.json") await read<Users>(() => ({}), "data", "users.json")
); );
if (!users.includes(String(user.id))) { if (!users.includes(String(userId))) {
if (users.length === 0) { if (users.length === 0) {
await write( 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", "data",
"users.json" "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 { } catch {
redirect("/"); redirect("/");
} }
+144
View File
@@ -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<Deployment[]> {
// 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<Deployment | null> => {
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<number, number>();
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<string | undefined> {
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<void>((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;
}
}
+86 -84
View File
@@ -1,110 +1,112 @@
"use server"; "use server";
import axios from "axios"; import { CPUusage } from "@/lib/server/calc";
import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types"; import { Log, SimpleStats } from "@/lib/types";
import { verifySession } from "./auth/session"; import Docker from "dockerode";
import { getNodes } from "./node";
export async function getDocker(nodes: Node[]): Promise<Docker[]> { const docker = new Docker();
if (await verifySession()) return [];
return await Promise.all( export async function getDocker() {
nodes.map(async (node) => { const version = await docker.version();
const data = await ( const containers = await docker.listContainers({ all: true });
await axios.get(`http://${node.ip}:3000/api/container/`, { return { version, containers };
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
return { ...node, containers: data.containers, version: data.version };
})
);
} }
export async function getContainerStats( export async function getContainerStats(
containerId: string containerId: string
): Promise<SimpleStats | undefined> { ): Promise<SimpleStats | undefined> {
const nodes = await getNodes(); const container = docker.getContainer(containerId);
return ( const data = await container.inspect();
await Promise.all(
nodes.map( if (!data.State.Running) {
async (n) => return;
await ( }
await axios.get(`http://${n.ip}:3000/api/container/stats`, {
headers: { Authorization: `ApiKey ${n.key}` }, const stats = await container.stats({ stream: false });
params: { containerId },
}) return {
).data?.stats cpu: CPUusage(stats),
) memory: (stats.memory_stats.usage / stats.memory_stats.limit) * 100,
) };
).find((s) => s);
} }
export async function startContainer(containerId: string) { export async function startContainer(containerId: string) {
const nodes = await getNodes(); await docker.getContainer(containerId).start();
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) { export async function stopContainer(containerId: string) {
const nodes = await getNodes(); await docker.getContainer(containerId).stop();
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) { export async function removeContainer(containerId: string) {
const nodes = await getNodes(); await docker.getContainer(containerId).remove();
}
nodes.forEach( function inferLevel(message: string): "info" | "warn" | "error" {
async (n) => const msg = message.toLowerCase();
await (
await axios.delete(`http://${n.ip}:3000/api/container`, { if (msg.includes("error") || msg.includes("fatal")) return "error";
headers: { Authorization: `ApiKey ${n.key}` }, if (msg.includes("warn")) return "warn";
params: { containerId },
}) return "info";
).data }
);
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<Log[]> {
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<Log[]> { export async function getAllContainerLogs(): Promise<Log[]> {
const nodes = await getNodes(); const containers = await docker.listContainers({ all: true });
return ( const logs = await Promise.all(
await Promise.all( containers.map((c) => getContainerLogs(c.Id, c.Names[0].replace("/", "")))
nodes.map( );
async (n) =>
await ( return logs
await axios.get(`http://${n.ip}:3000/api/logs`, {
headers: { Authorization: `ApiKey ${n.key}` },
})
).data
)
)
)
.flat() .flat()
.sort( .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
} }
-128
View File
@@ -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<Application[]> {
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<Record<string, Env>> {
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<string, Env>);
}
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 });
}
-53
View File
@@ -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],
};
})
);
}
-112
View File
@@ -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<SimpleStats | undefined> {
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<Log[]> {
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<Log[]> {
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));
}
+88
View File
@@ -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<Application[]> {
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<Record<string, Env>> {
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<string, Env>;
})
);
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
return merged;
}
export async function setEnv(
appId: string,
variables: Record<string, EnvVariable>
) {
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}` },
});
});
}
+44
View File
@@ -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<string | undefined> {
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;
}
+110
View File
@@ -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<Docker[]> {
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<SimpleStats | undefined> {
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<Log[]> {
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()
);
}
@@ -3,7 +3,7 @@
import { read, write } from "@/app/api/file"; import { read, write } from "@/app/api/file";
import { Node, NodeLiveData } from "@/lib/types"; import { Node, NodeLiveData } from "@/lib/types";
import axios from "axios"; import axios from "axios";
import { verifySession } from "./auth/session"; import { verifySession } from "../../core/auth/session";
export async function getNodes(): Promise<Node[]> { export async function getNodes(): Promise<Node[]> {
if (await verifySession()) return []; if (await verifySession()) return [];
+1 -2
View File
@@ -79,7 +79,6 @@ export interface Log {
} }
export interface EnvVariable { export interface EnvVariable {
key: string;
value: string; value: string;
secret: boolean; secret: boolean;
} }
@@ -87,5 +86,5 @@ export interface EnvVariable {
export interface Env { export interface Env {
id: string; id: string;
name: string; name: string;
variables: EnvVariable[]; variables: Record<string, EnvVariable>;
} }