Merge pull request #14 from orus-dev/patches-and-ui

Patches and UI
This commit is contained in:
2026-02-14 14:55:42 +01:00
committed by GitHub
18 changed files with 540 additions and 214 deletions
+203 -17
View File
@@ -21,6 +21,7 @@
"@types/dockerode": "^3.3.47", "@types/dockerode": "^3.3.47",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/simple-oauth2": "^5.0.8", "@types/simple-oauth2": "^5.0.8",
"@types/tar-fs": "^2.0.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -39,7 +40,8 @@
"simple-git": "^3.30.0", "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",
"tar-fs": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -2989,6 +2991,25 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/tar-fs": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.4.tgz",
"integrity": "sha512-ipPec0CjTmVDWE+QKr9cTmIIoTl7dFG/yARCM5MqK8i6CNLIG1P8x4kwDsOQY1ChZOZjH0wO9nvfgBvWl4R3kA==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/tar-stream": "*"
}
},
"node_modules/@types/tar-stream": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz",
"integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3051,6 +3072,111 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/b4a": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz",
"integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz",
"integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz",
"integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz",
"integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"streamx": "^2.21.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3504,6 +3630,34 @@
"node": ">= 8.0" "node": ">= 8.0"
} }
}, },
"node_modules/dockerode/node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/dockerode/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/dom-helpers": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -3617,6 +3771,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/execa": { "node_modules/execa": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@@ -3649,6 +3812,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -5014,6 +5183,17 @@
"nan": "^2.23.0" "nan": "^2.23.0"
} }
}, },
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -5142,31 +5322,37 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.4", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0", "pump": "^3.0.0",
"tar-stream": "^2.1.4" "tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
} }
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "2.2.0", "version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bl": "^4.0.3", "b4a": "^1.6.4",
"end-of-stream": "^1.4.1", "fast-fifo": "^1.2.0",
"fs-constants": "^1.0.0", "streamx": "^2.15.0"
"inherits": "^2.0.3", }
"readable-stream": "^3.1.1"
}, },
"engines": { "node_modules/text-decoder": {
"node": ">=6" "version": "1.2.6",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz",
"integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
} }
}, },
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
+3 -1
View File
@@ -21,6 +21,7 @@
"@types/dockerode": "^3.3.47", "@types/dockerode": "^3.3.47",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/simple-oauth2": "^5.0.8", "@types/simple-oauth2": "^5.0.8",
"@types/tar-fs": "^2.0.4",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -39,7 +40,8 @@
"simple-git": "^3.30.0", "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",
"tar-fs": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+8 -6
View File
@@ -1,16 +1,18 @@
import axios from "axios"; import axios from "axios";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export async function GET(request: Request) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const code = searchParams.get("code"); const code = searchParams.get("code");
console.log(request.url, request.nextUrl);
if (!code) { if (!code) {
return NextResponse.json( return NextResponse.json(
{ error: "No code provided" }, { error: "No code provided" },
{ status: StatusCodes.BAD_REQUEST } { status: StatusCodes.BAD_REQUEST },
); );
} }
@@ -26,7 +28,7 @@ export async function GET(request: Request) {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
} },
); );
const accessToken = response.data.access_token; const accessToken = response.data.access_token;
@@ -38,12 +40,12 @@ export async function GET(request: Request) {
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
}); });
return NextResponse.redirect(new URL("/overview", request.url)); return NextResponse.redirect(`${request.nextUrl.origin}/overview`);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
return NextResponse.json( return NextResponse.json(
{ error: "Error exchanging code for access token" }, { error: "Error exchanging code for access token" },
{ status: StatusCodes.INTERNAL_SERVER_ERROR } { status: StatusCodes.INTERNAL_SERVER_ERROR },
); );
} }
} }
+29 -3
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth"; import testAuth from "../auth";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { deployApp } from "@/core/deployment"; import { deployApp, toggleDeployment } from "@/core/deployment";
import { Application, Port } from "@/lib/types"; import { Application, Port } from "@/lib/types";
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
@@ -15,14 +15,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
if (auth) { if (auth) {
return NextResponse.json( return NextResponse.json(
{ message: auth }, { message: auth },
{ status: StatusCodes.UNAUTHORIZED } { status: StatusCodes.UNAUTHORIZED },
); );
} }
if (!name || !app) { 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 },
); );
} }
@@ -33,3 +33,29 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
deployId, deployId,
}); });
} }
export async function PUT(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
const { deployId, action }: { deployId: string; action: "start" | "stop" } =
await req.json();
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED },
);
}
if (!deployId || !action) {
return NextResponse.json(
{ message: "Body missing deployId or action" },
{ status: StatusCodes.BAD_REQUEST },
);
}
await toggleDeployment(deployId, action);
return NextResponse.json({
message: "ok",
});
}
+2 -1
View File
@@ -20,9 +20,10 @@ import { useAsyncInterval } from "@/hooks/use-async";
export default function ApplicationsPage() { export default function ApplicationsPage() {
const { value: apps, setValue: setApps } = useAsyncInterval( const { value: apps, setValue: setApps } = useAsyncInterval(
"apps",
[], [],
getApps, getApps,
3000 3000,
); );
const remove = async (appId: string) => { const remove = async (appId: string) => {
+88 -73
View File
@@ -1,17 +1,23 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardTitle,
} 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 { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react"; import { Plus, Play, Pause, Square, RotateCcw } from "lucide-react";
import { Application, Deployment } from "@/lib/types"; import { getDeployments, toggleDeployment } from "@/lib/dockship/deploy";
import { getDeployments } from "@/lib/dockship/deploy";
import { getApps } 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"; import { useAsync, useAsyncInterval } from "@/hooks/use-async";
import RemoveDialog from "@/components/dialogs/Remove";
import { Application } from "@/lib/types";
const statusDot = { const statusDot = {
running: "bg-chart-2", running: "bg-chart-2",
@@ -20,20 +26,33 @@ const statusDot = {
} as const; } as const;
export default function DeploymentsPage() { export default function DeploymentsPage() {
const { value: apps } = useAsync([], getApps); const { value: apps } = useAsyncInterval("apps", [], getApps, 5000);
const { value: deployments, loading } = useAsync([], getDeployments, [apps]); const { value: deployments, loading } = useAsync(
"deployments",
[],
getDeployments,
[apps],
);
const appNameByContainer = useMemo(() => { const appByContainer = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, Application>();
for (const app of apps) { 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);
} }
} }
return map; return map;
}, [apps]); }, [apps]);
const changeDeploymentStatus = async (
deployId: string,
action: "start" | "stop",
) => {
console.log(`Toggling deployment ${deployId} with action ${action}`);
await toggleDeployment(deployId, action);
};
return ( return (
<DashboardLayout <DashboardLayout
title="Deployments" title="Deployments"
@@ -44,7 +63,7 @@ export default function DeploymentsPage() {
<Badge> <Badge>
{loading {loading
? "Loading…" ? "Loading…"
: `${deployments.length} deployment${deployments.length > 0 && 's'}`} : `${deployments.length} deployment${deployments.length > 0 && "s"}`}
</Badge> </Badge>
<DeployAppDialog> <DeployAppDialog>
@@ -55,79 +74,75 @@ export default function DeploymentsPage() {
</DeployAppDialog> </DeployAppDialog>
</div> </div>
{/* Desktop */} <div className="divide-y divide-border flex flex-col gap-5">
<div className="hidden md:block space-y-3">
{deployments.map((dep) => { {deployments.map((dep) => {
const appName =
appNameByContainer.get(dep.container) ?? "Unknown app";
return ( return (
<div <Card key={`${dep.image}-${dep.container ?? "latest"}`}>
key={`${dep.image}-${dep.container ?? "latest"}`} <CardContent>
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div className="flex items-center gap-3">
<span
className={cn(
"size-2 rounded-full",
statusDot[dep.status as keyof typeof statusDot] ??
statusDot.stopped
)}
/>
<div>
<div className="font-mono text-sm">{appName}</div>
<div className="text-xs text-muted-foreground">
{dep.image}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm">
<Eye className="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon-sm">
<RotateCcw className="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon-sm">
<MoreVertical className="w-3 h-3" />
</Button>
</div>
</div>
);
})}
</div>
{/* Mobile */}
<div className="md:hidden divide-y divide-border">
{deployments.map((dep) => {
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" /> <span
<span className="font-mono text-sm">{appName}</span> className={cn(
</div> "size-2 rounded-full animate-pulse",
<span className="text-xs text-muted-foreground"> statusDot[dep.status as keyof typeof statusDot] ??
{dep.status} statusDot.stopped,
)}
/>
<CardTitle>
{dep.name} @{" "}
<span className="text-primary-foreground">
{appByContainer.get(dep.container)?.name ??
"Unknown app"}
</span> </span>
</CardTitle>
<CardDescription className="flex gap-3">
{dep.ports.map(([to, from]) => (
<span key={`${from}-${to}`}>
{from} → {to}
</span>
))}
</CardDescription>
</div> </div>
<p className="hidden xl:block text-muted-foreground">
<div className="flex justify-end gap-1 pt-2"> {dep.image}
<Button variant="ghost" size="icon-sm"> </p>
<Eye className="w-3 h-3" /> <div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={async () =>
await changeDeploymentStatus(
dep.container,
dep.status === "stopped" ? "start" : "stop",
)
}
>
{dep.status === "stopped" ? (
<Play className="w-3 h-3" />
) : dep.status === "running" ? (
<Square className="w-3 h-3" />
) : (
<Square className="w-3 h-3 text-primary" />
)}
</Button> </Button>
<DeployAppDialog
defaultApp={appByContainer.get(dep.container)?.id}
ports={dep.ports.map(([to, from]) => ({
containerPort: from.toString(),
hostPort: to.toString(),
protocol: "tcp",
}))}
nodeId={dep.nodeId}
>
<Button variant="ghost" size="icon-sm"> <Button variant="ghost" size="icon-sm">
<RotateCcw className="w-3 h-3" /> <RotateCcw className="w-3 h-3" />
</Button> </Button>
<Button variant="ghost" size="icon-sm"> </DeployAppDialog>
<MoreVertical className="w-3 h-3" /> <RemoveDialog remove={() => {}} />
</Button>
</div> </div>
</div> </div>
</CardContent>
</Card>
); );
})} })}
</div> </div>
+7 -2
View File
@@ -21,11 +21,16 @@ import { useAsync } from "@/hooks/use-async";
export default function EnvironmentPage() { export default function EnvironmentPage() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({}); const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv); const { value: envGroups, setValue: setEnvGroups } = useAsync(
"env",
{},
getEnv,
);
const { value: selectedApp, setValue: setSelectedApp } = useAsync( const { value: selectedApp, setValue: setSelectedApp } = useAsync(
"selectedApp",
"", "",
async () => Object.keys(envGroups)[0] || "", async () => Object.keys(envGroups)[0] || "",
[envGroups] [envGroups],
); );
const handleSave = async () => { const handleSave = async () => {
+37 -6
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, 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";
@@ -14,19 +14,43 @@ 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 containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">( const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">(
"all" "all",
); );
const { value: logs, setValue: setLogs } = useAsyncInterval( const { value: logs, setValue: setLogs } = useAsyncInterval(
"logs",
[], [],
async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()), async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()),
1000 1000,
); );
const filteredLogs = logs.filter((log) => const filteredLogs = logs.filter((log) =>
filter === "all" ? true : log.level === filter filter === "all" ? true : log.level === filter,
); );
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const threshold = 20; // px from bottom
const isAtBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
shouldAutoScroll.current = isAtBottom;
};
useEffect(() => {
if (!shouldAutoScroll.current || isPaused) return;
bottomRef.current?.scrollIntoView({
behavior: "smooth",
});
}, [filteredLogs, isPaused]);
const handleExport = () => { const handleExport = () => {
// Convert data to JSON string // Convert data to JSON string
const json = JSON.stringify(filteredLogs, null, 2); const json = JSON.stringify(filteredLogs, null, 2);
@@ -155,7 +179,11 @@ export default function LogsPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0 overflow-auto h-[calc(100%-48px)]"> <CardContent
className="p-0 overflow-auto h-[calc(100%-48px)]"
ref={containerRef}
onScroll={handleScroll}
>
<div className="font-mono text-xs px-3 md:px-6 space-y-2 md:space-y-0"> <div className="font-mono text-xs px-3 md:px-6 space-y-2 md:space-y-0">
{filteredLogs.map((log, index) => ( {filteredLogs.map((log, index) => (
<div <div
@@ -171,7 +199,7 @@ export default function LogsPage() {
<span <span
className={cn( className={cn(
"uppercase md:w-12 md:shrink-0 md:mx-3", "uppercase md:w-12 md:shrink-0 md:mx-3",
getLevelClass(log.level) getLevelClass(log.level),
)} )}
> >
[{log.level}] [{log.level}]
@@ -187,6 +215,9 @@ export default function LogsPage() {
{log.message} {log.message}
</span> */} </span> */}
<AnsiText text={log.message} /> <AnsiText text={log.message} />
{/* Scroll anchor */}
<div ref={bottomRef} />
</div> </div>
))} ))}
</div> </div>
+14 -6
View File
@@ -31,10 +31,17 @@ export default function MonitoringPage() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// Metrics polling every 30 seconds // Metrics polling every 30 seconds
const { value: metrics } = useAsyncInterval([], getMetrics, 30000, []); const { value: metrics } = useAsyncInterval(
"metrics",
[],
getMetrics,
30000,
[],
);
// Live nodes polling every 3 seconds // Live nodes polling every 3 seconds
const { value: liveData } = useAsyncInterval( const { value: liveData } = useAsyncInterval(
"liveNodes",
{ cpuUsage: 0, ramUsage: 0 }, { cpuUsage: 0, ramUsage: 0 },
async () => { async () => {
const nodes = await getLiveNodes(); const nodes = await getLiveNodes();
@@ -44,11 +51,12 @@ export default function MonitoringPage() {
}; };
}, },
3000, 3000,
[] [],
); );
// Container stats // Container stats
const { value: containerStats } = useAsync( const { value: containerStats } = useAsync(
"containerStats",
[], [],
async () => { async () => {
const nodes = await getNodes(); const nodes = await getNodes();
@@ -61,18 +69,18 @@ export default function MonitoringPage() {
const stats = await getContainerStats(c.Id); const stats = await getContainerStats(c.Id);
if (!stats) return undefined; if (!stats) return undefined;
return { ...stats, name: c.Names[0] }; return { ...stats, name: c.Names[0] };
}) }),
) ),
) )
).filter((s): s is SimpleStats & { name: string } => s !== undefined); ).filter((s): s is SimpleStats & { name: string } => s !== undefined);
}, },
[] [],
); );
const containerCPU = containerStats.reduce((acc, val) => acc + val.cpu, 0); const containerCPU = containerStats.reduce((acc, val) => acc + val.cpu, 0);
const containerMemory = containerStats.reduce( const containerMemory = containerStats.reduce(
(acc, val) => acc + val.memory, (acc, val) => acc + val.memory,
0 0,
); );
return ( return (
+8 -4
View File
@@ -35,14 +35,18 @@ function DiskUsage({ liveData }: NodeLiveData) {
export default function NodesPage() { export default function NodesPage() {
const { value: nodes, setValue: setNodes } = useAsyncInterval( const { value: nodes, setValue: setNodes } = useAsyncInterval(
"liveNodes",
[], [],
getLiveNodes, getLiveNodes,
3000 3000,
); );
const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ const { value: dockerNodes } = useAsync(
nodes, "docker",
]); [],
async () => getDocker(nodes),
[nodes],
);
return ( return (
<DashboardLayout title="Nodes" subtitle="Cluster node management"> <DashboardLayout title="Nodes" subtitle="Cluster node management">
+26 -28
View File
@@ -5,26 +5,11 @@ import { StatCard } from "@/components/dashboard/StatCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } 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 { Box, Server, Container, ArrowRight, Rocket } from "lucide-react";
Box,
Server,
Container,
Activity,
ArrowRight,
Clock,
Rocket,
} 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 "@/lib/dockship/node"; import { getLiveNodes } from "@/lib/dockship/node";
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import {
Application,
Deployment,
Docker,
Node,
NodeLiveData,
} from "@/lib/types";
import { getDocker } from "@/lib/dockship/docker"; import { getDocker } from "@/lib/dockship/docker";
import { getApps } from "@/lib/dockship/application"; import { getApps } from "@/lib/dockship/application";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -32,15 +17,24 @@ import { getDeployments } from "@/lib/dockship/deploy";
import { useAsync, useAsyncInterval } from "@/hooks/use-async"; import { useAsync, useAsyncInterval } from "@/hooks/use-async";
export default function OverviewPage() { export default function OverviewPage() {
const { value: nodes } = useAsyncInterval([], getLiveNodes, 3000); const { value: nodes } = useAsyncInterval(
const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ "liveNodes",
nodes, [],
]); getLiveNodes,
const { value: apps } = useAsyncInterval([], getApps, 3000); 3000,
);
const { value: dockerNodes } = useAsync(
"docker",
[],
async () => getDocker(nodes),
[nodes],
);
const { value: apps } = useAsyncInterval("apps", [], getApps, 3000);
const { value: deployments } = useAsyncInterval( const { value: deployments } = useAsyncInterval(
"deployments",
[], [],
async () => (await getDeployments()).filter((d) => d !== null), async () => (await getDeployments()).filter((d) => d !== null),
3000 3000,
); );
const appNameByContainer = useMemo(() => { const appNameByContainer = useMemo(() => {
@@ -67,8 +61,8 @@ export default function OverviewPage() {
/> />
<StatCard <StatCard
title="Deployments" title="Deployments"
value="0" value={deployments.length}
subtitle="0 running" subtitle={`${deployments.filter((d) => d.status === "running").length} running`}
icon={<Rocket className="w-4 h-4" />} icon={<Rocket className="w-4 h-4" />}
/> />
<StatCard <StatCard
@@ -82,7 +76,7 @@ export default function OverviewPage() {
value={dockerNodes.reduce( value={dockerNodes.reduce(
(sum, n) => (sum, n) =>
sum + n.containers.filter((c) => c.State === "running").length, sum + n.containers.filter((c) => c.State === "running").length,
0 0,
)} )}
subtitle="running" subtitle="running"
icon={<Container className="w-4 h-4" />} icon={<Container className="w-4 h-4" />}
@@ -164,12 +158,16 @@ export default function OverviewPage() {
"size-2 rounded-full", "size-2 rounded-full",
(dep.status === "running" && "bg-chart-2") || (dep.status === "running" && "bg-chart-2") ||
(dep.status === "pending" && "bg-chart-1") || (dep.status === "pending" && "bg-chart-1") ||
"bg-slate-400 dark:bg-slate-600" "bg-slate-400 dark:bg-slate-600",
)} )}
/> />
<div> <div>
<div className="font-mono text-sm"> <div className="font-mono text-sm">
{appNameByContainer.get(dep.container)} {dep.name} @{" "}
<span className="text-primary-foreground">
{appNameByContainer.get(dep.container) ??
"Unknown app"}
</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{dep.image.slice(7, 19)} {dep.image.slice(7, 19)}
+8 -4
View File
@@ -24,17 +24,21 @@ import { Plus } from "lucide-react";
export default function DeployAppDialog({ export default function DeployAppDialog({
defaultApp, defaultApp,
children, children,
ports: initialPorts,
nodeId: defaultNode,
}: { }: {
defaultApp?: string; defaultApp?: string;
nodeId?: string;
ports?: Port[];
children: ReactNode; children: ReactNode;
}) { }) {
const { value: nodes } = useAsync([], getLiveNodes); const { value: nodes } = useAsync("liveNodes", [], getLiveNodes);
const { value: apps } = useAsync<Application[]>([], getApps); const { value: apps } = useAsync<Application[]>("apps", [], 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(defaultNode || "");
const [ports, setPorts] = useState<Port[]>([]); const [ports, setPorts] = useState<Port[]>(initialPorts || []);
const [newPort, setNewPort] = useState<Port>({ const [newPort, setNewPort] = useState<Port>({
containerPort: "", containerPort: "",
hostPort: "", hostPort: "",
-3
View File
@@ -13,10 +13,7 @@ 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 "@/lib/dockship/node";
import { Combobox } from "../ui/combobox";
import { registerApp } from "@/lib/dockship/application"; import { registerApp } from "@/lib/dockship/application";
import { useAsync } from "@/hooks/use-async";
export default function RegisterAppDialog({ export default function RegisterAppDialog({
children, children,
+32 -18
View File
@@ -7,8 +7,8 @@ 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 { randomUUID } from "crypto";
import simpleGit from "simple-git";
import { cloneRepo } from "./git"; import { cloneRepo } from "./git";
import tar from "tar-fs";
const docker = new Docker(); const docker = new Docker();
@@ -35,7 +35,7 @@ function formatPorts(ports: Port[]) {
} }
export async function getDeployments( export async function getDeployments(
apps: Application[] apps: Application[],
): Promise<Deployment[]> { ): Promise<Deployment[]> {
const limit = pLimit(5); const limit = pLimit(5);
@@ -84,6 +84,7 @@ export async function getDeployments(
} }
return { return {
name: containerInfo.Name.replace(/^\//, ""),
image: containerInfo.Image, image: containerInfo.Image,
container: containerInfo.Id, container: containerInfo.Id,
cpu, cpu,
@@ -91,27 +92,27 @@ export async function getDeployments(
status: containerInfo.State?.Running ? "running" : "stopped", status: containerInfo.State?.Running ? "running" : "stopped",
ports: [...ports.entries()].map( ports: [...ports.entries()].map(
([publicPort, privatePort]) => ([publicPort, privatePort]) =>
[publicPort, privatePort] as [number, number] [publicPort, privatePort] as [number, number],
), ),
}; };
} catch { } catch {
// Container may have stopped / been removed // Container may have stopped / been removed
return null; return null;
} }
}) }),
) ),
); );
// Remove failed containers // Remove failed containers
return results.filter( return results.filter(
(deployment: any): deployment is Deployment => deployment !== null (deployment: any): deployment is Deployment => deployment !== null,
); );
} }
export async function deployApp( export async function deployApp(
name: string, name: string,
app: Application, app: Application,
ports: Port[] ports: Port[],
): Promise<string | undefined> { ): Promise<string | undefined> {
const deployId = randomUUID(); const deployId = randomUUID();
const deployPath = path.join("data", "deploys", deployId); const deployPath = path.join("data", "deploys", deployId);
@@ -129,17 +130,13 @@ export async function deployApp(
} }
try { try {
const stream = await docker.buildImage( const stream = await docker.buildImage(tar.pack(deployPath), {
{ t: imageTag,
context: deployPath, });
src: ["Dockerfile", "."],
},
{ t: imageTag }
);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
docker.modem.followProgress(stream, (err: Error | null) => docker.modem.followProgress(stream, (err: Error | null) =>
err ? reject(err) : resolve() err ? reject(err) : resolve(),
); );
}); });
@@ -151,13 +148,13 @@ export async function deployApp(
RestartPolicy: { Name: "unless-stopped" }, RestartPolicy: { Name: "unless-stopped" },
PortBindings: formatPorts(ports), PortBindings: formatPorts(ports),
}, },
Env: Object.entries(app.env).map( Env: Object.entries(app.env).map(([key, { value }]) => `${key}=${value}`),
([key, { value }]) => `${key}="${value.replaceAll('"', '\\"')}"`
),
}); });
await container.start(); await container.start();
await docker.pruneImages();
return container.id; return container.id;
} catch (err) { } catch (err) {
console.error("Failed to deploy app:", err); console.error("Failed to deploy app:", err);
@@ -166,3 +163,20 @@ export async function deployApp(
fs.rmSync(deployPath, { recursive: true, force: true }); fs.rmSync(deployPath, { recursive: true, force: true });
} }
} }
export async function toggleDeployment(
containerId: string,
action: "start" | "stop",
) {
const container = docker.getContainer(containerId);
try {
if (action === "start") {
await container.start();
} else {
await container.stop();
}
} catch (err) {
console.error(`Failed to ${action} container ${containerId}:`, err);
}
}
+35 -17
View File
@@ -1,20 +1,31 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export function useAsync<T>( export function useAsync<T>(
cacheKey: string,
defaultValue: T, defaultValue: T,
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
dependencies: any[] = [] dependencies: any[] = [],
) { ) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<null | Error>(null); const [error, setError] = useState<null | Error>(null);
const [value, setValue] = useState<T>(defaultValue); const [value, setValue] = useState<T>(() => {
try {
const cached = localStorage.getItem(cacheKey);
return cached ? (JSON.parse(cached) as T) : defaultValue;
} catch {
return defaultValue;
}
});
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
setLoading(true); setLoading(true);
asyncFunction() asyncFunction()
.then((result) => { .then((result) => {
if (isMounted) setValue(result); if (!isMounted) return;
setValue(result);
localStorage.setItem(cacheKey, JSON.stringify(result));
}) })
.catch((err) => { .catch((err) => {
if (isMounted) setError(err); if (isMounted) setError(err);
@@ -32,35 +43,42 @@ export function useAsync<T>(
} }
export function useAsyncInterval<T>( export function useAsyncInterval<T>(
cacheKey: string,
defaultValue: T, defaultValue: T,
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
interval: number, interval: number,
dependencies: any[] = [] dependencies: any[] = [],
) { ) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<null | Error>(null); const [error, setError] = useState<null | Error>(null);
const [value, setValue] = useState<T>(defaultValue); const [value, setValue] = useState<T>(() => {
try {
const cached = localStorage.getItem(cacheKey);
return cached ? (JSON.parse(cached) as T) : defaultValue;
} catch {
return defaultValue;
}
});
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const run = () => { const run = async () => {
setLoading(true); setLoading(true);
asyncFunction() try {
.then((result) => { const result = await asyncFunction();
if (isMounted) setValue(result); if (!isMounted) return;
}) setValue(result);
.catch((err) => { localStorage.setItem(cacheKey, JSON.stringify(result));
if (isMounted) setError(err); } catch (err) {
}) if (isMounted) setError(err as Error);
.finally(() => { } finally {
if (isMounted) setLoading(false); if (isMounted) setLoading(false);
}); }
}; };
const iid = setInterval(run, interval);
run(); run();
const iid = setInterval(run, interval);
return () => { return () => {
isMounted = false; isMounted = false;
+1 -11
View File
@@ -1,21 +1,11 @@
"use server"; "use server";
import { client } from "@/core/auth/oauth"; import { client } from "@/core/auth/oauth";
import { cookies, headers } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export async function redirectAuth() { export 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: `${baseUrl}/api/auth/callback`,
scope: "user:email", scope: "user:email",
state: "random_state_string", state: "random_state_string",
}); });
+29 -5
View File
@@ -5,7 +5,9 @@ import { getNodes } from "./node";
import axios from "axios"; import axios from "axios";
import { editApp, getApp, getApps } from "./application"; import { editApp, getApp, getApps } from "./application";
export async function getDeployments(): Promise<Deployment[]> { export async function getDeployments(): Promise<
(Deployment & { nodeId: string })[]
> {
const nodes = await getNodes(); const nodes = await getNodes();
const apps = await getApps(); const apps = await getApps();
@@ -17,8 +19,8 @@ export async function getDeployments(): Promise<Deployment[]> {
await axios.post(`http://${n.ip}:3000/api/get-deployments`, apps, { await axios.post(`http://${n.ip}:3000/api/get-deployments`, apps, {
headers: { Authorization: `ApiKey ${n.key}` }, headers: { Authorization: `ApiKey ${n.key}` },
}) })
).data.deployments ).data.deployments.map((d: any) => ({ ...d, nodeId: n.node_id })),
) ),
) )
).flat(); ).flat();
} }
@@ -27,7 +29,7 @@ export async function deployApp(
name: string, name: string,
appId: string, appId: string,
nodeId: string, nodeId: string,
ports: Port[] ports: Port[],
): Promise<string | undefined> { ): Promise<string | undefined> {
const nodes = await getNodes(); const nodes = await getNodes();
@@ -44,7 +46,7 @@ export async function deployApp(
{ name, app, ports }, { name, app, ports },
{ {
headers: { Authorization: `ApiKey ${node.key}` }, headers: { Authorization: `ApiKey ${node.key}` },
} },
) )
).data.deployId; ).data.deployId;
@@ -54,3 +56,25 @@ export async function deployApp(
return deployId; return deployId;
} }
export async function toggleDeployment(
deployId: string,
action: "start" | "stop",
) {
const nodes = await getNodes();
await Promise.all(
nodes.map(
async (node) =>
await axios.put(
`http://${node.ip}:3000/api/deploy`,
{ deployId, action },
{
headers: { Authorization: `ApiKey ${node.key}` },
},
),
),
);
return "ok";
}
+1
View File
@@ -58,6 +58,7 @@ export interface Application {
} }
export interface Deployment { export interface Deployment {
name: string;
image: string; image: string;
container: string; container: string;
cpu: number; cpu: number;