diff --git a/.gitignore b/.gitignore index 97bc0bc..d4e199e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,4 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -data \ No newline at end of file +data diff --git a/package-lock.json b/package-lock.json index 5f489f0..1b78881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^2.15.4", + "simple-git": "^3.30.0", "simple-oauth2": "^5.1.0", "systeminformation": "^5.28.2", "tailwind-merge": "^3.4.0" @@ -748,6 +749,21 @@ "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": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", @@ -4939,6 +4955,21 @@ "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": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz", diff --git a/package.json b/package.json index c2a0072..7e79ac0 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^2.15.4", + "simple-git": "^3.30.0", "simple-oauth2": "^5.1.0", "systeminformation": "^5.28.2", "tailwind-merge": "^3.4.0" diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts deleted file mode 100644 index 6dab73d..0000000 --- a/src/app/api/applications/route.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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", - }); -} diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index 6898a0c..8639ff5 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -1,32 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { deployApp, getDeployments } from "@/core/deployment"; -import { getApplications } from "@/core/application"; - -export async function GET(req: NextRequest): Promise { - 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, - }); -} +import { deployApp } from "@/core/deployment"; +import { Application, Port } from "@/lib/types"; export async function POST(req: NextRequest): Promise { 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) { return NextResponse.json( @@ -35,14 +19,14 @@ export async function POST(req: NextRequest): Promise { ); } - if (!name || !appId) { + if (!name || !app) { return NextResponse.json( { message: "Body missing name, appId, nodeId" }, { status: StatusCodes.BAD_REQUEST } ); } - const deployId = await deployApp(name, appId); + const deployId = await deployApp(name, app, ports); return NextResponse.json({ message: "ok", diff --git a/src/app/api/env/route.ts b/src/app/api/env/route.ts deleted file mode 100644 index bff743a..0000000 --- a/src/app/api/env/route.ts +++ /dev/null @@ -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 { - 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 { - 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 }); -} diff --git a/src/app/api/get-deployments/route.ts b/src/app/api/get-deployments/route.ts new file mode 100644 index 0000000..5be519f --- /dev/null +++ b/src/app/api/get-deployments/route.ts @@ -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 { + 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, + }); +} diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 8fa0c73..041c584 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -12,51 +12,42 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { MoreVertical, Plus, Rocket } from "lucide-react"; -import InstallApplicationDialog from "@/components/dialogs/InstallApplication"; -import { useEffect, useState } from "react"; -import { getApplications, removeApp } from "@/lib/dockship/application"; +import RegisterAppDialog from "@/components/dialogs/RegisterApp"; +import { getApps, removeApp } from "@/lib/dockship/application"; import RemoveDialog from "@/components/dialogs/Remove"; -import { Application } from "@/lib/types"; import DeployAppDialog from "@/components/dialogs/DeployApp"; +import { useAsyncInterval } from "@/hooks/use-async"; export default function ApplicationsPage() { - const [applications, setApplications] = useState([]); - - useEffect(() => { - const fetch = async () => { - const apps = await getApplications(); - setApplications(apps); - }; - - fetch(); - - const iid = setInterval(fetch, 3000); - return () => clearInterval(iid); - }, []); + const { value: apps, setValue: setApps } = useAsyncInterval( + [], + getApps, + 3000 + ); const remove = async (appId: string) => { await removeApp(appId); - setApplications((prev) => prev.filter((app) => app.id !== appId)); + setApps((prev) => prev.filter((app) => app.id !== appId)); }; return ( {/* Header */}
- {applications.length} apps + {apps.length} apps
- + - +
- {applications.map((app) => ( + {apps.map((app) => ( {app.name} diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index 70c3922..19cf303 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -8,9 +8,10 @@ import { Button } from "@/components/ui/button"; import { Rocket, RotateCcw, Eye, MoreVertical, Plus } from "lucide-react"; import { Application, Deployment } from "@/lib/types"; import { getDeployments } from "@/lib/dockship/deploy"; -import { getApplications } from "@/lib/dockship/application"; +import { getApps } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; import DeployAppDialog from "@/components/dialogs/DeployApp"; +import { useAsync } from "@/hooks/use-async"; const statusDot = { running: "bg-chart-2", @@ -19,46 +20,19 @@ const statusDot = { } as const; export default function DeploymentsPage() { - const [applications, setApplications] = useState([]); - const [deployments, setDeployments] = useState([]); - const [loading, setLoading] = useState(true); + const { value: apps } = useAsync([], getApps); - useEffect(() => { - 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 { value: deployments, loading } = useAsync([], getDeployments, [apps]); const appNameByContainer = useMemo(() => { const map = new Map(); - for (const app of applications) { + for (const app of apps) { for (const container of app.deployments) { map.set(container, app.name); } } return map; - }, [applications]); + }, [apps]); return ( - {loading ? "Loading…" : `${deployments.length} deployments`} + {loading + ? "Loading…" + : `${deployments.length} deployment${deployments.length > 0 && 's'}`} diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index 2db907e..458b577 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -16,22 +16,17 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ChevronDown } from "lucide-react"; +import { useAsync } from "@/hooks/use-async"; export default function EnvironmentPage() { - const [selectedApp, setSelectedApp] = useState(""); - const [showSecrets, setShowSecrets] = useState>({}); - const [envGroups, setEnvGroups] = useState>({}); const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - const fetch = async () => { - const env = await getEnv(); - setEnvGroups(env); - const vars = Object.keys(env); - if (vars.length > 0) setSelectedApp(vars[0]); - }; - fetch(); - }, []); + const [showSecrets, setShowSecrets] = useState>({}); + const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv); + const { value: selectedApp, setValue: setSelectedApp } = useAsync( + "", + async () => Object.keys(envGroups)[0] || "", + [envGroups] + ); const handleSave = async () => { setIsSaving(true); diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index 9509bc0..69b9444 100644 --- a/src/app/logs/page.tsx +++ b/src/app/logs/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { Log } from "@/lib/types"; import { getAllContainerLogs } from "@/lib/dockship/docker"; +import { useAsyncInterval } from "@/hooks/use-async"; +import { AnsiText } from "@/components/ansi/Ansi"; export default function LogsPage() { const [isPaused, setIsPaused] = useState(false); const [filter, setFilter] = useState<"all" | "info" | "warn" | "error">( "all" ); - const [logs, setLogs] = useState([]); - - useEffect(() => { - const fetch = async () => { - if (!isPaused) setLogs(await getAllContainerLogs()); - }; - - const iid = setInterval(fetch, 1000); - - fetch(); - - return () => clearInterval(iid); - }, []); + const { value: logs, setValue: setLogs } = useAsyncInterval( + [], + async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()), + 1000 + ); const filteredLogs = logs.filter((log) => filter === "all" ? true : log.level === filter @@ -189,9 +183,10 @@ export default function LogsPage() { {/* Message */} - + {/* {log.message} - + */} +
))} diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 74b3a30..f290113 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -6,13 +6,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig } from "@/components/ui/chart"; import GradientAreaChart from "@/components/GradientAreaChart"; import { getMetrics } from "@/lib/dockship/metrics"; -import { useEffect, useState } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import RadialChart from "@/components/RadialChart"; import { getLiveNodes, getNodes } from "@/lib/dockship/node"; import { average } from "@/lib/format"; import { getContainerStats, getDocker } from "@/lib/dockship/docker"; import { SimpleStats } from "@/lib/types"; +import { useAsync, useAsyncInterval } from "@/hooks/use-async"; const cpuChartConfig = { cpu: { label: "CPU Usage", color: "var(--chart-1)" }, @@ -28,74 +28,52 @@ const networkChartConfig = { } satisfies ChartConfig; export default function MonitoringPage() { - const [data, setData] = useState< - { - time: string; - cpu: number; - memory: number; - in: number; - out: number; - }[] - >([]); const isMobile = useIsMobile(); - const [liveData, setLiveData] = useState<{ - cpuUsage: number; - ramUsage: number; - }>(); - const [containerStats, SetContainerStats] = useState< - (SimpleStats & { name: string })[] - >([]); - useEffect(() => { - const fetchNodes = async () => { + // Metrics polling every 30 seconds + 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(); - setLiveData({ + return { cpuUsage: average(nodes, (n) => n.liveData?.cpu.usage || 0), ramUsage: average(nodes, (n) => n.liveData?.memory.usage || 0), - }); - }; - fetchNodes(); - const interval = setInterval(fetchNodes, 3000); - return () => clearInterval(interval); - }, []); + }; + }, + 3000, + [] + ); - useEffect(() => { - async function fetchMetrics() { - setData(await getMetrics()); - } - - fetchMetrics(); - - const i = setInterval(fetchMetrics, 30000); - - return () => clearInterval(i); - }, []); - - useEffect(() => { - async function fetchContainerMetrics() { + // Container stats + const { value: containerStats } = useAsync( + [], + async () => { const nodes = await getNodes(); const dockerNodes = await getDocker(nodes); - SetContainerStats( - ( - await Promise.all( - dockerNodes.flatMap((node) => - node.containers.map(async (c) => { - const stats = await getContainerStats(c.Id); - - if (stats === undefined) return undefined; - - return { ...stats, name: c.Names[0] }; - }) - ) + return ( + await Promise.all( + dockerNodes.flatMap((node) => + node.containers.map(async (c) => { + const stats = await getContainerStats(c.Id); + if (!stats) 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); - } - fetchContainerMetrics(); - }, []); + const containerCPU = containerStats.reduce((acc, val) => acc + val.cpu, 0); + const containerMemory = containerStats.reduce( + (acc, val) => acc + val.memory, + 0 + ); return ( {/* Summary Stats */} {isMobile ? ( -
- - +
+ + CPU - - + + RAM + + + con CPU + + + + + con RAM + +
) : (
@@ -124,7 +112,7 @@ export default function MonitoringPage() { CPU usage
- {liveData?.cpuUsage?.toFixed(0) || 0}% + {liveData?.cpuUsage?.toFixed(1) || 0}%
@@ -135,7 +123,7 @@ export default function MonitoringPage() { Memory usage
- {liveData?.ramUsage?.toFixed(0) || 0}% + {liveData?.ramUsage?.toFixed(1) || 0}%
@@ -143,21 +131,19 @@ export default function MonitoringPage() {
- Network In + Container CPU usage
-
38 MB/s
-
↑ 12% from avg
+
{containerCPU.toFixed(1)}%
+
- Network Out -
-
28 MB/s
-
- ↓ 5% from avg + Container memory usage
+
{containerMemory.toFixed(1)}%
+
@@ -167,13 +153,13 @@ export default function MonitoringPage() {
@@ -183,7 +169,7 @@ export default function MonitoringPage() {
diff --git a/src/app/nodes/page.tsx b/src/app/nodes/page.tsx index 7beaa07..70df860 100644 --- a/src/app/nodes/page.tsx +++ b/src/app/nodes/page.tsx @@ -14,6 +14,7 @@ import { setNodes as updateNodes } from "@/lib/dockship/node"; import AddNode from "@/components/dialogs/AddNode"; import { getDocker } from "@/lib/dockship/docker"; import RemoveDialog from "@/components/dialogs/Remove"; +import { useAsync, useAsyncInterval } from "@/hooks/use-async"; function DiskUsage({ liveData }: NodeLiveData) { if (!liveData) return null; @@ -33,25 +34,15 @@ function DiskUsage({ liveData }: NodeLiveData) { } export default function NodesPage() { - const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); - const [dockerNodes, setDockerNodes] = useState([]); + const { value: nodes, setValue: setNodes } = useAsyncInterval( + [], + getLiveNodes, + 3000 + ); - useEffect(() => { - const fetchNodes = async () => { - 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]); + const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ + nodes, + ]); return ( diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx index 868b291..b20c6f0 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -26,29 +26,22 @@ import { NodeLiveData, } from "@/lib/types"; import { getDocker } from "@/lib/dockship/docker"; -import { getApplications } from "@/lib/dockship/application"; +import { getApps } from "@/lib/dockship/application"; import { cn } from "@/lib/utils"; import { getDeployments } from "@/lib/dockship/deploy"; +import { useAsync, useAsyncInterval } from "@/hooks/use-async"; export default function OverviewPage() { - const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); - const [dockerNodes, setDockerNodes] = useState([]); - const [apps, setApplications] = useState([]); - const [deployments, setDeployments] = useState([]); - - useEffect(() => { - const fetchNodes = async () => { - const nodes = await getLiveNodes(); - setNodes(nodes); - setDockerNodes(await getDocker(nodes)); - setApplications(await getApplications()); - setDeployments((await getDeployments()).filter((d) => d !== null)); - }; - fetchNodes(); - const interval = setInterval(fetchNodes, 3000); - - return () => clearInterval(interval); - }, []); + const { value: nodes } = useAsyncInterval([], getLiveNodes, 3000); + const { value: dockerNodes } = useAsync([], async () => getDocker(nodes), [ + nodes, + ]); + const { value: apps } = useAsyncInterval([], getApps, 3000); + const { value: deployments } = useAsyncInterval( + [], + async () => (await getDeployments()).filter((d) => d !== null), + 3000 + ); const appNameByContainer = useMemo(() => { const map = new Map(); @@ -138,13 +131,9 @@ export default function OverviewPage() { {app.name}
-
+
{app.deployments.length}
- -
- {nodes.find((n) => n.node_id === app.nodeId)?.name ?? "—"} -
)) )} diff --git a/src/components/ansi/Ansi.tsx b/src/components/ansi/Ansi.tsx new file mode 100644 index 0000000..d62789f --- /dev/null +++ b/src/components/ansi/Ansi.tsx @@ -0,0 +1,50 @@ +import { tokenizeAnsi } from "./tokenize"; + +type AnsiTextProps = { + text: string; + className?: string; +}; + +const ANSI_TW_CLASSES: Record = { + 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 ( + + {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 ( + + {token.text} + + ); + })} + + ); +} diff --git a/src/components/ansi/parser.ts b/src/components/ansi/parser.ts new file mode 100644 index 0000000..3daa603 --- /dev/null +++ b/src/components/ansi/parser.ts @@ -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 = { + 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; +} diff --git a/src/components/ansi/tokenize.ts b/src/components/ansi/tokenize.ts new file mode 100644 index 0000000..654b4a6 --- /dev/null +++ b/src/components/ansi/tokenize.ts @@ -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; +} diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx index 1b7688a..56351ab 100644 --- a/src/components/dialogs/DeployApp.tsx +++ b/src/components/dialogs/DeployApp.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, ReactNode, useEffect } from "react"; +import { useState, ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -15,9 +15,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; -import { Application, Node, NodeLiveData } from "@/lib/types"; -import { getApplications } from "@/core/application"; import { deployApp } from "@/lib/dockship/deploy"; +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({ defaultApp, @@ -26,24 +28,32 @@ export default function DeployAppDialog({ defaultApp?: string; children: ReactNode; }) { - const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); - const [apps, setApps] = useState([]); - + const { value: nodes } = useAsync([], getLiveNodes); + const { value: apps } = useAsync([], getApps); const [name, setName] = useState(""); const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); - useEffect(() => { - const fetchNodes = async () => { - setNodeList(await getLiveNodes()); - setApps(await getApplications()); - }; - fetchNodes(); - }, []); + const [ports, setPorts] = useState([]); + const [newPort, setNewPort] = useState({ + containerPort: "", + hostPort: "", + protocol: "tcp", + }); + + 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 () => { - console.log(name, app, node); - await deployApp(name, app, node); + console.log("Deploying:", { name, app, node, ports }); + await deployApp(name, app, node, ports); }; return ( @@ -56,6 +66,7 @@ export default function DeployAppDialog({
+ {/* Name */}
+ {/* Application */}
+ {/* Node */}
+ + {/* Ports */} +
+ +
+ + setNewPort({ ...newPort, containerPort: e.target.value }) + } + /> + + setNewPort({ ...newPort, hostPort: e.target.value }) + } + /> + + setNewPort({ + ...newPort, + protocol: v as "tcp" | "udp", + }) + } + data={[ + { label: "TCP", value: "tcp" }, + { label: "UDP", value: "udp" }, + ]} + placeholder="Select protocol..." + listPlaceholder="No protocols found :(" + /> + +
+ + {ports.length > 0 && ( +
    + {ports.map((p, i) => ( +
  • + + {p.containerPort} → {p.hostPort} ( + {p.protocol.toUpperCase()}) + + +
  • + ))} +
+ )} +
diff --git a/src/components/dialogs/InstallApplication.tsx b/src/components/dialogs/RegisterApp.tsx similarity index 62% rename from src/components/dialogs/InstallApplication.tsx rename to src/components/dialogs/RegisterApp.tsx index 47b242c..955914e 100644 --- a/src/components/dialogs/InstallApplication.tsx +++ b/src/components/dialogs/RegisterApp.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, ReactNode, useEffect } from "react"; +import { useState, ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -15,28 +15,19 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; -import { Node, NodeLiveData } from "@/lib/types"; -import { installApp } from "@/lib/dockship/application"; +import { registerApp } from "@/lib/dockship/application"; +import { useAsync } from "@/hooks/use-async"; -export default function InstallApplicationDialog({ +export default function RegisterAppDialog({ children, }: { children: ReactNode; }) { - const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); const [name, setName] = useState(""); const [repo, setRepo] = useState(""); - const [node, setNode] = useState(""); - - useEffect(() => { - const fetchNodes = async () => { - setNodeList(await getLiveNodes()); - }; - fetchNodes(); - }, []); const deploy = async () => { - console.log(await installApp(name, repo, node)); + console.log(await registerApp(name, repo)); }; return ( @@ -45,7 +36,7 @@ export default function InstallApplicationDialog({ - Install Application + Register Application
@@ -72,21 +63,6 @@ export default function InstallApplicationDialog({ placeholder="https://github.com/me/myrepo.git" />
- -
- - node.liveData) - .map((node) => ({ label: node.name, value: node.node_id }))} - placeholder="Select Node..." - listPlaceholder="No nodes found :(" - /> -
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index 6c6c953..85bbb49 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -43,13 +43,13 @@ export function Combobox({ variant="outline" role="combobox" aria-expanded={open} - className="w-50 justify-between" + className="justify-between" > {value ? data.find((itm) => itm.value === value)?.label : placeholder} - + diff --git a/src/core/application.ts b/src/core/application.ts deleted file mode 100644 index 6d5b58b..0000000 --- a/src/core/application.ts +++ /dev/null @@ -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 { - 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> { - 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 = JSON.parse( - fs.readFileSync(envPath, "utf-8") - ); - acc[appId] = { variables: envData, id: appId, name }; - } - return acc; - }, {} as Record); -} - -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 }); -} diff --git a/src/core/deployment.ts b/src/core/deployment.ts index cd7c007..99f6e7b 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -1,11 +1,14 @@ "use server"; import Docker from "dockerode"; -import { Application, Deployment } from "@/lib/types"; +import { Application, Deployment, Port } from "@/lib/types"; import { CPUusage } from "@/lib/server/calc"; import path from "path"; import fs from "fs"; import pLimit from "p-limit"; +import { randomUUID } from "crypto"; +import simpleGit from "simple-git"; +import { cloneRepo } from "./git"; const docker = new Docker(); @@ -17,10 +20,23 @@ function sanitizeString(str: string): string { ); } +function formatPorts(ports: Port[]) { + const dockerPorts: Record = {}; + + 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( apps: Application[] ): Promise { - // Limit concurrent Docker calls (adjust if needed) const limit = pLimit(5); const deploymentIds = apps.flatMap((app) => app.deployments); @@ -94,23 +110,28 @@ export async function getDeployments( export async function deployApp( name: string, - appId: string + app: Application, + ports: Port[] ): Promise { - const appPath = path.join("data", "apps", appId, "repo"); - const imageTag = `dockship/${appId}:latest`; + const deployId = randomUUID(); + const deployPath = path.join("data", "deploys", deployId); + const imageTag = `dockship/${deployId}:latest`; + + // Clone the repo + await cloneRepo(app.repo, deployPath); try { // Check if app exists - fs.accessSync(appPath); + fs.accessSync(deployPath); } catch { - console.error("Repo does not exist:", appPath); + console.error("Repo does not exist:", deployPath); return undefined; } try { const stream = await docker.buildImage( { - context: appPath, + context: deployPath, src: ["Dockerfile", "."], }, { t: imageTag } @@ -128,10 +149,11 @@ export async function deployApp( Tty: true, HostConfig: { RestartPolicy: { Name: "unless-stopped" }, - PortBindings: { - "3000/tcp": [{ HostPort: "3800" }], - }, + PortBindings: formatPorts(ports), }, + Env: Object.entries(app.env).map( + ([key, { value }]) => `${key}="${value.replaceAll('"', '\\"')}"` + ), }); await container.start(); @@ -140,5 +162,7 @@ export async function deployApp( } catch (err) { console.error("Failed to deploy app:", err); return undefined; + } finally { + fs.rmSync(deployPath, { recursive: true, force: true }); } } diff --git a/src/core/docker.ts b/src/core/docker.ts index e8e203a..42c0101 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -67,36 +67,52 @@ function demuxLogs(buffer: Buffer): string { return output; } - async function getContainerLogs( containerId: string, name: string ): Promise { const container = docker.getContainer(containerId); - const raw = await container.logs({ - stdout: true, - stderr: true, - timestamps: true, - }); + // Inspect the container to check the logging driver + const info = await container.inspect(); + const driver = info.HostConfig.LogConfig?.Type; - const text = demuxLogs(raw); + if (driver !== "json-file" && driver !== "journald") { + // Skip containers with unsupported logging drivers + return []; + } - 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; + try { + const raw = await container.logs({ + stdout: true, + stderr: true, + timestamps: true, }); + + const text = demuxLogs(raw); + + return text + .split("\n") + .filter(Boolean) + .map((line) => { + const firstSpace = line.indexOf(" "); + const timestamp = line.slice(0, firstSpace); + const message = line.slice(firstSpace + 1); + + return { + timestamp, + level: inferLevel(message), + source: name, + message, + } satisfies Log; + }); + } 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 { diff --git a/src/core/git.ts b/src/core/git.ts new file mode 100644 index 0000000..5a1ac0d --- /dev/null +++ b/src/core/git.ts @@ -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"]); +} diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts new file mode 100644 index 0000000..260b37c --- /dev/null +++ b/src/hooks/use-async.ts @@ -0,0 +1,72 @@ +import { useState, useEffect } from "react"; + +export function useAsync( + defaultValue: T, + asyncFunction: () => Promise, + dependencies: any[] = [] +) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(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( + defaultValue: T, + asyncFunction: () => Promise, + interval: number, + dependencies: any[] = [] +) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(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 }; +} diff --git a/src/lib/dockship/application.ts b/src/lib/dockship/application.ts index 9ac56ea..064f8ed 100644 --- a/src/lib/dockship/application.ts +++ b/src/lib/dockship/application.ts @@ -1,88 +1,99 @@ "use server"; import { verifySession } from "../../core/auth/session"; -import { getNodes } from "@/lib/dockship/node"; -import axios from "axios"; import { Env, EnvVariable, Application } from "@/lib/types"; +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 { if (await verifySession()) { throw new Error("Unauthorized"); } - const nodes = await getNodes(); - - const targetNode = nodes.find((node) => node.node_id === nodeId); - - return await ( - await axios.post( - `http://${targetNode?.ip}:3000/api/applications`, - { name, repo, nodeId }, - { - headers: { Authorization: `ApiKey ${targetNode?.key}` }, - } - ) - ).data.applications; + return read(() => [], "data", "apps.json"); } -export async function getApplications(): Promise { - const nodes = await getNodes(); +export async function getApp(appId: string): Promise { + if (await verifySession()) { + throw new Error("Unauthorized"); + } - return ( - await Promise.all( - nodes.map( - async (n) => - await ( - await axios.get(`http://${n.ip}:3000/api/applications`, { - headers: { Authorization: `ApiKey ${n.key}` }, - }) - ).data.applications - ) - ) - ).flat(); + return (await read(() => [], "data", "apps.json")).find( + (app) => app.id === appId + ); +} + +export async function registerApp(name: string, repo: string) { + if (await verifySession()) { + throw new Error("Unauthorized"); + } + + const app: Application = { + 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> { - const nodes = await getNodes(); + if (await verifySession()) { + throw new Error("Unauthorized"); + } - const envRecords = await Promise.all( - nodes.map(async (n) => { - const response = await axios.get(`http://${n.ip}:3000/api/env`, { - headers: { Authorization: `ApiKey ${n.key}` }, - }); - return response.data as Record; - }) - ); + const apps = await getApps(); - const merged: Record = Object.assign({}, ...envRecords); + return apps.reduce>((acc, app) => { + acc[app.id] = { + id: app.id, + name: app.name, + variables: app.env, + }; - return merged; + return acc; + }, {}); } export async function setEnv( appId: string, variables: Record ) { - const nodes = await getNodes(); + if (await verifySession()) { + throw new Error("Unauthorized"); + } - nodes.forEach(async (n) => { - await axios.post( - `http://${n.ip}:3000/api/env`, - { appId, variables }, - { - headers: { Authorization: `ApiKey ${n.key}` }, - } - ); - }); -} - -export async function removeApp(appId: string) { - const nodes = await getNodes(); - - nodes.forEach(async (n) => { - await axios.delete(`http://${n.ip}:3000/api/applications`, { - params: { appId }, - headers: { Authorization: `ApiKey ${n.key}` }, - }); - }); + const apps = await getApps(); + const app = apps.find((app) => app.id === appId); + if (!app) throw new Error("App not found"); + editApp(appId, { ...app, env: variables }); } diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 0f6155e..0a39828 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -1,21 +1,23 @@ "use server"; -import { Deployment } from "@/lib/types"; +import { Deployment, Port } from "@/lib/types"; import { getNodes } from "./node"; import axios from "axios"; +import { editApp, getApp, getApps } from "./application"; -export async function getDeployments(): Promise<(null | Deployment)[]> { +export async function getDeployments(): Promise { const nodes = await getNodes(); + const apps = await getApps(); return ( await Promise.all( nodes.map( async (n) => 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}` }, }) - ).data.applications + ).data.deployments ) ) ).flat(); @@ -24,7 +26,8 @@ export async function getDeployments(): Promise<(null | Deployment)[]> { export async function deployApp( name: string, appId: string, - nodeId: string + nodeId: string, + ports: Port[] ): Promise { const nodes = await getNodes(); @@ -32,13 +35,22 @@ export async function deployApp( 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( `http://${node.ip}:3000/api/deploy`, - { name, appId }, + { name, app, ports }, { headers: { Authorization: `ApiKey ${node.key}` }, } ) ).data.deployId; + + app.deployments.push(deployId); + + editApp(appId, app); + + return deployId; } diff --git a/src/lib/dockship/redirect.ts b/src/lib/dockship/redirect.ts index b3ed3f7..19208dc 100644 --- a/src/lib/dockship/redirect.ts +++ b/src/lib/dockship/redirect.ts @@ -1,11 +1,21 @@ "use server"; import { client } from "@/core/auth/oauth"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; 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({ - redirect_uri: "http://localhost:3000/api/auth/callback", + redirect_uri: `${baseUrl}/api/auth/callback`, scope: "user:email", state: "random_state_string", }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0691c4a..a7bfac6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,9 +52,9 @@ export interface Application { id: string; name: string; repo: string; - nodeId: string; createdAt: string; deployments: string[]; + env: Record; } export interface Deployment { @@ -88,3 +88,9 @@ export interface Env { name: string; variables: Record; } + +export interface Port { + containerPort: string; + hostPort: string; + protocol: "tcp" | "udp"; +}