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",
"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"
}
}
}
}
+1
View File
@@ -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",
+1 -5
View File
@@ -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<NextResponse> {
const auth = await testAuth(req);
+1 -1
View File
@@ -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<NextResponse> {
+1 -1
View File
@@ -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<NextResponse> {
const auth = await testAuth(req);
+1 -1
View File
@@ -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<NextResponse> {
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 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<NextResponse> {
const auth = await testAuth(req);
+1 -1
View File
@@ -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<NextResponse> {
const auth = await testAuth(req);
+4 -1
View File
@@ -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<Application[]>([]);
@@ -75,9 +76,11 @@ export default function ApplicationsPage() {
</CardContent>
<CardFooter className="justify-end">
<DeployAppDialog defaultApp={app.id}>
<Button variant="ghost" size="icon-sm" aria-label="Deploy">
<Rocket className="w-3 h-3" />
</Button>
</DeployAppDialog>
<RemoveDialog remove={() => remove(app.id)} />
+92 -51
View File
@@ -1,105 +1,145 @@
"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<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetch = async () => {
const apps = await getApplications();
let mounted = true;
async function fetchData() {
try {
const [apps, deps] = await Promise.all([
getApplications(),
getDeployments(),
]);
if (!mounted) return;
setApplications(apps);
setDeployments((await getDeployments(apps)).filter((d) => d !== null));
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 (
<DashboardLayout
title="Deployments"
subtitle="Deployment history and management"
>
{/* Header */}
<div className="flex flex-row gap-3 items-center justify-between mb-6">
<Badge>{deployments.length} deployments</Badge>
<div className="flex items-center justify-between mb-6">
<Badge>
{loading ? "Loading…" : `${deployments.length} deployments`}
</Badge>
<Button size="sm" className="gap-2 self-auto">
<DeployAppDialog>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" />
New Deployment
</Button>
</DeployAppDialog>
</div>
<div className="hidden md:visible space-y-3">
{deployments.map((dep) => (
{/* Desktop */}
<div className="hidden md:block space-y-3">
{deployments.map((dep) => {
const appName =
appNameByContainer.get(dep.container) ?? "Unknown app";
return (
<div
key={dep.image}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0"
key={`${dep.image}-${dep.container ?? "latest"}`}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<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"
statusDot[dep.status as keyof typeof statusDot] ??
statusDot.stopped
)}
/>
<div>
<div className="font-mono text-sm">
{
applications.find((app) =>
app.deployments.includes(dep.image)
)?.name
}
</div>
<div className="font-mono text-sm">{appName}</div>
<div className="text-xs text-muted-foreground">
{/* {dep.version} */}
{dep.image}
</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 */}
<div className="flex items-center gap-1">
<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>
{/* Mobile */}
<div className="md:hidden divide-y divide-border">
{deployments.map((dep) => (
<div key={dep.image} className="p-4 space-y-3">
{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">{dep.image}</span>
<span className="font-mono text-sm">{appName}</span>
</div>
<span className="text-xs text-muted-foreground">
{dep.status}
</span>
</div>
{/* <div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">By</span>
<div className="truncate">{dep.deployedBy}</div>
</div>
</div> */}
<div className="flex justify-end gap-1 pt-2">
<Button variant="ghost" size="icon-sm">
<Eye className="w-3 h-3" />
@@ -112,7 +152,8 @@ export default function DeploymentsPage() {
</Button>
</div>
</div>
))}
);
})}
</div>
</DashboardLayout>
);
+53 -37
View File
@@ -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 (
<DashboardLayout
title="Environment"
@@ -90,8 +86,12 @@ export default function EnvironmentPage() {
</DropdownMenu>
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{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
</p>
</div>
@@ -129,9 +129,9 @@ export default function EnvironmentPage() {
{/* Rows */}
<div className="divide-y divide-border md:divide-none">
{currentGroup?.variables.map((variable, i) => (
{Object.entries(currentGroup?.variables).map(([key, variable]) => (
<div
key={variable.key}
key={key}
className="
py-4 md:py-3
flex flex-col gap-3
@@ -141,23 +141,33 @@ export default function EnvironmentPage() {
{/* Key */}
<div className="md:col-span-4 flex items-center gap-2">
{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>
{/* Value */}
<div className="md:col-span-6">
<Input
type={
variable.secret && !showSecrets[variable.key]
? "password"
: "text"
variable.secret && !showSecrets[key] ? "password" : "text"
}
value={
variable.secret && !showSecrets[variable.key]
? maskValue(variable.value)
: variable.value
}
readOnly
value={variable.value}
onChange={(e) => {
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"
/>
</div>
@@ -168,9 +178,14 @@ export default function EnvironmentPage() {
<Button
variant="ghost"
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" />
) : (
<Eye className="w-3 h-3" />
@@ -182,9 +197,7 @@ export default function EnvironmentPage() {
size="icon-sm"
className="hover:text-primary"
onClick={() =>
navigator.clipboard.writeText(
`${variable.key}=${variable.value}`
)
navigator.clipboard.writeText(`${key}=${variable.value}`)
}
>
<Copy className="w-3 h-3" />
@@ -194,15 +207,18 @@ export default function EnvironmentPage() {
size="icon-sm"
className="hover:text-destructive"
onClick={() => {
setEnvGroups((env) => ({
...env,
setEnvGroups((prev) => {
const { [key]: _, ...restVars } =
prev[selectedApp].variables;
return {
...prev,
[selectedApp]: {
...env[selectedApp],
variables: env[selectedApp].variables.filter(
(_, iv) => iv !== i
),
...prev[selectedApp],
variables: restVars,
},
}));
};
});
}}
>
<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 { cn } from "@/lib/utils";
import { Log } from "@/lib/types";
import { getAllContainerLogs } from "@/core/docker";
import { getAllContainerLogs } from "@/lib/dockship/docker";
export default function LogsPage() {
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 { type ChartConfig } from "@/components/ui/chart";
import GradientAreaChart from "@/components/GradientAreaChart";
import { getMetrics } from "@/core/metrics";
import { getMetrics } from "@/lib/dockship/metrics";
import { useEffect, useState } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import RadialChart from "@/components/RadialChart";
import { getLiveNodes, getNodes } from "@/core/node";
import { getLiveNodes, getNodes } from "@/lib/dockship/node";
import { average } from "@/lib/format";
import { getContainerStats, getDocker } from "@/core/docker";
import { getContainerStats, getDocker } from "@/lib/dockship/docker";
import { SimpleStats } from "@/lib/types";
const cpuChartConfig = {
+3 -3
View File
@@ -8,11 +8,11 @@ import { Server, Plus, Settings } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { Docker, Node, NodeLiveData } from "@/lib/types";
import { useEffect, useState } from "react";
import { getLiveNodes } from "@/core/node";
import { getLiveNodes } from "@/lib/dockship/node";
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 { getDocker } from "@/core/docker";
import { getDocker } from "@/lib/dockship/docker";
import RemoveDialog from "@/components/dialogs/Remove";
function DiskUsage({ liveData }: NodeLiveData) {
+32 -42
View File
@@ -16,48 +16,25 @@ import {
} from "lucide-react";
import { Progress } from "@/components/ui/progress";
import Link from "next/link";
import { getLiveNodes } from "@/core/node";
import { useEffect, useState } from "react";
import { Application, Docker, Node, NodeLiveData } from "@/lib/types";
import { getDocker } from "@/core/docker";
import { getApplications } from "@/core/application";
import { getLiveNodes } from "@/lib/dockship/node";
import { useEffect, useMemo, useState } from "react";
import {
Application,
Deployment,
Docker,
Node,
NodeLiveData,
} from "@/lib/types";
import { getDocker } from "@/lib/dockship/docker";
import { getApplications } from "@/lib/dockship/application";
import { cn } from "@/lib/utils";
const recentDeployments = [
{
id: "dep-001",
app: "api-gateway",
version: "v2.4.1",
status: "running" as const,
time: "2m ago",
},
{
id: "dep-002",
app: "auth-service",
version: "v1.8.0",
status: "running" as const,
time: "15m ago",
},
{
id: "dep-003",
app: "worker-queue",
version: "v3.1.2",
status: "pending" as const,
time: "32m ago",
},
{
id: "dep-004",
app: "metrics-collector",
version: "v1.2.0",
status: "error" as const,
time: "1h ago",
},
];
import { getDeployments } from "@/lib/dockship/deploy";
export default function OverviewPage() {
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
const [apps, setApplications] = useState<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]);
useEffect(() => {
const fetchNodes = async () => {
@@ -65,6 +42,7 @@ export default function OverviewPage() {
setNodes(nodes);
setDockerNodes(await getDocker(nodes));
setApplications(await getApplications());
setDeployments((await getDeployments()).filter((d) => d !== null));
};
fetchNodes();
const interval = setInterval(fetchNodes, 3000);
@@ -72,6 +50,16 @@ export default function OverviewPage() {
return () => clearInterval(interval);
}, []);
const appNameByContainer = useMemo(() => {
const map = new Map<string, string>();
for (const app of apps) {
for (const container of app.deployments) {
map.set(container, app.name);
}
}
return map;
}, [apps]);
return (
<DashboardLayout title="Overview" subtitle="System status and metrics">
{/* Stats Grid */}
@@ -137,7 +125,7 @@ export default function OverviewPage() {
) : (
apps.slice(-5).map((app) => (
<div
key={app.name}
key={app.id}
className="
grid grid-cols-12 gap-4 py-3 items-center
text-sm
@@ -176,9 +164,9 @@ export default function OverviewPage() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{recentDeployments.map((dep) => (
{deployments.slice(-5).map((dep) => (
<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"
>
<div className="flex items-center gap-3">
@@ -191,14 +179,16 @@ export default function OverviewPage() {
)}
/>
<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">
{dep.version}
{dep.image.slice(7, 19)}
</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}
{/* <Clock className="w-3 h-3" /> {dep.time} */}
</div>
</div>
))}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
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 Image from "next/image";
+1 -1
View File
@@ -18,7 +18,7 @@ import {
authNode,
getNodes,
setNodes as updateNodes,
} from "@/core/node";
} from "@/lib/dockship/node";
export default function AddNode({ children }: { children: ReactNode }) {
const [step, setStep] = useState<1 | 2>(1);
+11 -5
View File
@@ -34,12 +34,18 @@ export function AddVariableDialog({
const handleAdd = () => {
if (!key) return;
const newVariable: EnvVariable = { key, value, secret };
setEnvGroups((env) => ({
...env,
setEnvGroups((prev) => ({
...prev,
[selectedApp]: {
...currentGroup,
variables: [...currentGroup.variables, newVariable],
...prev[selectedApp],
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";
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,
+104 -61
View File
@@ -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<Application[]> {
const nodes = await getNodes();
return fs
.readdirSync(DATA_DIR)
.map((appId) =>
JSON.parse(
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
)
);
}
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 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 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<string, Env>;
})
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: Record<string, EnvVariable> = JSON.parse(
fs.readFileSync(envPath, "utf-8")
);
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
return merged;
acc[appId] = { variables: envData, id: appId, name };
}
return acc;
}, {} as Record<string, Env>);
}
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 });
}
+20 -7
View File
@@ -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<string, { roles: string[] }>;
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
const tokens: Record<string, string> = await read(
() => ({}),
"data",
"sessions.json"
);
const tokenHash = createHash("sha256").update(accessToken).digest("hex");
try {
const user = (
const userId: string =
tokens[tokenHash] ||
(
await axios.get("https://api.github.com/user", {
headers: { Authorization: `Bearer ${accessToken}` },
})
).data;
).data.id;
// Test the token
const users = Object.keys(
await read<Users>(() => ({}), "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("/");
}
+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";
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<Docker[]> {
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<SimpleStats | undefined> {
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<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 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
)
)
)
.flat()
.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
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));
}
-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 { 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<Node[]> {
if (await verifySession()) return [];
+1 -2
View File
@@ -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<string, EnvVariable>;
}