+1
-1
@@ -40,4 +40,4 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
data
|
data
|
||||||
|
|||||||
Generated
+31
@@ -36,6 +36,7 @@
|
|||||||
"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",
|
||||||
|
"simple-git": "^3.30.0",
|
||||||
"simple-oauth2": "^5.1.0",
|
"simple-oauth2": "^5.1.0",
|
||||||
"systeminformation": "^5.28.2",
|
"systeminformation": "^5.28.2",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
@@ -748,6 +749,21 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kwsites/file-exists": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kwsites/promise-deferred": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.1.1",
|
"version": "16.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
|
||||||
@@ -4939,6 +4955,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-git": {
|
||||||
|
"version": "3.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz",
|
||||||
|
"integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kwsites/file-exists": "^1.1.1",
|
||||||
|
"@kwsites/promise-deferred": "^1.1.1",
|
||||||
|
"debug": "^4.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simple-oauth2": {
|
"node_modules/simple-oauth2": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"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",
|
||||||
|
"simple-git": "^3.30.0",
|
||||||
"simple-oauth2": "^5.1.0",
|
"simple-oauth2": "^5.1.0",
|
||||||
"systeminformation": "^5.28.2",
|
"systeminformation": "^5.28.2",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import testAuth from "../auth";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
import { installApp, getApplications, removeApp } 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 applications = await getApplications();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "ok",
|
|
||||||
applications,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
||||||
const auth = await testAuth(req);
|
|
||||||
const { name, repo, nodeId }: { name: string; repo: string; nodeId: string } =
|
|
||||||
await req.json();
|
|
||||||
|
|
||||||
if (auth) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: auth },
|
|
||||||
{ status: StatusCodes.UNAUTHORIZED }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name || !repo || !nodeId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Body missing name, repo, nodeId" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await installApp(name, repo, nodeId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "ok",
|
|
||||||
...res,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
|
||||||
const auth = await testAuth(req);
|
|
||||||
const appId = req.nextUrl.searchParams.get("appId");
|
|
||||||
|
|
||||||
if (auth) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: auth },
|
|
||||||
{ status: StatusCodes.UNAUTHORIZED }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing param appId" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await removeApp(appId);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "ok",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,16 @@
|
|||||||
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 { deployApp, getDeployments } from "@/core/deployment";
|
import { deployApp } from "@/core/deployment";
|
||||||
import { getApplications } from "@/core/application";
|
import { Application, Port } from "@/lib/types";
|
||||||
|
|
||||||
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> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
const { name, appId }: { name: string; appId: string } = await req.json();
|
const {
|
||||||
|
name,
|
||||||
|
app,
|
||||||
|
ports,
|
||||||
|
}: { name: string; app: Application; ports: Port[] } = await req.json();
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -35,14 +19,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || !appId) {
|
if (!name || !app) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Body missing name, appId, nodeId" },
|
{ message: "Body missing name, appId, nodeId" },
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
{ status: StatusCodes.BAD_REQUEST }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deployId = await deployApp(name, appId);
|
const deployId = await deployApp(name, app, ports);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "ok",
|
message: "ok",
|
||||||
|
|||||||
Vendored
-41
@@ -1,41 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import testAuth from "../auth";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
import { getEnv, setEnv } 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await getEnv());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
|
||||||
const auth = await testAuth(req);
|
|
||||||
|
|
||||||
if (auth) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: auth },
|
|
||||||
{ status: StatusCodes.UNAUTHORIZED }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { appId, variables } = await req.json();
|
|
||||||
|
|
||||||
if (!appId || !variables) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Missing appId, variables in body" },
|
|
||||||
{ status: StatusCodes.BAD_REQUEST }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await setEnv(appId, variables);
|
|
||||||
|
|
||||||
return NextResponse.json({ message: "ok", success: true });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import testAuth from "../auth";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
import { getDeployments } from "@/core/deployment";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const auth = await testAuth(req);
|
||||||
|
const apps = await req.json();
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: auth },
|
||||||
|
{ status: StatusCodes.UNAUTHORIZED }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apps)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Body should be Application[]" },
|
||||||
|
{ status: StatusCodes.UNAUTHORIZED }
|
||||||
|
);
|
||||||
|
|
||||||
|
const deployments = await getDeployments(apps);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "ok",
|
||||||
|
deployments,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,51 +12,42 @@ import {
|
|||||||
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 { MoreVertical, Plus, Rocket } from "lucide-react";
|
import { MoreVertical, Plus, Rocket } from "lucide-react";
|
||||||
import InstallApplicationDialog from "@/components/dialogs/InstallApplication";
|
import RegisterAppDialog from "@/components/dialogs/RegisterApp";
|
||||||
import { useEffect, useState } from "react";
|
import { getApps, removeApp } from "@/lib/dockship/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 DeployAppDialog from "@/components/dialogs/DeployApp";
|
import DeployAppDialog from "@/components/dialogs/DeployApp";
|
||||||
|
import { useAsyncInterval } from "@/hooks/use-async";
|
||||||
|
|
||||||
export default function ApplicationsPage() {
|
export default function ApplicationsPage() {
|
||||||
const [applications, setApplications] = useState<Application[]>([]);
|
const { value: apps, setValue: setApps } = useAsyncInterval(
|
||||||
|
[],
|
||||||
useEffect(() => {
|
getApps,
|
||||||
const fetch = async () => {
|
3000
|
||||||
const apps = await getApplications();
|
);
|
||||||
setApplications(apps);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetch();
|
|
||||||
|
|
||||||
const iid = setInterval(fetch, 3000);
|
|
||||||
return () => clearInterval(iid);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const remove = async (appId: string) => {
|
const remove = async (appId: string) => {
|
||||||
await removeApp(appId);
|
await removeApp(appId);
|
||||||
setApplications((prev) => prev.filter((app) => app.id !== appId));
|
setApps((prev) => prev.filter((app) => app.id !== appId));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
title="Applications"
|
title="Applications"
|
||||||
subtitle="Manage installed applications"
|
subtitle="Manage dockship applications"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-6">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-6">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge>{applications.length} apps</Badge>
|
<Badge>{apps.length} apps</Badge>
|
||||||
</div>
|
</div>
|
||||||
<InstallApplicationDialog>
|
<RegisterAppDialog>
|
||||||
<Button size="sm" className="gap-2 w-full sm:w-auto">
|
<Button size="sm" className="gap-2 w-full sm:w-auto">
|
||||||
<Plus className="w-4 h-4" /> Install Application
|
<Plus className="w-4 h-4" /> Register Application
|
||||||
</Button>
|
</Button>
|
||||||
</InstallApplicationDialog>
|
</RegisterAppDialog>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{applications.map((app) => (
|
{apps.map((app) => (
|
||||||
<Card key={app.id}>
|
<Card key={app.id}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{app.name}</CardTitle>
|
<CardTitle>{app.name}</CardTitle>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react";
|
import { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react";
|
||||||
import { Application, Deployment } from "@/lib/types";
|
import { Application, Deployment } from "@/lib/types";
|
||||||
import { getDeployments } from "@/lib/dockship/deploy";
|
import { getDeployments } from "@/lib/dockship/deploy";
|
||||||
import { getApplications } from "@/lib/dockship/application";
|
import { getApps } from "@/lib/dockship/application";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import DeployAppDialog from "@/components/dialogs/DeployApp";
|
import DeployAppDialog from "@/components/dialogs/DeployApp";
|
||||||
|
import { useAsync } from "@/hooks/use-async";
|
||||||
|
|
||||||
const statusDot = {
|
const statusDot = {
|
||||||
running: "bg-chart-2",
|
running: "bg-chart-2",
|
||||||
@@ -19,46 +20,19 @@ const statusDot = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function DeploymentsPage() {
|
export default function DeploymentsPage() {
|
||||||
const [applications, setApplications] = useState<Application[]>([]);
|
const { value: apps } = useAsync([], getApps);
|
||||||
const [deployments, setDeployments] = useState<Deployment[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { value: deployments, loading } = useAsync([], getDeployments, [apps]);
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const [apps, deps] = await Promise.all([
|
|
||||||
getApplications(),
|
|
||||||
getDeployments(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setApplications(apps);
|
|
||||||
setDeployments(deps.filter((d) => d !== null));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load deployments:", err);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const appNameByContainer = useMemo(() => {
|
const appNameByContainer = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const app of applications) {
|
for (const app of apps) {
|
||||||
for (const container of app.deployments) {
|
for (const container of app.deployments) {
|
||||||
map.set(container, app.name);
|
map.set(container, app.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [applications]);
|
}, [apps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -68,7 +42,9 @@ export default function DeploymentsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Badge>
|
<Badge>
|
||||||
{loading ? "Loading…" : `${deployments.length} deployments`}
|
{loading
|
||||||
|
? "Loading…"
|
||||||
|
: `${deployments.length} deployment${deployments.length > 0 && 's'}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<DeployAppDialog>
|
<DeployAppDialog>
|
||||||
|
|||||||
@@ -16,22 +16,17 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useAsync } from "@/hooks/use-async";
|
||||||
|
|
||||||
export default function EnvironmentPage() {
|
export default function EnvironmentPage() {
|
||||||
const [selectedApp, setSelectedApp] = useState("");
|
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
|
||||||
const [envGroups, setEnvGroups] = useState<Record<string, Env>>({});
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||||
useEffect(() => {
|
const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv);
|
||||||
const fetch = async () => {
|
const { value: selectedApp, setValue: setSelectedApp } = useAsync(
|
||||||
const env = await getEnv();
|
"",
|
||||||
setEnvGroups(env);
|
async () => Object.keys(envGroups)[0] || "",
|
||||||
const vars = Object.keys(env);
|
[envGroups]
|
||||||
if (vars.length > 0) setSelectedApp(vars[0]);
|
);
|
||||||
};
|
|
||||||
fetch();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|||||||
+11
-16
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
|
import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,25 +9,19 @@ 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 "@/lib/dockship/docker";
|
import { getAllContainerLogs } from "@/lib/dockship/docker";
|
||||||
|
import { useAsyncInterval } from "@/hooks/use-async";
|
||||||
|
import { AnsiText } from "@/components/ansi/Ansi";
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">(
|
const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">(
|
||||||
"all"
|
"all"
|
||||||
);
|
);
|
||||||
const [logs, setLogs] = useState<Log[]>([]);
|
const { value: logs, setValue: setLogs } = useAsyncInterval(
|
||||||
|
[],
|
||||||
useEffect(() => {
|
async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()),
|
||||||
const fetch = async () => {
|
1000
|
||||||
if (!isPaused) setLogs(await getAllContainerLogs());
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const iid = setInterval(fetch, 1000);
|
|
||||||
|
|
||||||
fetch();
|
|
||||||
|
|
||||||
return () => clearInterval(iid);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredLogs = logs.filter((log) =>
|
const filteredLogs = logs.filter((log) =>
|
||||||
filter === "all" ? true : log.level === filter
|
filter === "all" ? true : log.level === filter
|
||||||
@@ -189,9 +183,10 @@ export default function LogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<span className="text-foreground wrap-break-words">
|
{/* <span className="text-foreground wrap-break-words">
|
||||||
{log.message}
|
{log.message}
|
||||||
</span>
|
</span> */}
|
||||||
|
<AnsiText text={log.message} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+62
-76
@@ -6,13 +6,13 @@ 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 "@/lib/dockship/metrics";
|
import { getMetrics } from "@/lib/dockship/metrics";
|
||||||
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 "@/lib/dockship/node";
|
import { getLiveNodes, getNodes } from "@/lib/dockship/node";
|
||||||
import { average } from "@/lib/format";
|
import { average } from "@/lib/format";
|
||||||
import { getContainerStats, getDocker } from "@/lib/dockship/docker";
|
import { getContainerStats, getDocker } from "@/lib/dockship/docker";
|
||||||
import { SimpleStats } from "@/lib/types";
|
import { SimpleStats } from "@/lib/types";
|
||||||
|
import { useAsync, useAsyncInterval } from "@/hooks/use-async";
|
||||||
|
|
||||||
const cpuChartConfig = {
|
const cpuChartConfig = {
|
||||||
cpu: { label: "CPU Usage", color: "var(--chart-1)" },
|
cpu: { label: "CPU Usage", color: "var(--chart-1)" },
|
||||||
@@ -28,74 +28,52 @@ const networkChartConfig = {
|
|||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
const [data, setData] = useState<
|
|
||||||
{
|
|
||||||
time: string;
|
|
||||||
cpu: number;
|
|
||||||
memory: number;
|
|
||||||
in: number;
|
|
||||||
out: number;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [liveData, setLiveData] = useState<{
|
|
||||||
cpuUsage: number;
|
|
||||||
ramUsage: number;
|
|
||||||
}>();
|
|
||||||
const [containerStats, SetContainerStats] = useState<
|
|
||||||
(SimpleStats & { name: string })[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Metrics polling every 30 seconds
|
||||||
const fetchNodes = async () => {
|
const { value: metrics } = useAsyncInterval([], getMetrics, 30000, []);
|
||||||
|
|
||||||
|
// Live nodes polling every 3 seconds
|
||||||
|
const { value: liveData } = useAsyncInterval(
|
||||||
|
{ cpuUsage: 0, ramUsage: 0 },
|
||||||
|
async () => {
|
||||||
const nodes = await getLiveNodes();
|
const nodes = await getLiveNodes();
|
||||||
setLiveData({
|
return {
|
||||||
cpuUsage: average(nodes, (n) => n.liveData?.cpu.usage || 0),
|
cpuUsage: average(nodes, (n) => n.liveData?.cpu.usage || 0),
|
||||||
ramUsage: average(nodes, (n) => n.liveData?.memory.usage || 0),
|
ramUsage: average(nodes, (n) => n.liveData?.memory.usage || 0),
|
||||||
});
|
};
|
||||||
};
|
},
|
||||||
fetchNodes();
|
3000,
|
||||||
const interval = setInterval(fetchNodes, 3000);
|
[]
|
||||||
return () => clearInterval(interval);
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Container stats
|
||||||
async function fetchMetrics() {
|
const { value: containerStats } = useAsync(
|
||||||
setData(await getMetrics());
|
[],
|
||||||
}
|
async () => {
|
||||||
|
|
||||||
fetchMetrics();
|
|
||||||
|
|
||||||
const i = setInterval(fetchMetrics, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(i);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchContainerMetrics() {
|
|
||||||
const nodes = await getNodes();
|
const nodes = await getNodes();
|
||||||
const dockerNodes = await getDocker(nodes);
|
const dockerNodes = await getDocker(nodes);
|
||||||
|
|
||||||
SetContainerStats(
|
return (
|
||||||
(
|
await Promise.all(
|
||||||
await Promise.all(
|
dockerNodes.flatMap((node) =>
|
||||||
dockerNodes.flatMap((node) =>
|
node.containers.map(async (c) => {
|
||||||
node.containers.map(async (c) => {
|
const stats = await getContainerStats(c.Id);
|
||||||
const stats = await getContainerStats(c.Id);
|
if (!stats) return undefined;
|
||||||
|
return { ...stats, name: c.Names[0] };
|
||||||
if (stats === undefined) return undefined;
|
})
|
||||||
|
|
||||||
return { ...stats, name: c.Names[0] };
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
).filter((s): s is SimpleStats & { name: string } => s !== undefined)
|
)
|
||||||
);
|
).filter((s): s is SimpleStats & { name: string } => s !== undefined);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(containerStats);
|
const containerCPU = containerStats.reduce((acc, val) => acc + val.cpu, 0);
|
||||||
}
|
const containerMemory = containerStats.reduce(
|
||||||
fetchContainerMetrics();
|
(acc, val) => acc + val.memory,
|
||||||
}, []);
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -104,17 +82,27 @@ export default function MonitoringPage() {
|
|||||||
>
|
>
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
<Card>
|
<Card className="p-0 flex items-center justify-center">
|
||||||
<CardContent className="px-4">
|
<CardContent className="p-1 flex items-center justify-center">
|
||||||
<RadialChart value={liveData?.cpuUsage || 0}>CPU</RadialChart>
|
<RadialChart value={liveData?.cpuUsage || 0}>CPU</RadialChart>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="p-0 flex items-center justify-center">
|
||||||
<CardContent className="px-4">
|
<CardContent className="p-1 flex items-center justify-center">
|
||||||
<RadialChart value={liveData?.ramUsage || 0}>RAM</RadialChart>
|
<RadialChart value={liveData?.ramUsage || 0}>RAM</RadialChart>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="p-0 flex items-center justify-center">
|
||||||
|
<CardContent className="p-1 flex items-center justify-center">
|
||||||
|
<RadialChart value={containerCPU}>con CPU</RadialChart>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-0 flex items-center justify-center">
|
||||||
|
<CardContent className="p-1 flex items-center justify-center">
|
||||||
|
<RadialChart value={containerMemory}>con RAM</RadialChart>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
@@ -124,7 +112,7 @@ export default function MonitoringPage() {
|
|||||||
CPU usage
|
CPU usage
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-value">
|
<div className="stat-value">
|
||||||
{liveData?.cpuUsage?.toFixed(0) || 0}%
|
{liveData?.cpuUsage?.toFixed(1) || 0}%
|
||||||
</div>
|
</div>
|
||||||
<Progress value={liveData?.cpuUsage} className="w-full" />
|
<Progress value={liveData?.cpuUsage} className="w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -135,7 +123,7 @@ export default function MonitoringPage() {
|
|||||||
Memory usage
|
Memory usage
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-value">
|
<div className="stat-value">
|
||||||
{liveData?.ramUsage?.toFixed(0) || 0}%
|
{liveData?.ramUsage?.toFixed(1) || 0}%
|
||||||
</div>
|
</div>
|
||||||
<Progress value={liveData?.ramUsage} className="w-full" />
|
<Progress value={liveData?.ramUsage} className="w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -143,21 +131,19 @@ export default function MonitoringPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
Network In
|
Container CPU usage
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-value">38 MB/s</div>
|
<div className="stat-value">{containerCPU.toFixed(1)}%</div>
|
||||||
<div className="text-xs text-success mt-1">↑ 12% from avg</div>
|
<Progress value={containerCPU} className="w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
Network Out
|
Container memory usage
|
||||||
</div>
|
|
||||||
<div className="stat-value">28 MB/s</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
↓ 5% from avg
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-value">{containerMemory.toFixed(1)}%</div>
|
||||||
|
<Progress value={containerMemory} className="w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,13 +153,13 @@ export default function MonitoringPage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
<GradientAreaChart
|
<GradientAreaChart
|
||||||
title="CPU Usage"
|
title="CPU Usage"
|
||||||
data={data}
|
data={metrics}
|
||||||
config={cpuChartConfig}
|
config={cpuChartConfig}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
<GradientAreaChart
|
<GradientAreaChart
|
||||||
title="Memory Usage"
|
title="Memory Usage"
|
||||||
data={data}
|
data={metrics}
|
||||||
config={memoryChartConfig}
|
config={memoryChartConfig}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +169,7 @@ export default function MonitoringPage() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<GradientAreaChart
|
<GradientAreaChart
|
||||||
title="Network I/O"
|
title="Network I/O"
|
||||||
data={data}
|
data={metrics}
|
||||||
config={networkChartConfig}
|
config={networkChartConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-18
@@ -14,6 +14,7 @@ import { setNodes as updateNodes } from "@/lib/dockship/node";
|
|||||||
import AddNode from "@/components/dialogs/AddNode";
|
import AddNode from "@/components/dialogs/AddNode";
|
||||||
import { getDocker } from "@/lib/dockship/docker";
|
import { getDocker } from "@/lib/dockship/docker";
|
||||||
import RemoveDialog from "@/components/dialogs/Remove";
|
import RemoveDialog from "@/components/dialogs/Remove";
|
||||||
|
import { useAsync, useAsyncInterval } from "@/hooks/use-async";
|
||||||
|
|
||||||
function DiskUsage({ liveData }: NodeLiveData) {
|
function DiskUsage({ liveData }: NodeLiveData) {
|
||||||
if (!liveData) return null;
|
if (!liveData) return null;
|
||||||
@@ -33,25 +34,15 @@ function DiskUsage({ liveData }: NodeLiveData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
|
const { value: nodes, setValue: setNodes } = useAsyncInterval(
|
||||||
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
|
[],
|
||||||
|
getLiveNodes,
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [
|
||||||
const fetchNodes = async () => {
|
nodes,
|
||||||
const nodes = await getLiveNodes();
|
]);
|
||||||
setNodes(nodes);
|
|
||||||
};
|
|
||||||
fetchNodes();
|
|
||||||
const interval = setInterval(fetchNodes, 3000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchDocker = async () => {
|
|
||||||
setDockerNodes(await getDocker(nodes));
|
|
||||||
};
|
|
||||||
fetchDocker();
|
|
||||||
}, [nodes]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout title="Nodes" subtitle="Cluster node management">
|
<DashboardLayout title="Nodes" subtitle="Cluster node management">
|
||||||
|
|||||||
+13
-24
@@ -26,29 +26,22 @@ import {
|
|||||||
NodeLiveData,
|
NodeLiveData,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { getDocker } from "@/lib/dockship/docker";
|
import { getDocker } from "@/lib/dockship/docker";
|
||||||
import { getApplications } from "@/lib/dockship/application";
|
import { getApps } from "@/lib/dockship/application";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getDeployments } from "@/lib/dockship/deploy";
|
import { getDeployments } from "@/lib/dockship/deploy";
|
||||||
|
import { useAsync, useAsyncInterval } from "@/hooks/use-async";
|
||||||
|
|
||||||
export default function OverviewPage() {
|
export default function OverviewPage() {
|
||||||
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
|
const { value: nodes } = useAsyncInterval([], getLiveNodes, 3000);
|
||||||
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
|
const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [
|
||||||
const [apps, setApplications] = useState<Application[]>([]);
|
nodes,
|
||||||
const [deployments, setDeployments] = useState<Deployment[]>([]);
|
]);
|
||||||
|
const { value: apps } = useAsyncInterval([], getApps, 3000);
|
||||||
useEffect(() => {
|
const { value: deployments } = useAsyncInterval(
|
||||||
const fetchNodes = async () => {
|
[],
|
||||||
const nodes = await getLiveNodes();
|
async () => (await getDeployments()).filter((d) => d !== null),
|
||||||
setNodes(nodes);
|
3000
|
||||||
setDockerNodes(await getDocker(nodes));
|
);
|
||||||
setApplications(await getApplications());
|
|
||||||
setDeployments((await getDeployments()).filter((d) => d !== null));
|
|
||||||
};
|
|
||||||
fetchNodes();
|
|
||||||
const interval = setInterval(fetchNodes, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const appNameByContainer = useMemo(() => {
|
const appNameByContainer = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -138,13 +131,9 @@ export default function OverviewPage() {
|
|||||||
{app.name}
|
{app.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-4 text-muted-foreground">
|
<div className="col-span-7 text-muted-foreground">
|
||||||
{app.deployments.length}
|
{app.deployments.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-3 truncate">
|
|
||||||
{nodes.find((n) => n.node_id === app.nodeId)?.name ?? "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { tokenizeAnsi } from "./tokenize";
|
||||||
|
|
||||||
|
type AnsiTextProps = {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANSI_TW_CLASSES: Record<string, string> = {
|
||||||
|
black: "text-black",
|
||||||
|
red: "text-red-500",
|
||||||
|
green: "text-emerald-500",
|
||||||
|
yellow: "text-yellow-400",
|
||||||
|
blue: "text-blue-400",
|
||||||
|
magenta: "text-purple-400",
|
||||||
|
cyan: "text-cyan-400",
|
||||||
|
white: "text-gray-100",
|
||||||
|
|
||||||
|
brightBlack: "text-gray-500",
|
||||||
|
brightRed: "text-red-400",
|
||||||
|
brightGreen: "text-emerald-400",
|
||||||
|
brightYellow: "text-yellow-300",
|
||||||
|
brightBlue: "text-blue-300",
|
||||||
|
brightMagenta: "text-purple-300",
|
||||||
|
brightCyan: "text-cyan-300",
|
||||||
|
brightWhite: "text-white",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnsiText({ text, className }: AnsiTextProps) {
|
||||||
|
const tokens = tokenizeAnsi(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`whitespace-pre-wrap font-mono ${className ?? ""}`}>
|
||||||
|
{tokens.map((token, i) => {
|
||||||
|
const classes = [
|
||||||
|
token.style.color && ANSI_TW_CLASSES[token.style.color],
|
||||||
|
token.style.bold && "font-semibold",
|
||||||
|
token.style.underline && "underline",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={i} className={classes}>
|
||||||
|
{token.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type StyleState = {
|
||||||
|
color?: string;
|
||||||
|
bold?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANSI_REGEX = /\u001b\[([0-9;]*)m/g;
|
||||||
|
|
||||||
|
const COLOR_CODES: Record<number, string> = {
|
||||||
|
30: "black",
|
||||||
|
31: "red",
|
||||||
|
32: "green",
|
||||||
|
33: "yellow",
|
||||||
|
34: "blue",
|
||||||
|
35: "magenta",
|
||||||
|
36: "cyan",
|
||||||
|
37: "white",
|
||||||
|
|
||||||
|
90: "brightBlack",
|
||||||
|
91: "brightRed",
|
||||||
|
92: "brightGreen",
|
||||||
|
93: "brightYellow",
|
||||||
|
94: "brightBlue",
|
||||||
|
95: "brightMagenta",
|
||||||
|
96: "brightCyan",
|
||||||
|
97: "brightWhite",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyAnsiCodes(codes: number[], state: StyleState): StyleState {
|
||||||
|
let next = { ...state };
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
if (code === 0) {
|
||||||
|
next = {};
|
||||||
|
} else if (code === 1) {
|
||||||
|
next.bold = true;
|
||||||
|
} else if (code === 4) {
|
||||||
|
next.underline = true;
|
||||||
|
} else if (COLOR_CODES[code]) {
|
||||||
|
next.color = COLOR_CODES[code];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ANSI_REGEX, applyAnsiCodes, StyleState } from "./parser";
|
||||||
|
|
||||||
|
export type Token = {
|
||||||
|
text: string;
|
||||||
|
style: StyleState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tokenizeAnsi(input: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let style: StyleState = {};
|
||||||
|
|
||||||
|
while ((match = ANSI_REGEX.exec(input))) {
|
||||||
|
const index = match.index;
|
||||||
|
|
||||||
|
if (index > lastIndex) {
|
||||||
|
tokens.push({
|
||||||
|
text: input.slice(lastIndex, index),
|
||||||
|
style,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = match[1].split(";").map(Number);
|
||||||
|
style = applyAnsiCodes(codes, style);
|
||||||
|
|
||||||
|
lastIndex = ANSI_REGEX.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < input.length) {
|
||||||
|
tokens.push({
|
||||||
|
text: input.slice(lastIndex),
|
||||||
|
style,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, ReactNode, useEffect } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,9 +15,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getLiveNodes } from "@/lib/dockship/node";
|
import { getLiveNodes } from "@/lib/dockship/node";
|
||||||
import { Combobox } from "../ui/combobox";
|
import { Combobox } from "../ui/combobox";
|
||||||
import { Application, Node, NodeLiveData } from "@/lib/types";
|
|
||||||
import { getApplications } from "@/core/application";
|
|
||||||
import { deployApp } from "@/lib/dockship/deploy";
|
import { deployApp } from "@/lib/dockship/deploy";
|
||||||
|
import { useAsync } from "@/hooks/use-async";
|
||||||
|
import { Application, Port } from "@/lib/types";
|
||||||
|
import { getApps } from "@/lib/dockship/application";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
export default function DeployAppDialog({
|
export default function DeployAppDialog({
|
||||||
defaultApp,
|
defaultApp,
|
||||||
@@ -26,24 +28,32 @@ export default function DeployAppDialog({
|
|||||||
defaultApp?: string;
|
defaultApp?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]);
|
const { value: nodes } = useAsync([], getLiveNodes);
|
||||||
const [apps, setApps] = useState<Application[]>([]);
|
const { value: apps } = useAsync<Application[]>([], getApps);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [app, setApp] = useState(defaultApp || "");
|
const [app, setApp] = useState(defaultApp || "");
|
||||||
const [node, setNode] = useState("");
|
const [node, setNode] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const [ports, setPorts] = useState<Port[]>([]);
|
||||||
const fetchNodes = async () => {
|
const [newPort, setNewPort] = useState<Port>({
|
||||||
setNodeList(await getLiveNodes());
|
containerPort: "",
|
||||||
setApps(await getApplications());
|
hostPort: "",
|
||||||
};
|
protocol: "tcp",
|
||||||
fetchNodes();
|
});
|
||||||
}, []);
|
|
||||||
|
const addPort = () => {
|
||||||
|
if (!newPort.containerPort || !newPort.hostPort) return;
|
||||||
|
setPorts([...ports, newPort]);
|
||||||
|
setNewPort({ containerPort: "", hostPort: "", protocol: "tcp" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePort = (index: number) => {
|
||||||
|
setPorts(ports.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
console.log(name, app, node);
|
console.log("Deploying:", { name, app, node, ports });
|
||||||
await deployApp(name, app, node);
|
await deployApp(name, app, node, ports);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,6 +66,7 @@ export default function DeployAppDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
{/* Name */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
Deployment name <span className="text-destructive">*</span>
|
Deployment name <span className="text-destructive">*</span>
|
||||||
@@ -68,6 +79,7 @@ export default function DeployAppDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Application */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label htmlFor="repo">
|
<Label htmlFor="repo">
|
||||||
Application <span className="text-destructive">*</span>
|
Application <span className="text-destructive">*</span>
|
||||||
@@ -84,6 +96,7 @@ export default function DeployAppDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Node */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label htmlFor="node">
|
<Label htmlFor="node">
|
||||||
Node <span className="text-destructive">*</span>
|
Node <span className="text-destructive">*</span>
|
||||||
@@ -91,13 +104,72 @@ export default function DeployAppDialog({
|
|||||||
<Combobox
|
<Combobox
|
||||||
value={node}
|
value={node}
|
||||||
setValue={setNode}
|
setValue={setNode}
|
||||||
data={nodeList
|
data={nodes
|
||||||
.filter((node) => node.liveData)
|
.filter((node) => node.liveData)
|
||||||
.map((node) => ({ label: node.name, value: node.node_id }))}
|
.map((node) => ({ label: node.name, value: node.node_id }))}
|
||||||
placeholder="Select Node..."
|
placeholder="Select Node..."
|
||||||
listPlaceholder="No nodes found :("
|
listPlaceholder="No nodes found :("
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ports */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label>Ports</Label>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<Input
|
||||||
|
placeholder="Container Port"
|
||||||
|
value={newPort.containerPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPort({ ...newPort, containerPort: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Host Port"
|
||||||
|
value={newPort.hostPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewPort({ ...newPort, hostPort: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
value={newPort.protocol}
|
||||||
|
setValue={(v) =>
|
||||||
|
setNewPort({
|
||||||
|
...newPort,
|
||||||
|
protocol: v as "tcp" | "udp",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ label: "TCP", value: "tcp" },
|
||||||
|
{ label: "UDP", value: "udp" },
|
||||||
|
]}
|
||||||
|
placeholder="Select protocol..."
|
||||||
|
listPlaceholder="No protocols found :("
|
||||||
|
/>
|
||||||
|
<Button onClick={addPort} variant="ghost">
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ports.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{ports.map((p, i) => (
|
||||||
|
<li key={i} className="flex justify-between items-center">
|
||||||
|
<span>
|
||||||
|
{p.containerPort} → {p.hostPort} (
|
||||||
|
{p.protocol.toUpperCase()})
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePort(i)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
+6
-30
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, ReactNode, useEffect } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,28 +15,19 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getLiveNodes } from "@/lib/dockship/node";
|
import { getLiveNodes } from "@/lib/dockship/node";
|
||||||
import { Combobox } from "../ui/combobox";
|
import { Combobox } from "../ui/combobox";
|
||||||
import { Node, NodeLiveData } from "@/lib/types";
|
import { registerApp } from "@/lib/dockship/application";
|
||||||
import { installApp } from "@/lib/dockship/application";
|
import { useAsync } from "@/hooks/use-async";
|
||||||
|
|
||||||
export default function InstallApplicationDialog({
|
export default function RegisterAppDialog({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [repo, setRepo] = useState("");
|
const [repo, setRepo] = useState("");
|
||||||
const [node, setNode] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchNodes = async () => {
|
|
||||||
setNodeList(await getLiveNodes());
|
|
||||||
};
|
|
||||||
fetchNodes();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deploy = async () => {
|
const deploy = async () => {
|
||||||
console.log(await installApp(name, repo, node));
|
console.log(await registerApp(name, repo));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +36,7 @@ export default function InstallApplicationDialog({
|
|||||||
|
|
||||||
<DialogContent className="sm:max-w-106.25">
|
<DialogContent className="sm:max-w-106.25">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Install Application</DialogTitle>
|
<DialogTitle>Register Application</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -72,21 +63,6 @@ export default function InstallApplicationDialog({
|
|||||||
placeholder="https://github.com/me/myrepo.git"
|
placeholder="https://github.com/me/myrepo.git"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -43,13 +43,13 @@ export function Combobox({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-50 justify-between"
|
className="justify-between"
|
||||||
>
|
>
|
||||||
{value ? data.find((itm) => itm.value === value)?.label : placeholder}
|
{value ? data.find((itm) => itm.value === value)?.label : placeholder}
|
||||||
<ChevronsUpDown className="opacity-50" />
|
<ChevronsUpDown className="opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-50 p-0">
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search framework..." className="h-9" />
|
<CommandInput placeholder="Search framework..." className="h-9" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -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: Record<string, EnvVariable> = JSON.parse(
|
|
||||||
fs.readFileSync(envPath, "utf-8")
|
|
||||||
);
|
|
||||||
acc[appId] = { variables: envData, id: appId, name };
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Env>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setEnv(appId: string, variables: EnvVariable[]) {
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
+35
-11
@@ -1,11 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import { Application, Deployment } from "@/lib/types";
|
import { Application, Deployment, Port } from "@/lib/types";
|
||||||
import { CPUusage } from "@/lib/server/calc";
|
import { CPUusage } from "@/lib/server/calc";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import simpleGit from "simple-git";
|
||||||
|
import { cloneRepo } from "./git";
|
||||||
|
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
|
|
||||||
@@ -17,10 +20,23 @@ function sanitizeString(str: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPorts(ports: Port[]) {
|
||||||
|
const dockerPorts: Record<string, { HostPort: string }[]> = {};
|
||||||
|
|
||||||
|
ports.forEach(({ containerPort, hostPort, protocol }) => {
|
||||||
|
const key = `${containerPort}/${protocol}`;
|
||||||
|
if (!dockerPorts[key]) {
|
||||||
|
dockerPorts[key] = [];
|
||||||
|
}
|
||||||
|
dockerPorts[key].push({ HostPort: hostPort });
|
||||||
|
});
|
||||||
|
|
||||||
|
return dockerPorts;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDeployments(
|
export async function getDeployments(
|
||||||
apps: Application[]
|
apps: Application[]
|
||||||
): Promise<Deployment[]> {
|
): Promise<Deployment[]> {
|
||||||
// Limit concurrent Docker calls (adjust if needed)
|
|
||||||
const limit = pLimit(5);
|
const limit = pLimit(5);
|
||||||
|
|
||||||
const deploymentIds = apps.flatMap((app) => app.deployments);
|
const deploymentIds = apps.flatMap((app) => app.deployments);
|
||||||
@@ -94,23 +110,28 @@ export async function getDeployments(
|
|||||||
|
|
||||||
export async function deployApp(
|
export async function deployApp(
|
||||||
name: string,
|
name: string,
|
||||||
appId: string
|
app: Application,
|
||||||
|
ports: Port[]
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const appPath = path.join("data", "apps", appId, "repo");
|
const deployId = randomUUID();
|
||||||
const imageTag = `dockship/${appId}:latest`;
|
const deployPath = path.join("data", "deploys", deployId);
|
||||||
|
const imageTag = `dockship/${deployId}:latest`;
|
||||||
|
|
||||||
|
// Clone the repo
|
||||||
|
await cloneRepo(app.repo, deployPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if app exists
|
// Check if app exists
|
||||||
fs.accessSync(appPath);
|
fs.accessSync(deployPath);
|
||||||
} catch {
|
} catch {
|
||||||
console.error("Repo does not exist:", appPath);
|
console.error("Repo does not exist:", deployPath);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await docker.buildImage(
|
const stream = await docker.buildImage(
|
||||||
{
|
{
|
||||||
context: appPath,
|
context: deployPath,
|
||||||
src: ["Dockerfile", "."],
|
src: ["Dockerfile", "."],
|
||||||
},
|
},
|
||||||
{ t: imageTag }
|
{ t: imageTag }
|
||||||
@@ -128,10 +149,11 @@ export async function deployApp(
|
|||||||
Tty: true,
|
Tty: true,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
RestartPolicy: { Name: "unless-stopped" },
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
PortBindings: {
|
PortBindings: formatPorts(ports),
|
||||||
"3000/tcp": [{ HostPort: "3800" }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
Env: Object.entries(app.env).map(
|
||||||
|
([key, { value }]) => `${key}="${value.replaceAll('"', '\\"')}"`
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
await container.start();
|
await container.start();
|
||||||
@@ -140,5 +162,7 @@ export async function deployApp(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to deploy app:", err);
|
console.error("Failed to deploy app:", err);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(deployPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-21
@@ -67,36 +67,52 @@ function demuxLogs(buffer: Buffer): string {
|
|||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getContainerLogs(
|
async function getContainerLogs(
|
||||||
containerId: string,
|
containerId: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<Log[]> {
|
): Promise<Log[]> {
|
||||||
const container = docker.getContainer(containerId);
|
const container = docker.getContainer(containerId);
|
||||||
|
|
||||||
const raw = await container.logs({
|
// Inspect the container to check the logging driver
|
||||||
stdout: true,
|
const info = await container.inspect();
|
||||||
stderr: true,
|
const driver = info.HostConfig.LogConfig?.Type;
|
||||||
timestamps: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = demuxLogs(raw);
|
if (driver !== "json-file" && driver !== "journald") {
|
||||||
|
// Skip containers with unsupported logging drivers
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return text
|
try {
|
||||||
.split("\n")
|
const raw = await container.logs({
|
||||||
.filter(Boolean)
|
stdout: true,
|
||||||
.map((line) => {
|
stderr: true,
|
||||||
const firstSpace = line.indexOf(" ");
|
timestamps: true,
|
||||||
const timestamp = line.slice(0, firstSpace);
|
|
||||||
const message = line.slice(firstSpace + 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp,
|
|
||||||
level: inferLevel(message),
|
|
||||||
source: name,
|
|
||||||
message,
|
|
||||||
} satisfies Log;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 501) {
|
||||||
|
// Logging driver does not support reading
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw err; // rethrow any other error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllContainerLogs(): Promise<Log[]> {
|
export async function getAllContainerLogs(): Promise<Log[]> {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import simpleGit from "simple-git";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
export async function cloneRepo(repoUrl: string, deployPath: string) {
|
||||||
|
fs.mkdirSync(deployPath, { recursive: true });
|
||||||
|
const git = simpleGit();
|
||||||
|
await git.clone(repoUrl, deployPath, ["--depth=1"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useAsync<T>(
|
||||||
|
defaultValue: T,
|
||||||
|
asyncFunction: () => Promise<T>,
|
||||||
|
dependencies: any[] = []
|
||||||
|
) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<null | Error>(null);
|
||||||
|
const [value, setValue] = useState<T>(defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
asyncFunction()
|
||||||
|
.then((result) => {
|
||||||
|
if (isMounted) setValue(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (isMounted) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isMounted) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, dependencies);
|
||||||
|
|
||||||
|
return { loading, error, value, setValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsyncInterval<T>(
|
||||||
|
defaultValue: T,
|
||||||
|
asyncFunction: () => Promise<T>,
|
||||||
|
interval: number,
|
||||||
|
dependencies: any[] = []
|
||||||
|
) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<null | Error>(null);
|
||||||
|
const [value, setValue] = useState<T>(defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
setLoading(true);
|
||||||
|
asyncFunction()
|
||||||
|
.then((result) => {
|
||||||
|
if (isMounted) setValue(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (isMounted) setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isMounted) setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const iid = setInterval(run, interval);
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearInterval(iid);
|
||||||
|
};
|
||||||
|
}, dependencies);
|
||||||
|
|
||||||
|
return { loading, error, value, setValue };
|
||||||
|
}
|
||||||
@@ -1,88 +1,99 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { verifySession } from "../../core/auth/session";
|
import { verifySession } from "../../core/auth/session";
|
||||||
import { getNodes } from "@/lib/dockship/node";
|
|
||||||
import axios from "axios";
|
|
||||||
import { Env, EnvVariable, Application } from "@/lib/types";
|
import { Env, EnvVariable, Application } from "@/lib/types";
|
||||||
|
import { read, write } from "@/app/api/file";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export async function installApp(name: string, repo: string, nodeId: string) {
|
export async function getApps(): Promise<Application[]> {
|
||||||
if (await verifySession()) {
|
if (await verifySession()) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = await getNodes();
|
return read(() => [], "data", "apps.json");
|
||||||
|
|
||||||
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 getApp(appId: string): Promise<Application | undefined> {
|
||||||
const nodes = await getNodes();
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (await read<Application[]>(() => [], "data", "apps.json")).find(
|
||||||
await Promise.all(
|
(app) => app.id === appId
|
||||||
nodes.map(
|
);
|
||||||
async (n) =>
|
}
|
||||||
await (
|
|
||||||
await axios.get(`http://${n.ip}:3000/api/applications`, {
|
export async function registerApp(name: string, repo: string) {
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
if (await verifySession()) {
|
||||||
})
|
throw new Error("Unauthorized");
|
||||||
).data.applications
|
}
|
||||||
)
|
|
||||||
)
|
const app: Application = {
|
||||||
).flat();
|
id: randomUUID(),
|
||||||
|
name,
|
||||||
|
repo,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
deployments: [],
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await write([...(await getApps()), app], "data", "apps.json");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeApp(appId: string) {
|
||||||
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = await getApps();
|
||||||
|
|
||||||
|
const nextApps = apps.filter((app) => app.id !== appId);
|
||||||
|
|
||||||
|
await write(nextApps, "data", "apps.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editApp(appId: string, updatedApp: Application) {
|
||||||
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = await getApps();
|
||||||
|
|
||||||
|
const nextApps = apps.map((app) => (app.id === appId ? updatedApp : app));
|
||||||
|
|
||||||
|
await write(nextApps, "data", "apps.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnv(): Promise<Record<string, Env>> {
|
export async function getEnv(): Promise<Record<string, Env>> {
|
||||||
const nodes = await getNodes();
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
const envRecords = await Promise.all(
|
const apps = await getApps();
|
||||||
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 apps.reduce<Record<string, Env>>((acc, app) => {
|
||||||
|
acc[app.id] = {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
variables: app.env,
|
||||||
|
};
|
||||||
|
|
||||||
return merged;
|
return acc;
|
||||||
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setEnv(
|
export async function setEnv(
|
||||||
appId: string,
|
appId: string,
|
||||||
variables: Record<string, EnvVariable>
|
variables: Record<string, EnvVariable>
|
||||||
) {
|
) {
|
||||||
const nodes = await getNodes();
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
nodes.forEach(async (n) => {
|
const apps = await getApps();
|
||||||
await axios.post(
|
const app = apps.find((app) => app.id === appId);
|
||||||
`http://${n.ip}:3000/api/env`,
|
if (!app) throw new Error("App not found");
|
||||||
{ appId, variables },
|
editApp(appId, { ...app, env: 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}` },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { Deployment } from "@/lib/types";
|
import { Deployment, Port } from "@/lib/types";
|
||||||
import { getNodes } from "./node";
|
import { getNodes } from "./node";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { editApp, getApp, getApps } from "./application";
|
||||||
|
|
||||||
export async function getDeployments(): Promise<(null | Deployment)[]> {
|
export async function getDeployments(): Promise<Deployment[]> {
|
||||||
const nodes = await getNodes();
|
const nodes = await getNodes();
|
||||||
|
const apps = await getApps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
nodes.map(
|
nodes.map(
|
||||||
async (n) =>
|
async (n) =>
|
||||||
await (
|
await (
|
||||||
await axios.get(`http://${n.ip}:3000/api/deploy`, {
|
await axios.post(`http://${n.ip}:3000/api/get-deployments`, apps, {
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
})
|
})
|
||||||
).data.applications
|
).data.deployments
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
@@ -24,7 +26,8 @@ export async function getDeployments(): Promise<(null | Deployment)[]> {
|
|||||||
export async function deployApp(
|
export async function deployApp(
|
||||||
name: string,
|
name: string,
|
||||||
appId: string,
|
appId: string,
|
||||||
nodeId: string
|
nodeId: string,
|
||||||
|
ports: Port[]
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const nodes = await getNodes();
|
const nodes = await getNodes();
|
||||||
|
|
||||||
@@ -32,13 +35,22 @@ export async function deployApp(
|
|||||||
|
|
||||||
if (!node) throw new Error("Node not found");
|
if (!node) throw new Error("Node not found");
|
||||||
|
|
||||||
return await (
|
const app = await getApp(appId);
|
||||||
|
if (!app) throw new Error("App not found");
|
||||||
|
|
||||||
|
const deployId = await (
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`http://${node.ip}:3000/api/deploy`,
|
`http://${node.ip}:3000/api/deploy`,
|
||||||
{ name, appId },
|
{ name, app, ports },
|
||||||
{
|
{
|
||||||
headers: { Authorization: `ApiKey ${node.key}` },
|
headers: { Authorization: `ApiKey ${node.key}` },
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
).data.deployId;
|
).data.deployId;
|
||||||
|
|
||||||
|
app.deployments.push(deployId);
|
||||||
|
|
||||||
|
editApp(appId, app);
|
||||||
|
|
||||||
|
return deployId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { client } from "@/core/auth/oauth";
|
import { client } from "@/core/auth/oauth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function redirectAuth() {
|
export default async function redirectAuth() {
|
||||||
|
// Get headers of the incoming request
|
||||||
|
const hdrs = await headers();
|
||||||
|
|
||||||
|
// Get protocol and host
|
||||||
|
const host = hdrs.get("host");
|
||||||
|
const proto = hdrs.get("x-forwarded-proto") || "http";
|
||||||
|
|
||||||
|
const baseUrl = `${proto}://${host}`;
|
||||||
|
|
||||||
const authorizationUri = client.authorizeURL({
|
const authorizationUri = client.authorizeURL({
|
||||||
redirect_uri: "http://localhost:3000/api/auth/callback",
|
redirect_uri: `${baseUrl}/api/auth/callback`,
|
||||||
scope: "user:email",
|
scope: "user:email",
|
||||||
state: "random_state_string",
|
state: "random_state_string",
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-1
@@ -52,9 +52,9 @@ export interface Application {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
nodeId: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
deployments: string[];
|
deployments: string[];
|
||||||
|
env: Record<string, EnvVariable>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Deployment {
|
export interface Deployment {
|
||||||
@@ -88,3 +88,9 @@ export interface Env {
|
|||||||
name: string;
|
name: string;
|
||||||
variables: Record<string, EnvVariable>;
|
variables: Record<string, EnvVariable>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Port {
|
||||||
|
containerPort: string;
|
||||||
|
hostPort: string;
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user