From 2cc324c5170ded7fbd37502ceb25e00bc0f89132 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 03:58:32 +0100 Subject: [PATCH 01/15] Implemented useAsync in the app structure for cleaner code --- src/app/applications/page.tsx | 21 ++--- src/app/deployments/page.tsx | 36 ++------ src/app/environment/page.tsx | 20 ++-- src/app/logs/page.tsx | 21 ++--- src/app/monitoring/page.tsx | 92 ++++++++++--------- src/app/nodes/page.tsx | 27 ++---- src/app/overview/page.tsx | 41 ++++----- src/components/dialogs/DeployApp.tsx | 21 ++--- src/components/dialogs/InstallApplication.tsx | 12 +-- src/hooks/use-async.ts | 68 ++++++++++++++ 10 files changed, 177 insertions(+), 182 deletions(-) create mode 100644 src/hooks/use-async.ts diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 8fa0c73..2b71156 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -13,26 +13,17 @@ 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 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: applications, setValue: setApplications } = useAsyncInterval( + [], + getApplications, + 3000 + ); const remove = async (appId: string) => { await removeApp(appId); diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index 70c3922..bc93051 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -11,6 +11,7 @@ import { getDeployments } from "@/lib/dockship/deploy"; import { getApplications } 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,36 +20,13 @@ const statusDot = { } as const; export default function DeploymentsPage() { - const [applications, setApplications] = useState([]); - const [deployments, setDeployments] = useState([]); - const [loading, setLoading] = useState(true); + const { value: applications } = useAsync([], getApplications); - 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( + [], + async () => (await getDeployments()).filter((d) => d !== null), + [applications] + ); const appNameByContainer = useMemo(() => { const map = new Map(); diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index 2db907e..b709700 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -16,22 +16,16 @@ 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] || "" + ); const handleSave = async () => { setIsSaving(true); diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index 9509bc0..d240717 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,18 @@ 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"; 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()), + 100 + ); const filteredLogs = logs.filter((log) => filter === "all" ? true : log.level === filter diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 74b3a30..5a86310 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -13,6 +13,7 @@ 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)" }, @@ -29,73 +30,74 @@ const networkChartConfig = { export default function MonitoringPage() { const [data, setData] = useState< - { - time: string; - cpu: number; - memory: number; - in: number; - out: number; - }[] + { 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< + const [containerStats, setContainerStats] = useState< (SimpleStats & { name: string })[] >([]); - useEffect(() => { - const fetchNodes = async () => { + // Live nodes polling every 3 seconds + const { value: liveNodes } = 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, + [] + ); + // Update liveData state useEffect(() => { - async function fetchMetrics() { - setData(await getMetrics()); - } + setLiveData(liveNodes); + }, [liveNodes]); - fetchMetrics(); - - const i = setInterval(fetchMetrics, 30000); - - return () => clearInterval(i); - }, []); + // Metrics polling every 30 seconds + const { value: metrics } = useAsyncInterval( + [] as typeof data, + getMetrics, + 30000, + [] + ); + // Update data state useEffect(() => { - async function fetchContainerMetrics() { + setData(metrics); + }, [metrics]); + + // Container stats fetched once + const { value: containers } = useAsync( + [] as typeof containerStats, + 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(); - }, []); + useEffect(() => { + setContainerStats(containers); + }, [containers]); return ( ([]); - 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..56510c3 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -29,36 +29,29 @@ import { getDocker } from "@/lib/dockship/docker"; import { getApplications } 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: applications } = useAsyncInterval([], getApplications, 3000); + const { value: deployments } = useAsyncInterval( + [], + async () => (await getDeployments()).filter((d) => d !== null), + 3000 + ); const appNameByContainer = useMemo(() => { const map = new Map(); - for (const app of apps) { + for (const app of applications) { for (const container of app.deployments) { map.set(container, app.name); } } return map; - }, [apps]); + }, [applications]); return ( @@ -66,9 +59,9 @@ export default function OverviewPage() {
app.deployments.length > 0).length + applications.filter((app) => app.deployments.length > 0).length } Deployed`} icon={} /> @@ -118,12 +111,12 @@ export default function OverviewPage() {
{/* Rows */} - {apps.slice(-5).length === 0 ? ( + {applications.slice(-5).length === 0 ? (
No applications yet
) : ( - apps.slice(-5).map((app) => ( + applications.slice(-5).map((app) => (
([]); - const [apps, setApps] = useState([]); - + const { value: nodes } = useAsync([], getLiveNodes); + const { value: applications } = useAsync([], getApplications); const [name, setName] = useState(""); const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); - useEffect(() => { - const fetchNodes = async () => { - setNodeList(await getLiveNodes()); - setApps(await getApplications()); - }; - fetchNodes(); - }, []); - const deploy = async () => { console.log(name, app, node); await deployApp(name, app, node); @@ -75,7 +66,7 @@ export default function DeployAppDialog({ ({ + data={applications.map((app) => ({ label: app.name, value: app.id, }))} @@ -91,7 +82,7 @@ export default function DeployAppDialog({ node.liveData) .map((node) => ({ label: node.name, value: node.node_id }))} placeholder="Select Node..." diff --git a/src/components/dialogs/InstallApplication.tsx b/src/components/dialogs/InstallApplication.tsx index 47b242c..9abb5c9 100644 --- a/src/components/dialogs/InstallApplication.tsx +++ b/src/components/dialogs/InstallApplication.tsx @@ -17,24 +17,18 @@ import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; import { Node, NodeLiveData } from "@/lib/types"; import { installApp } from "@/lib/dockship/application"; +import { useAsync } from "@/hooks/use-async"; export default function InstallApplicationDialog({ children, }: { children: ReactNode; }) { - const [nodeList, setNodeList] = useState<(NodeLiveData & Node)[]>([]); + const { value: nodes } = useAsync([], getLiveNodes); 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)); }; @@ -80,7 +74,7 @@ export default function InstallApplicationDialog({ node.liveData) .map((node) => ({ label: node.name, value: node.node_id }))} placeholder="Select Node..." diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts new file mode 100644 index 0000000..65ac907 --- /dev/null +++ b/src/hooks/use-async.ts @@ -0,0 +1,68 @@ +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 iid = setInterval(() => { + setLoading(true); + asyncFunction() + .then((result) => { + if (isMounted) setValue(result); + }) + .catch((err) => { + if (isMounted) setError(err); + }) + .finally(() => { + if (isMounted) setLoading(false); + }); + }, interval); + + return () => { + isMounted = false; + clearInterval(iid); + }; + }, dependencies); + + return { loading, error, value, setValue }; +} From 6914b97faffa01d2071d0aa2d534784ee70733ba Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 03:59:57 +0100 Subject: [PATCH 02/15] Small fix in env --- src/app/environment/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index b709700..458b577 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -24,7 +24,8 @@ export default function EnvironmentPage() { const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv); const { value: selectedApp, setValue: setSelectedApp } = useAsync( "", - async () => Object.keys(envGroups)[0] || "" + async () => Object.keys(envGroups)[0] || "", + [envGroups] ); const handleSave = async () => { From 5e84c05bc7b4674e99ace34413e25ba5920d3bd7 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 04:06:31 +0100 Subject: [PATCH 03/15] Small fix in monitoring and useAsync --- src/app/monitoring/page.tsx | 49 ++++++++----------------------------- src/hooks/use-async.ts | 8 ++++-- 2 files changed, 16 insertions(+), 41 deletions(-) diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 5a86310..13f1e69 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -29,20 +29,13 @@ 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 })[] - >([]); + + // Metrics polling every 30 seconds + const { value: metrics } = useAsyncInterval([], getMetrics, 30000, []); // Live nodes polling every 3 seconds - const { value: liveNodes } = useAsyncInterval( + const { value: liveData } = useAsyncInterval( { cpuUsage: 0, ramUsage: 0 }, async () => { const nodes = await getLiveNodes(); @@ -55,27 +48,9 @@ export default function MonitoringPage() { [] ); - // Update liveData state - useEffect(() => { - setLiveData(liveNodes); - }, [liveNodes]); - - // Metrics polling every 30 seconds - const { value: metrics } = useAsyncInterval( - [] as typeof data, - getMetrics, - 30000, - [] - ); - - // Update data state - useEffect(() => { - setData(metrics); - }, [metrics]); - - // Container stats fetched once - const { value: containers } = useAsync( - [] as typeof containerStats, + // Container stats + const { value: containerStats } = useAsync( + [], async () => { const nodes = await getNodes(); const dockerNodes = await getDocker(nodes); @@ -95,10 +70,6 @@ export default function MonitoringPage() { [] ); - useEffect(() => { - setContainerStats(containers); - }, [containers]); - return ( @@ -185,7 +156,7 @@ export default function MonitoringPage() {
diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts index 65ac907..260b37c 100644 --- a/src/hooks/use-async.ts +++ b/src/hooks/use-async.ts @@ -44,7 +44,7 @@ export function useAsyncInterval( useEffect(() => { let isMounted = true; - const iid = setInterval(() => { + const run = () => { setLoading(true); asyncFunction() .then((result) => { @@ -56,7 +56,11 @@ export function useAsyncInterval( .finally(() => { if (isMounted) setLoading(false); }); - }, interval); + }; + + const iid = setInterval(run, interval); + + run(); return () => { isMounted = false; From 7e5289ae229d698dbb89a709f771d10182d8c588 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 13:06:16 +0100 Subject: [PATCH 04/15] Colors in logs --- src/app/logs/page.tsx | 6 ++-- src/components/ansi/Ansi.tsx | 50 +++++++++++++++++++++++++++++++++ src/components/ansi/parser.ts | 45 +++++++++++++++++++++++++++++ src/components/ansi/tokenize.ts | 39 +++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/components/ansi/Ansi.tsx create mode 100644 src/components/ansi/parser.ts create mode 100644 src/components/ansi/tokenize.ts diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index d240717..dd6c53d 100644 --- a/src/app/logs/page.tsx +++ b/src/app/logs/page.tsx @@ -10,6 +10,7 @@ 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); @@ -182,9 +183,10 @@ export default function LogsPage() { {/* Message */} - + {/* {log.message} - + */} +
))} 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; +} From 6da3c1a0bec33ad4d6fea864e1822981a1ef9e13 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 19:07:54 +0100 Subject: [PATCH 05/15] Removed app structure from the core --- src/app/api/applications/route.ts | 74 ---------------- src/app/api/deploy/route.ts | 5 +- src/app/api/env/route.ts | 41 --------- src/app/applications/page.tsx | 87 ------------------ src/components/dialogs/DeployApp.tsx | 4 +- src/core/application.ts | 128 --------------------------- 6 files changed, 4 insertions(+), 335 deletions(-) delete mode 100644 src/app/api/applications/route.ts delete mode 100644 src/app/api/env/route.ts delete mode 100644 src/app/applications/page.tsx delete mode 100644 src/core/application.ts 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..2478a8b 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -2,7 +2,6 @@ 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); @@ -14,9 +13,9 @@ export async function GET(req: NextRequest): Promise { ); } - const apps = await getApplications(); + // const apps = await getApplications(); - const applications = await getDeployments(apps); + const applications = await getDeployments([]); 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/applications/page.tsx b/src/app/applications/page.tsx deleted file mode 100644 index 2b71156..0000000 --- a/src/app/applications/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -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 { getApplications, removeApp } from "@/lib/dockship/application"; -import RemoveDialog from "@/components/dialogs/Remove"; -import DeployAppDialog from "@/components/dialogs/DeployApp"; -import { useAsyncInterval } from "@/hooks/use-async"; - -export default function ApplicationsPage() { - const { value: applications, setValue: setApplications } = useAsyncInterval( - [], - getApplications, - 3000 - ); - - const remove = async (appId: string) => { - await removeApp(appId); - setApplications((prev) => prev.filter((app) => app.id !== appId)); - }; - - return ( - - {/* Header */} -
-
- {applications.length} apps -
- - - -
-
- {applications.map((app) => ( - - - {app.name} - {app.repo} - - - - {/* Deployments */} -
- - Deployments - - - {app.deployments?.length ?? 0} - -
-
- - - - - - - remove(app.id)} /> - - - -
- ))} -
-
- ); -} diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx index 29bc7fe..05f67ed 100644 --- a/src/components/dialogs/DeployApp.tsx +++ b/src/components/dialogs/DeployApp.tsx @@ -15,9 +15,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; -import { getApplications } from "@/core/application"; import { deployApp } from "@/lib/dockship/deploy"; import { useAsync } from "@/hooks/use-async"; +import { Application } from "@/lib/types"; export default function DeployAppDialog({ defaultApp, @@ -27,7 +27,7 @@ export default function DeployAppDialog({ children: ReactNode; }) { const { value: nodes } = useAsync([], getLiveNodes); - const { value: applications } = useAsync([], getApplications); + const { value: applications } = useAsync([], async () => []); const [name, setName] = useState(""); const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); 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 }); -} From b39f32d006481e1e5e821106f6f7e095df2bc93a Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 19:27:25 +0100 Subject: [PATCH 06/15] Fixed app structure --- src/app/applications/page.tsx | 87 +++++++++++++++++++++++++++++++++ src/lib/dockship/application.ts | 75 +++++++++++++--------------- src/lib/types.ts | 1 + 3 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 src/app/applications/page.tsx diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx new file mode 100644 index 0000000..2b71156 --- /dev/null +++ b/src/app/applications/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { DashboardLayout } from "@/components/dashboard/DashboardLayout"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +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 { getApplications, removeApp } from "@/lib/dockship/application"; +import RemoveDialog from "@/components/dialogs/Remove"; +import DeployAppDialog from "@/components/dialogs/DeployApp"; +import { useAsyncInterval } from "@/hooks/use-async"; + +export default function ApplicationsPage() { + const { value: applications, setValue: setApplications } = useAsyncInterval( + [], + getApplications, + 3000 + ); + + const remove = async (appId: string) => { + await removeApp(appId); + setApplications((prev) => prev.filter((app) => app.id !== appId)); + }; + + return ( + + {/* Header */} +
+
+ {applications.length} apps +
+ + + +
+
+ {applications.map((app) => ( + + + {app.name} + {app.repo} + + + + {/* Deployments */} +
+ + Deployments + + + {app.deployments?.length ?? 0} + +
+
+ + + + + + + remove(app.id)} /> + + + +
+ ))} +
+
+ ); +} diff --git a/src/lib/dockship/application.ts b/src/lib/dockship/application.ts index 9ac56ea..479af46 100644 --- a/src/lib/dockship/application.ts +++ b/src/lib/dockship/application.ts @@ -4,6 +4,7 @@ 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"; export async function installApp(name: string, repo: string, nodeId: string) { if (await verifySession()) { @@ -26,63 +27,53 @@ export async function installApp(name: string, repo: string, nodeId: string) { } export async function getApplications(): Promise { - const nodes = await getNodes(); + 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 read(() => [], "data", "apps.json"); } export async function getEnv(): Promise> { - const nodes = await getNodes(); + const apps = await getApplications(); - 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; - }) - ); + return apps.reduce>((acc, app) => { + acc[app.id] = { + id: app.id, + name: app.name, + variables: app.env, + }; - const merged: Record = Object.assign({}, ...envRecords); - - 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}` }, - } - ); - }); + const apps = await getApplications(); + const app = apps.find((app) => app.id === appId); + if (!app) throw new Error("App not found"); + editApp(appId, { ...app, env: variables }); } export async function removeApp(appId: string) { - const nodes = await getNodes(); + const apps = await getApplications(); - nodes.forEach(async (n) => { - await axios.delete(`http://${n.ip}:3000/api/applications`, { - params: { appId }, - headers: { Authorization: `ApiKey ${n.key}` }, - }); - }); + const nextApps = apps.filter((app) => app.id !== appId); + + await write(nextApps, "data", "apps.json"); +} + +export async function editApp(appId: string, updatedApp: Application) { + const apps = await getApplications(); + + const nextApps = apps.map((app) => (app.id === appId ? updatedApp : app)); + + await write(nextApps, "data", "apps.json"); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0691c4a..7853d2d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,6 +55,7 @@ export interface Application { nodeId: string; createdAt: string; deployments: string[]; + env: Record; } export interface Deployment { From 1a1dc90c011bf273343602c8dbeb44335da24dfb Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 19:51:39 +0100 Subject: [PATCH 07/15] Shortened applictions for apps in code --- src/app/api/deploy/route.ts | 6 +++--- src/app/applications/page.tsx | 17 +++++++---------- src/app/deployments/page.tsx | 10 +++++----- src/app/overview/page.tsx | 16 ++++++++-------- src/components/dialogs/DeployApp.tsx | 4 ++-- src/lib/dockship/application.ts | 12 ++++++------ src/lib/dockship/deploy.ts | 2 +- 7 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index 2478a8b..1297a7d 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -13,13 +13,13 @@ export async function GET(req: NextRequest): Promise { ); } - // const apps = await getApplications(); + // const apps = await getApps(); - const applications = await getDeployments([]); + const apps = await getDeployments([]); return NextResponse.json({ message: "ok", - applications, + apps, }); } diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index 2b71156..c61e2f9 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -13,32 +13,29 @@ 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 { getApplications, removeApp } from "@/lib/dockship/application"; +import { getApps, removeApp } from "@/lib/dockship/application"; import RemoveDialog from "@/components/dialogs/Remove"; import DeployAppDialog from "@/components/dialogs/DeployApp"; import { useAsyncInterval } from "@/hooks/use-async"; export default function ApplicationsPage() { - const { value: applications, setValue: setApplications } = useAsyncInterval( + const { value: apps, setValue: setApps } = useAsyncInterval( [], - getApplications, + 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 bc93051..ad54567 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -8,7 +8,7 @@ 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"; @@ -20,23 +20,23 @@ const statusDot = { } as const; export default function DeploymentsPage() { - const { value: applications } = useAsync([], getApplications); + const { value: apps } = useAsync([], getApps); const { value: deployments, loading } = useAsync( [], async () => (await getDeployments()).filter((d) => d !== null), - [applications] + [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 ( getDocker(nodes), [ nodes, ]); - const { value: applications } = useAsyncInterval([], getApplications, 3000); + const { value: apps } = useAsyncInterval([], getApps, 3000); const { value: deployments } = useAsyncInterval( [], async () => (await getDeployments()).filter((d) => d !== null), @@ -45,13 +45,13 @@ export default function OverviewPage() { 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 ( @@ -59,9 +59,9 @@ export default function OverviewPage() {
app.deployments.length > 0).length + apps.filter((app) => app.deployments.length > 0).length } Deployed`} icon={} /> @@ -111,12 +111,12 @@ export default function OverviewPage() {
{/* Rows */} - {applications.slice(-5).length === 0 ? ( + {apps.slice(-5).length === 0 ? (
No applications yet
) : ( - applications.slice(-5).map((app) => ( + apps.slice(-5).map((app) => (
({ + data={apps.map((app) => ({ label: app.name, value: app.id, }))} diff --git a/src/lib/dockship/application.ts b/src/lib/dockship/application.ts index 479af46..db7c4a4 100644 --- a/src/lib/dockship/application.ts +++ b/src/lib/dockship/application.ts @@ -23,10 +23,10 @@ export async function installApp(name: string, repo: string, nodeId: string) { headers: { Authorization: `ApiKey ${targetNode?.key}` }, } ) - ).data.applications; + ).data.apps; } -export async function getApplications(): Promise { +export async function getApps(): Promise { if (await verifySession()) { throw new Error("Unauthorized"); } @@ -35,7 +35,7 @@ export async function getApplications(): Promise { } export async function getEnv(): Promise> { - const apps = await getApplications(); + const apps = await getApps(); return apps.reduce>((acc, app) => { acc[app.id] = { @@ -56,14 +56,14 @@ export async function setEnv( throw new Error("Unauthorized"); } - const apps = await getApplications(); + 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 }); } export async function removeApp(appId: string) { - const apps = await getApplications(); + const apps = await getApps(); const nextApps = apps.filter((app) => app.id !== appId); @@ -71,7 +71,7 @@ export async function removeApp(appId: string) { } export async function editApp(appId: string, updatedApp: Application) { - const apps = await getApplications(); + const apps = await getApps(); const nextApps = apps.map((app) => (app.id === appId ? updatedApp : app)); diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 0f6155e..37c27b3 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -15,7 +15,7 @@ export async function getDeployments(): Promise<(null | Deployment)[]> { await axios.get(`http://${n.ip}:3000/api/deploy`, { headers: { Authorization: `ApiKey ${n.key}` }, }) - ).data.applications + ).data.apps ) ) ).flat(); From 61a9e1d8d99e446bc496b298f8f332699c09e69f Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 20:01:42 +0100 Subject: [PATCH 08/15] Register application --- src/app/applications/page.tsx | 13 +-- src/app/overview/page.tsx | 6 +- ...InstallApplication.tsx => RegisterApp.tsx} | 28 ++---- src/lib/dockship/application.ts | 86 +++++++++++-------- src/lib/types.ts | 1 - 5 files changed, 62 insertions(+), 72 deletions(-) rename src/components/dialogs/{InstallApplication.tsx => RegisterApp.tsx} (66%) diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx index c61e2f9..041c584 100644 --- a/src/app/applications/page.tsx +++ b/src/app/applications/page.tsx @@ -12,7 +12,7 @@ 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 RegisterAppDialog from "@/components/dialogs/RegisterApp"; import { getApps, removeApp } from "@/lib/dockship/application"; import RemoveDialog from "@/components/dialogs/Remove"; import DeployAppDialog from "@/components/dialogs/DeployApp"; @@ -31,17 +31,20 @@ export default function ApplicationsPage() { }; return ( - + {/* Header */}
{apps.length} apps
- + - +
{apps.map((app) => ( diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx index 97c3074..b20c6f0 100644 --- a/src/app/overview/page.tsx +++ b/src/app/overview/page.tsx @@ -131,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/dialogs/InstallApplication.tsx b/src/components/dialogs/RegisterApp.tsx similarity index 66% rename from src/components/dialogs/InstallApplication.tsx rename to src/components/dialogs/RegisterApp.tsx index 9abb5c9..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,22 +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 { value: nodes } = useAsync([], getLiveNodes); const [name, setName] = useState(""); const [repo, setRepo] = useState(""); - const [node, setNode] = useState(""); const deploy = async () => { - console.log(await installApp(name, repo, node)); + console.log(await registerApp(name, repo)); }; return ( @@ -39,7 +36,7 @@ export default function InstallApplicationDialog({ - Install Application + Register Application
@@ -66,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/lib/dockship/application.ts b/src/lib/dockship/application.ts index db7c4a4..c7049c3 100644 --- a/src/lib/dockship/application.ts +++ b/src/lib/dockship/application.ts @@ -1,30 +1,9 @@ "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"; - -export async function installApp(name: string, repo: string, nodeId: string) { - if (await verifySession()) { - throw new Error("Unauthorized"); - } - - const nodes = await getNodes(); - - const targetNode = nodes.find((node) => node.node_id === nodeId); - - return await ( - await axios.post( - `http://${targetNode?.ip}:3000/api/applications`, - { name, repo, nodeId }, - { - headers: { Authorization: `ApiKey ${targetNode?.key}` }, - } - ) - ).data.apps; -} +import { randomUUID } from "crypto"; export async function getApps(): Promise { if (await verifySession()) { @@ -34,7 +13,54 @@ export async function getApps(): Promise { return read(() => [], "data", "apps.json"); } +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> { + if (await verifySession()) { + throw new Error("Unauthorized"); + } + const apps = await getApps(); return apps.reduce>((acc, app) => { @@ -61,19 +87,3 @@ export async function setEnv( if (!app) throw new Error("App not found"); editApp(appId, { ...app, env: variables }); } - -export async function removeApp(appId: string) { - 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) { - const apps = await getApps(); - - const nextApps = apps.map((app) => (app.id === appId ? updatedApp : app)); - - await write(nextApps, "data", "apps.json"); -} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7853d2d..3642591 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,7 +52,6 @@ export interface Application { id: string; name: string; repo: string; - nodeId: string; createdAt: string; deployments: string[]; env: Record; From 07a37f92df9775273f79336c81883dd463fd333c Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 20:15:58 +0100 Subject: [PATCH 09/15] Deployment interface --- src/app/api/deploy/route.ts | 20 ------------------- src/app/api/get-deployments/route.ts | 29 ++++++++++++++++++++++++++++ src/components/dialogs/DeployApp.tsx | 3 ++- src/core/deployment.ts | 1 - src/lib/dockship/application.ts | 10 ++++++++++ src/lib/dockship/deploy.ts | 15 ++++++++++++-- 6 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 src/app/api/get-deployments/route.ts diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index 1297a7d..84cba7a 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -3,26 +3,6 @@ import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; import { deployApp, getDeployments } from "@/core/deployment"; -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 getApps(); - - const apps = await getDeployments([]); - - return NextResponse.json({ - message: "ok", - apps, - }); -} - export async function POST(req: NextRequest): Promise { const auth = await testAuth(req); const { name, appId }: { name: string; appId: string } = await req.json(); diff --git a/src/app/api/get-deployments/route.ts b/src/app/api/get-deployments/route.ts new file mode 100644 index 0000000..3ebd933 --- /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 { deployApp, 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/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx index 764a2f5..3a6fede 100644 --- a/src/components/dialogs/DeployApp.tsx +++ b/src/components/dialogs/DeployApp.tsx @@ -18,6 +18,7 @@ import { Combobox } from "../ui/combobox"; import { deployApp } from "@/lib/dockship/deploy"; import { useAsync } from "@/hooks/use-async"; import { Application } from "@/lib/types"; +import { getApps } from "@/lib/dockship/application"; export default function DeployAppDialog({ defaultApp, @@ -27,7 +28,7 @@ export default function DeployAppDialog({ children: ReactNode; }) { const { value: nodes } = useAsync([], getLiveNodes); - const { value: apps } = useAsync([], async () => []); + const { value: apps } = useAsync([], getApps); const [name, setName] = useState(""); const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); diff --git a/src/core/deployment.ts b/src/core/deployment.ts index cd7c007..6342f5f 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -20,7 +20,6 @@ function sanitizeString(str: string): string { 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); diff --git a/src/lib/dockship/application.ts b/src/lib/dockship/application.ts index c7049c3..064f8ed 100644 --- a/src/lib/dockship/application.ts +++ b/src/lib/dockship/application.ts @@ -13,6 +13,16 @@ export async function getApps(): Promise { return read(() => [], "data", "apps.json"); } +export async function getApp(appId: string): Promise { + if (await verifySession()) { + throw new Error("Unauthorized"); + } + + 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"); diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 37c27b3..d2df6cf 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -3,16 +3,18 @@ import { Deployment } from "@/lib/types"; import { getNodes } from "./node"; import axios from "axios"; +import { editApp, getApp, getApps } from "./application"; export async function getDeployments(): Promise<(null | Deployment)[]> { 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.apps @@ -32,7 +34,7 @@ export async function deployApp( if (!node) throw new Error("Node not found"); - return await ( + const deployId = await ( await axios.post( `http://${node.ip}:3000/api/deploy`, { name, appId }, @@ -41,4 +43,13 @@ export async function deployApp( } ) ).data.deployId; + + const app = await getApp(appId); + if (!app) throw new Error("(deployId) App not found"); + + app.deployments.push(deployId); + + editApp(appId, app); + + return deployId; } From 9d41823e87bc82d5db0f919ee2682f139a24fc0b Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 21:34:35 +0100 Subject: [PATCH 10/15] Deploy app --- .gitignore | 2 +- package-lock.json | 31 ++++++++++++++++++++++++++++ package.json | 1 + src/app/api/deploy/route.ts | 7 ++++--- src/app/api/get-deployments/route.ts | 2 +- src/app/deployments/page.tsx | 10 ++++----- src/core/deployment.ts | 21 +++++++++++++------ src/core/git.ts | 8 +++++++ src/lib/dockship/deploy.ts | 10 ++++----- 9 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 src/core/git.ts 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/deploy/route.ts b/src/app/api/deploy/route.ts index 84cba7a..13a68fa 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; import { deployApp, getDeployments } from "@/core/deployment"; +import { Application } 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 }: { name: string; app: Application } = await req.json(); if (auth) { return NextResponse.json( @@ -14,14 +15,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); return NextResponse.json({ message: "ok", diff --git a/src/app/api/get-deployments/route.ts b/src/app/api/get-deployments/route.ts index 3ebd933..5be519f 100644 --- a/src/app/api/get-deployments/route.ts +++ b/src/app/api/get-deployments/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { deployApp, getDeployments } from "@/core/deployment"; +import { getDeployments } from "@/core/deployment"; export async function POST(req: NextRequest): Promise { const auth = await testAuth(req); diff --git a/src/app/deployments/page.tsx b/src/app/deployments/page.tsx index ad54567..19cf303 100644 --- a/src/app/deployments/page.tsx +++ b/src/app/deployments/page.tsx @@ -22,11 +22,7 @@ const statusDot = { export default function DeploymentsPage() { const { value: apps } = useAsync([], getApps); - const { value: deployments, loading } = useAsync( - [], - async () => (await getDeployments()).filter((d) => d !== null), - [apps] - ); + const { value: deployments, loading } = useAsync([], getDeployments, [apps]); const appNameByContainer = useMemo(() => { const map = new Map(); @@ -46,7 +42,9 @@ export default function DeploymentsPage() { {/* Header */}
- {loading ? "Loading…" : `${deployments.length} deployments`} + {loading + ? "Loading…" + : `${deployments.length} deployment${deployments.length > 0 && 's'}`} diff --git a/src/core/deployment.ts b/src/core/deployment.ts index 6342f5f..5d3ed36 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -6,6 +6,9 @@ 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(); @@ -93,23 +96,27 @@ export async function getDeployments( export async function deployApp( name: string, - appId: string + app: Application ): 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 } @@ -139,5 +146,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/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/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index d2df6cf..905132e 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -5,7 +5,7 @@ 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(); @@ -34,19 +34,19 @@ export async function deployApp( if (!node) throw new Error("Node not found"); + 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 }, { headers: { Authorization: `ApiKey ${node.key}` }, } ) ).data.deployId; - const app = await getApp(appId); - if (!app) throw new Error("(deployId) App not found"); - app.deployments.push(deployId); editApp(appId, app); From eb69cc02e54420ccc812355334c46f20b1f067f7 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 21:36:33 +0100 Subject: [PATCH 11/15] Fixed getDeployments --- src/app/api/deploy/route.ts | 2 +- src/lib/dockship/deploy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index 13a68fa..fdddf5f 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; -import { deployApp, getDeployments } from "@/core/deployment"; +import { deployApp } from "@/core/deployment"; import { Application } from "@/lib/types"; export async function POST(req: NextRequest): Promise { diff --git a/src/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index 905132e..a060348 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -17,7 +17,7 @@ export async function getDeployments(): Promise { await axios.post(`http://${n.ip}:3000/api/get-deployments`, apps, { headers: { Authorization: `ApiKey ${n.key}` }, }) - ).data.apps + ).data.deployments ) ) ).flat(); From 8f2ebaeab72eb570b9aea496d571450808f1f5cd Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 21:47:49 +0100 Subject: [PATCH 12/15] Deployment with env --- src/core/deployment.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/deployment.ts b/src/core/deployment.ts index 5d3ed36..3fe7c25 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -138,6 +138,9 @@ export async function deployApp( "3000/tcp": [{ HostPort: "3800" }], }, }, + Env: Object.entries(app.env).map( + ([key, { value }]) => `${key}="${value.replaceAll('"', '\\"')}"` + ), }); await container.start(); From 21f9f1bb56b9175e299dc4fe6e6fa29dd890e37e Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 22:16:32 +0100 Subject: [PATCH 13/15] Deployment port configuration and log fix --- src/app/api/deploy/route.ts | 10 +++- src/app/logs/page.tsx | 2 +- src/components/dialogs/DeployApp.tsx | 86 +++++++++++++++++++++++++++- src/components/ui/combobox.tsx | 4 +- src/core/deployment.ts | 23 ++++++-- src/core/docker.ts | 58 ++++++++++++------- src/lib/dockship/deploy.ts | 7 ++- src/lib/types.ts | 6 ++ 8 files changed, 158 insertions(+), 38 deletions(-) diff --git a/src/app/api/deploy/route.ts b/src/app/api/deploy/route.ts index fdddf5f..8639ff5 100644 --- a/src/app/api/deploy/route.ts +++ b/src/app/api/deploy/route.ts @@ -2,11 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import testAuth from "../auth"; import { StatusCodes } from "http-status-codes"; import { deployApp } from "@/core/deployment"; -import { Application } from "@/lib/types"; +import { Application, Port } from "@/lib/types"; export async function POST(req: NextRequest): Promise { const auth = await testAuth(req); - const { name, app }: { name: string; app: Application } = await req.json(); + const { + name, + app, + ports, + }: { name: string; app: Application; ports: Port[] } = await req.json(); if (auth) { return NextResponse.json( @@ -22,7 +26,7 @@ export async function POST(req: NextRequest): Promise { ); } - const deployId = await deployApp(name, app); + const deployId = await deployApp(name, app, ports); return NextResponse.json({ message: "ok", diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index dd6c53d..69b9444 100644 --- a/src/app/logs/page.tsx +++ b/src/app/logs/page.tsx @@ -20,7 +20,7 @@ export default function LogsPage() { const { value: logs, setValue: setLogs } = useAsyncInterval( [], async () => (isPaused ? ([] as Log[]) : getAllContainerLogs()), - 100 + 1000 ); const filteredLogs = logs.filter((log) => diff --git a/src/components/dialogs/DeployApp.tsx b/src/components/dialogs/DeployApp.tsx index 3a6fede..56351ab 100644 --- a/src/components/dialogs/DeployApp.tsx +++ b/src/components/dialogs/DeployApp.tsx @@ -17,8 +17,9 @@ import { getLiveNodes } from "@/lib/dockship/node"; import { Combobox } from "../ui/combobox"; import { deployApp } from "@/lib/dockship/deploy"; import { useAsync } from "@/hooks/use-async"; -import { Application } from "@/lib/types"; +import { Application, Port } from "@/lib/types"; import { getApps } from "@/lib/dockship/application"; +import { Plus } from "lucide-react"; export default function DeployAppDialog({ defaultApp, @@ -33,9 +34,26 @@ export default function DeployAppDialog({ const [app, setApp] = useState(defaultApp || ""); const [node, setNode] = useState(""); + 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 ( @@ -48,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/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/deployment.ts b/src/core/deployment.ts index 3fe7c25..99f6e7b 100644 --- a/src/core/deployment.ts +++ b/src/core/deployment.ts @@ -1,7 +1,7 @@ "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"; @@ -20,6 +20,20 @@ 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 { @@ -96,7 +110,8 @@ export async function getDeployments( export async function deployApp( name: string, - app: Application + app: Application, + ports: Port[] ): Promise { const deployId = randomUUID(); const deployPath = path.join("data", "deploys", deployId); @@ -134,9 +149,7 @@ 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('"', '\\"')}"` 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/lib/dockship/deploy.ts b/src/lib/dockship/deploy.ts index a060348..0a39828 100644 --- a/src/lib/dockship/deploy.ts +++ b/src/lib/dockship/deploy.ts @@ -1,6 +1,6 @@ "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"; @@ -26,7 +26,8 @@ export async function getDeployments(): Promise { export async function deployApp( name: string, appId: string, - nodeId: string + nodeId: string, + ports: Port[] ): Promise { const nodes = await getNodes(); @@ -40,7 +41,7 @@ export async function deployApp( const deployId = await ( await axios.post( `http://${node.ip}:3000/api/deploy`, - { name, app }, + { name, app, ports }, { headers: { Authorization: `ApiKey ${node.key}` }, } diff --git a/src/lib/types.ts b/src/lib/types.ts index 3642591..a7bfac6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -88,3 +88,9 @@ export interface Env { name: string; variables: Record; } + +export interface Port { + containerPort: string; + hostPort: string; + protocol: "tcp" | "udp"; +} From 07e19b29e4ad8a4af9c42562d19a6db3da9717f5 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 22:34:38 +0100 Subject: [PATCH 14/15] Improved monitoring --- src/app/monitoring/page.tsx | 45 ++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index 13f1e69..f290113 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -6,7 +6,6 @@ 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"; @@ -70,6 +69,12 @@ export default function MonitoringPage() { [] ); + 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 + +
) : (
@@ -97,7 +112,7 @@ export default function MonitoringPage() { CPU usage
- {liveData?.cpuUsage?.toFixed(0) || 0}% + {liveData?.cpuUsage?.toFixed(1) || 0}%
@@ -108,7 +123,7 @@ export default function MonitoringPage() { Memory usage
- {liveData?.ramUsage?.toFixed(0) || 0}% + {liveData?.ramUsage?.toFixed(1) || 0}%
@@ -116,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)}%
+
From 8c45fca82624e9d9d9c727e482b8d4fd3baa9a52 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Sat, 3 Jan 2026 22:48:00 +0100 Subject: [PATCH 15/15] Fixed auth --- src/lib/dockship/redirect.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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", });