Generated
+28
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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,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,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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -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,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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<DeployAppDialog defaultApp={app.id}>
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="Deploy">
|
<Button variant="ghost" size="icon-sm" aria-label="Deploy">
|
||||||
<Rocket className="w-3 h-3" />
|
<Rocket className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DeployAppDialog>
|
||||||
|
|
||||||
<RemoveDialog remove={() => remove(app.id)} />
|
<RemoveDialog remove={() => remove(app.id)} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +1,145 @@
|
|||||||
"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();
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [apps, deps] = await Promise.all([
|
||||||
|
getApplications(),
|
||||||
|
getDeployments(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
setApplications(apps);
|
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 (
|
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>
|
||||||
|
<Button size="sm" className="gap-2">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
New Deployment
|
New Deployment
|
||||||
</Button>
|
</Button>
|
||||||
|
</DeployAppDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:visible space-y-3">
|
{/* Desktop */}
|
||||||
{deployments.map((dep) => (
|
<div className="hidden md:block space-y-3">
|
||||||
|
{deployments.map((dep) => {
|
||||||
|
const appName =
|
||||||
|
appNameByContainer.get(dep.container) ?? "Unknown app";
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={dep.image}
|
key={`${dep.image}-${dep.container ?? "latest"}`}
|
||||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0"
|
className="flex items-center 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">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-2 rounded-full",
|
"size-2 rounded-full",
|
||||||
(dep.status === "running" && "bg-chart-2") ||
|
statusDot[dep.status as keyof typeof statusDot] ??
|
||||||
(dep.status === "pending" && "bg-chart-1") ||
|
statusDot.stopped
|
||||||
"bg-slate-400 dark:bg-slate-600"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">{appName}</div>
|
||||||
{
|
|
||||||
applications.find((app) =>
|
|
||||||
app.deployments.includes(dep.image)
|
|
||||||
)?.name
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{/* {dep.version} */}
|
{dep.image}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="md:hidden divide-y divide-border">
|
||||||
{deployments.map((dep) => (
|
{deployments.map((dep) => {
|
||||||
<div key={dep.image} className="p-4 space-y-3">
|
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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Rocket className="w-4 h-4 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
{dep.status}
|
{dep.status}
|
||||||
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end gap-1 pt-2">
|
||||||
<Button variant="ghost" size="icon-sm">
|
<Button variant="ghost" size="icon-sm">
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
@@ -112,7 +152,8 @@ export default function DeploymentsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 } =
|
||||||
|
prev[selectedApp].variables;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
[selectedApp]: {
|
[selectedApp]: {
|
||||||
...env[selectedApp],
|
...prev[selectedApp],
|
||||||
variables: env[selectedApp].variables.filter(
|
variables: restVars,
|
||||||
(_, iv) => iv !== i
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+104
-61
@@ -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)
|
||||||
|
.map((appId) =>
|
||||||
|
JSON.parse(
|
||||||
|
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export async function installApp(name: string, repo: string, nodeId: string) {
|
||||||
await Promise.all(
|
// Create app ID
|
||||||
nodes.map(
|
const appId = `app_${randomUUID()}`;
|
||||||
async (n) =>
|
const appDir = path.join(DATA_DIR, appId);
|
||||||
await (
|
const repoDir = path.join(appDir, "repo");
|
||||||
await axios.get(`http://${n.ip}:3000/api/applications`, {
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
// Create directories
|
||||||
})
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
).data.applications
|
|
||||||
)
|
// Write app.json
|
||||||
)
|
const appConfig: Application = {
|
||||||
).flat();
|
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}` },
|
|
||||||
});
|
const envPath = path.join(DATA_DIR, appId, "env.json");
|
||||||
return response.data as Record<string, Env>;
|
if (fs.existsSync(envPath)) {
|
||||||
})
|
const envData: Record<string, EnvVariable> = JSON.parse(
|
||||||
|
fs.readFileSync(envPath, "utf-8")
|
||||||
);
|
);
|
||||||
|
acc[appId] = { variables: envData, id: appId, name };
|
||||||
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
|
}
|
||||||
|
return acc;
|
||||||
return merged;
|
}, {} 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
() => ({}),
|
||||||
|
"data",
|
||||||
|
"sessions.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenHash = createHash("sha256").update(accessToken).digest("hex");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = (
|
const userId: string =
|
||||||
|
tokens[tokenHash] ||
|
||||||
|
(
|
||||||
await axios.get("https://api.github.com/user", {
|
await axios.get("https://api.github.com/user", {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
})
|
})
|
||||||
).data;
|
).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("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
|
||||||
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()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return logs
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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],
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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}` },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user