Merge pull request #13 from orus-dev/restructure

Restructure
This commit is contained in:
Klesti Selimaj
2026-01-03 22:51:09 +01:00
committed by GitHub
29 changed files with 690 additions and 624 deletions
+31
View File
@@ -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",
+1
View File
@@ -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"
-74
View File
@@ -1,74 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { installApp, getApplications, removeApp } from "@/core/application";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
const applications = await getApplications();
return NextResponse.json({
message: "ok",
applications,
});
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
const { name, repo, nodeId }: { name: string; repo: string; nodeId: string } =
await req.json();
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
if (!name || !repo || !nodeId) {
return NextResponse.json(
{ message: "Body missing name, repo, nodeId" },
{ status: StatusCodes.BAD_REQUEST }
);
}
const res = await installApp(name, repo, nodeId);
return NextResponse.json({
message: "ok",
...res,
});
}
export async function DELETE(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
const appId = req.nextUrl.searchParams.get("appId");
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
if (!appId) {
return NextResponse.json(
{ message: "Missing param appId" },
{ status: StatusCodes.BAD_REQUEST }
);
}
await removeApp(appId);
return NextResponse.json({
message: "ok",
});
}
+9 -25
View File
@@ -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<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
const apps = await getApplications();
const applications = await getDeployments(apps);
return NextResponse.json({
message: "ok",
applications,
});
}
import { deployApp } from "@/core/deployment";
import { Application, Port } from "@/lib/types";
export async function POST(req: NextRequest): Promise<NextResponse> {
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<NextResponse> {
);
}
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",
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { getEnv, setEnv } from "@/core/application";
export async function GET(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
return NextResponse.json(await getEnv());
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
const { appId, variables } = await req.json();
if (!appId || !variables) {
return NextResponse.json(
{ message: "Missing appId, variables in body" },
{ status: StatusCodes.BAD_REQUEST }
);
}
await setEnv(appId, variables);
return NextResponse.json({ message: "ok", success: true });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import testAuth from "../auth";
import { StatusCodes } from "http-status-codes";
import { getDeployments } from "@/core/deployment";
export async function POST(req: NextRequest): Promise<NextResponse> {
const auth = await testAuth(req);
const apps = await req.json();
if (auth) {
return NextResponse.json(
{ message: auth },
{ status: StatusCodes.UNAUTHORIZED }
);
}
if (!apps)
return NextResponse.json(
{ message: "Body should be Application[]" },
{ status: StatusCodes.UNAUTHORIZED }
);
const deployments = await getDeployments(apps);
return NextResponse.json({
message: "ok",
deployments,
});
}
+15 -24
View File
@@ -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<Application[]>([]);
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 (
<DashboardLayout
title="Applications"
subtitle="Manage installed applications"
subtitle="Manage dockship applications"
>
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-6">
<div className="flex flex-wrap items-center gap-2">
<Badge>{applications.length} apps</Badge>
<Badge>{apps.length} apps</Badge>
</div>
<InstallApplicationDialog>
<RegisterAppDialog>
<Button size="sm" className="gap-2 w-full sm:w-auto">
<Plus className="w-4 h-4" /> Install Application
<Plus className="w-4 h-4" /> Register Application
</Button>
</InstallApplicationDialog>
</RegisterAppDialog>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{applications.map((app) => (
{apps.map((app) => (
<Card key={app.id}>
<CardHeader>
<CardTitle>{app.name}</CardTitle>
+9 -33
View File
@@ -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<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]);
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<string, string>();
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 (
<DashboardLayout
@@ -68,7 +42,9 @@ export default function DeploymentsPage() {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<Badge>
{loading ? "Loading…" : `${deployments.length} deployments`}
{loading
? "Loading…"
: `${deployments.length} deployment${deployments.length > 0 && 's'}`}
</Badge>
<DeployAppDialog>
+8 -13
View File
@@ -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<Record<string, boolean>>({});
const [envGroups, setEnvGroups] = useState<Record<string, Env>>({});
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<Record<string, boolean>>({});
const { value: envGroups, setValue: setEnvGroups } = useAsync({}, getEnv);
const { value: selectedApp, setValue: setSelectedApp } = useAsync(
"",
async () => Object.keys(envGroups)[0] || "",
[envGroups]
);
const handleSave = async () => {
setIsSaving(true);
+11 -16
View File
@@ -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<Log[]>([]);
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() {
</span>
{/* Message */}
<span className="text-foreground wrap-break-words">
{/* <span className="text-foreground wrap-break-words">
{log.message}
</span>
</span> */}
<AnsiText text={log.message} />
</div>
))}
</div>
+53 -67
View File
@@ -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(
(
return (
await Promise.all(
dockerNodes.flatMap((node) =>
node.containers.map(async (c) => {
const stats = await getContainerStats(c.Id);
if (stats === undefined) return undefined;
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 (
<DashboardLayout
@@ -104,17 +82,27 @@ export default function MonitoringPage() {
>
{/* Summary Stats */}
{isMobile ? (
<div className="grid grid-cols-2 gap-4 mb-6">
<Card>
<CardContent className="px-4">
<div className="grid grid-cols-4 gap-4 mb-6">
<Card className="p-0 flex items-center justify-center">
<CardContent className="p-1 flex items-center justify-center">
<RadialChart value={liveData?.cpuUsage || 0}>CPU</RadialChart>
</CardContent>
</Card>
<Card>
<CardContent className="px-4">
<Card className="p-0 flex items-center justify-center">
<CardContent className="p-1 flex items-center justify-center">
<RadialChart value={liveData?.ramUsage || 0}>RAM</RadialChart>
</CardContent>
</Card>
<Card className="p-0 flex items-center justify-center">
<CardContent className="p-1 flex items-center justify-center">
<RadialChart value={containerCPU}>con CPU</RadialChart>
</CardContent>
</Card>
<Card className="p-0 flex items-center justify-center">
<CardContent className="p-1 flex items-center justify-center">
<RadialChart value={containerMemory}>con RAM</RadialChart>
</CardContent>
</Card>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@@ -124,7 +112,7 @@ export default function MonitoringPage() {
CPU usage
</div>
<div className="stat-value">
{liveData?.cpuUsage?.toFixed(0) || 0}%
{liveData?.cpuUsage?.toFixed(1) || 0}%
</div>
<Progress value={liveData?.cpuUsage} className="w-full" />
</CardContent>
@@ -135,7 +123,7 @@ export default function MonitoringPage() {
Memory usage
</div>
<div className="stat-value">
{liveData?.ramUsage?.toFixed(0) || 0}%
{liveData?.ramUsage?.toFixed(1) || 0}%
</div>
<Progress value={liveData?.ramUsage} className="w-full" />
</CardContent>
@@ -143,21 +131,19 @@ export default function MonitoringPage() {
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network In
Container CPU usage
</div>
<div className="stat-value">38 MB/s</div>
<div className="text-xs text-success mt-1">↑ 12% from avg</div>
<div className="stat-value">{containerCPU.toFixed(1)}%</div>
<Progress value={containerCPU} className="w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Network Out
</div>
<div className="stat-value">28 MB/s</div>
<div className="text-xs text-muted-foreground mt-1">
↓ 5% from avg
Container memory usage
</div>
<div className="stat-value">{containerMemory.toFixed(1)}%</div>
<Progress value={containerMemory} className="w-full" />
</CardContent>
</Card>
</div>
@@ -167,13 +153,13 @@ export default function MonitoringPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<GradientAreaChart
title="CPU Usage"
data={data}
data={metrics}
config={cpuChartConfig}
max={100}
/>
<GradientAreaChart
title="Memory Usage"
data={data}
data={metrics}
config={memoryChartConfig}
max={100}
/>
@@ -183,7 +169,7 @@ export default function MonitoringPage() {
<div className="mb-6">
<GradientAreaChart
title="Network I/O"
data={data}
data={metrics}
config={networkChartConfig}
/>
</div>
+9 -18
View File
@@ -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<Docker[]>([]);
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 (
<DashboardLayout title="Nodes" subtitle="Cluster node management">
+13 -24
View File
@@ -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<Docker[]>([]);
const [apps, setApplications] = useState<Application[]>([]);
const [deployments, setDeployments] = useState<Deployment[]>([]);
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<string, string>();
@@ -138,13 +131,9 @@ export default function OverviewPage() {
{app.name}
</div>
<div className="col-span-4 text-muted-foreground">
<div className="col-span-7 text-muted-foreground">
{app.deployments.length}
</div>
<div className="col-span-3 truncate">
{nodes.find((n) => n.node_id === app.nodeId)?.name ?? "—"}
</div>
</div>
))
)}
+50
View File
@@ -0,0 +1,50 @@
import { tokenizeAnsi } from "./tokenize";
type AnsiTextProps = {
text: string;
className?: string;
};
const ANSI_TW_CLASSES: Record<string, string> = {
black: "text-black",
red: "text-red-500",
green: "text-emerald-500",
yellow: "text-yellow-400",
blue: "text-blue-400",
magenta: "text-purple-400",
cyan: "text-cyan-400",
white: "text-gray-100",
brightBlack: "text-gray-500",
brightRed: "text-red-400",
brightGreen: "text-emerald-400",
brightYellow: "text-yellow-300",
brightBlue: "text-blue-300",
brightMagenta: "text-purple-300",
brightCyan: "text-cyan-300",
brightWhite: "text-white",
};
export function AnsiText({ text, className }: AnsiTextProps) {
const tokens = tokenizeAnsi(text);
return (
<span className={`whitespace-pre-wrap font-mono ${className ?? ""}`}>
{tokens.map((token, i) => {
const classes = [
token.style.color && ANSI_TW_CLASSES[token.style.color],
token.style.bold && "font-semibold",
token.style.underline && "underline",
]
.filter(Boolean)
.join(" ");
return (
<span key={i} className={classes}>
{token.text}
</span>
);
})}
</span>
);
}
+45
View File
@@ -0,0 +1,45 @@
export type StyleState = {
color?: string;
bold?: boolean;
underline?: boolean;
};
export const ANSI_REGEX = /\u001b\[([0-9;]*)m/g;
const COLOR_CODES: Record<number, string> = {
30: "black",
31: "red",
32: "green",
33: "yellow",
34: "blue",
35: "magenta",
36: "cyan",
37: "white",
90: "brightBlack",
91: "brightRed",
92: "brightGreen",
93: "brightYellow",
94: "brightBlue",
95: "brightMagenta",
96: "brightCyan",
97: "brightWhite",
};
export function applyAnsiCodes(codes: number[], state: StyleState): StyleState {
let next = { ...state };
for (const code of codes) {
if (code === 0) {
next = {};
} else if (code === 1) {
next.bold = true;
} else if (code === 4) {
next.underline = true;
} else if (COLOR_CODES[code]) {
next.color = COLOR_CODES[code];
}
}
return next;
}
+39
View File
@@ -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;
}
+87 -15
View File
@@ -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<Application[]>([]);
const { value: nodes } = useAsync([], getLiveNodes);
const { value: apps } = useAsync<Application[]>([], getApps);
const [name, setName] = useState("");
const [app, setApp] = useState(defaultApp || "");
const [node, setNode] = useState("");
useEffect(() => {
const fetchNodes = async () => {
setNodeList(await getLiveNodes());
setApps(await getApplications());
const [ports, setPorts] = useState<Port[]>([]);
const [newPort, setNewPort] = useState<Port>({
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));
};
fetchNodes();
}, []);
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({
</DialogHeader>
<div className="grid gap-4">
{/* Name */}
<div className="grid gap-3">
<Label htmlFor="name">
Deployment name <span className="text-destructive">*</span>
@@ -68,6 +79,7 @@ export default function DeployAppDialog({
/>
</div>
{/* Application */}
<div className="grid gap-3">
<Label htmlFor="repo">
Application <span className="text-destructive">*</span>
@@ -84,6 +96,7 @@ export default function DeployAppDialog({
/>
</div>
{/* Node */}
<div className="grid gap-3">
<Label htmlFor="node">
Node <span className="text-destructive">*</span>
@@ -91,13 +104,72 @@ export default function DeployAppDialog({
<Combobox
value={node}
setValue={setNode}
data={nodeList
data={nodes
.filter((node) => node.liveData)
.map((node) => ({ label: node.name, value: node.node_id }))}
placeholder="Select Node..."
listPlaceholder="No nodes found :("
/>
</div>
{/* Ports */}
<div className="grid gap-3">
<Label>Ports</Label>
<div className="flex gap-2 items-end">
<Input
placeholder="Container Port"
value={newPort.containerPort}
onChange={(e) =>
setNewPort({ ...newPort, containerPort: e.target.value })
}
/>
<Input
placeholder="Host Port"
value={newPort.hostPort}
onChange={(e) =>
setNewPort({ ...newPort, hostPort: e.target.value })
}
/>
<Combobox
value={newPort.protocol}
setValue={(v) =>
setNewPort({
...newPort,
protocol: v as "tcp" | "udp",
})
}
data={[
{ label: "TCP", value: "tcp" },
{ label: "UDP", value: "udp" },
]}
placeholder="Select protocol..."
listPlaceholder="No protocols found :("
/>
<Button onClick={addPort} variant="ghost">
<Plus />
</Button>
</div>
{ports.length > 0 && (
<ul className="mt-2 space-y-1">
{ports.map((p, i) => (
<li key={i} className="flex justify-between items-center">
<span>
{p.containerPort} → {p.hostPort} (
{p.protocol.toUpperCase()})
</span>
<Button
variant="destructive"
size="sm"
onClick={() => removePort(i)}
>
Remove
</Button>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
@@ -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({
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>Install Application</DialogTitle>
<DialogTitle>Register Application</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
@@ -72,21 +63,6 @@ export default function InstallApplicationDialog({
placeholder="https://github.com/me/myrepo.git"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="node">
Node <span className="text-destructive">*</span>
</Label>
<Combobox
value={node}
setValue={setNode}
data={nodeList
.filter((node) => node.liveData)
.map((node) => ({ label: node.name, value: node.node_id }))}
placeholder="Select Node..."
listPlaceholder="No nodes found :("
/>
</div>
</div>
<DialogFooter>
+2 -2
View File
@@ -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}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-50 p-0">
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder="Search framework..." className="h-9" />
<CommandList>
-128
View File
@@ -1,128 +0,0 @@
"use server";
import Docker from "dockerode";
import { randomUUID } from "crypto";
import fs from "fs";
import path from "path";
import { exec } from "child_process";
import util from "util";
import { Application, Env, EnvVariable } from "@/lib/types";
const docker = new Docker();
const execAsync = util.promisify(exec);
const DATA_DIR = path.join(process.cwd(), "data", "apps");
export async function getApplications(): Promise<Application[]> {
return fs
.readdirSync(DATA_DIR)
.map((appId) =>
JSON.parse(
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
)
);
}
export async function installApp(name: string, repo: string, nodeId: string) {
// Create app ID
const appId = `app_${randomUUID()}`;
const appDir = path.join(DATA_DIR, appId);
const repoDir = path.join(appDir, "repo");
// Create directories
fs.mkdirSync(repoDir, { recursive: true });
// Write app.json
const appConfig: Application = {
id: appId,
name,
repo,
nodeId,
createdAt: new Date().toISOString(),
deployments: [],
};
fs.writeFileSync(
path.join(appDir, "app.json"),
JSON.stringify(appConfig, null, 2)
);
// Write env.json
fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], null, 2));
// Clone repository
try {
await execAsync(`git clone ${repo} ${repoDir}`);
} catch (err) {
throw new Error("Failed to clone repository");
}
// Validate Dockerfile
const dockerfilePath = path.join(repoDir, "Dockerfile");
try {
fs.accessSync(dockerfilePath);
} catch {
fs.rmSync(appDir, { recursive: true });
throw new Error("Dockerfile not found in repository root");
}
return {
success: true,
appId,
};
}
export async function getEnv(): Promise<Record<string, Env>> {
const apps = fs.readdirSync(DATA_DIR);
return apps.reduce((acc, appId) => {
const appPath = path.join(DATA_DIR, appId, "app.json");
const name = JSON.parse(fs.readFileSync(appPath).toString()).name;
const envPath = path.join(DATA_DIR, appId, "env.json");
if (fs.existsSync(envPath)) {
const envData: Record<string, EnvVariable> = JSON.parse(
fs.readFileSync(envPath, "utf-8")
);
acc[appId] = { variables: envData, id: appId, name };
}
return acc;
}, {} as Record<string, Env>);
}
export async function setEnv(appId: string, variables: EnvVariable[]) {
const apps = fs.readdirSync(DATA_DIR);
apps.forEach((id) => {
if (id === appId) {
const envPath = path.join(DATA_DIR, appId, "env.json");
fs.writeFileSync(envPath, JSON.stringify(variables));
}
});
}
export async function removeApp(appId: string) {
const appPath = path.join(DATA_DIR, appId);
const appMeta: Application = JSON.parse(
fs.readFileSync(path.join(appPath, "app.json")).toString()
);
appMeta.deployments.forEach(async (imgId) => {
// Remove containers
(await docker.listContainers({ all: true }))
.filter((con) => con.ImageID === imgId)
.forEach((con) => {
const container = docker.getContainer(con.Id);
container.stop();
container.remove();
});
// Remove image
const image = docker.getImage(imgId);
image.remove();
});
fs.rmSync(appPath, { recursive: true });
}
+35 -11
View File
@@ -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<string, { HostPort: string }[]> = {};
ports.forEach(({ containerPort, hostPort, protocol }) => {
const key = `${containerPort}/${protocol}`;
if (!dockerPorts[key]) {
dockerPorts[key] = [];
}
dockerPorts[key].push({ HostPort: hostPort });
});
return dockerPorts;
}
export async function getDeployments(
apps: Application[]
): Promise<Deployment[]> {
// 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<string | undefined> {
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 });
}
}
+17 -1
View File
@@ -67,13 +67,22 @@ function demuxLogs(buffer: Buffer): string {
return output;
}
async function getContainerLogs(
containerId: string,
name: string
): Promise<Log[]> {
const container = docker.getContainer(containerId);
// Inspect the container to check the logging driver
const info = await container.inspect();
const driver = info.HostConfig.LogConfig?.Type;
if (driver !== "json-file" && driver !== "journald") {
// Skip containers with unsupported logging drivers
return [];
}
try {
const raw = await container.logs({
stdout: true,
stderr: true,
@@ -97,6 +106,13 @@ async function getContainerLogs(
message,
} satisfies Log;
});
} catch (err: any) {
if (err.statusCode === 501) {
// Logging driver does not support reading
return [];
}
throw err; // rethrow any other error
}
}
export async function getAllContainerLogs(): Promise<Log[]> {
+8
View File
@@ -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"]);
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect } from "react";
export function useAsync<T>(
defaultValue: T,
asyncFunction: () => Promise<T>,
dependencies: any[] = []
) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<null | Error>(null);
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => {
let isMounted = true;
setLoading(true);
asyncFunction()
.then((result) => {
if (isMounted) setValue(result);
})
.catch((err) => {
if (isMounted) setError(err);
})
.finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, dependencies);
return { loading, error, value, setValue };
}
export function useAsyncInterval<T>(
defaultValue: T,
asyncFunction: () => Promise<T>,
interval: number,
dependencies: any[] = []
) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<null | Error>(null);
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => {
let isMounted = true;
const run = () => {
setLoading(true);
asyncFunction()
.then((result) => {
if (isMounted) setValue(result);
})
.catch((err) => {
if (isMounted) setError(err);
})
.finally(() => {
if (isMounted) setLoading(false);
});
};
const iid = setInterval(run, interval);
run();
return () => {
isMounted = false;
clearInterval(iid);
};
}, dependencies);
return { loading, error, value, setValue };
}
+72 -61
View File
@@ -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<Application[]> {
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<Application[]> {
const nodes = await getNodes();
export async function getApp(appId: string): Promise<Application | undefined> {
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<Application[]>(() => [], "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<Record<string, Env>> {
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<string, Env>;
})
);
const apps = await getApps();
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
return apps.reduce<Record<string, Env>>((acc, app) => {
acc[app.id] = {
id: app.id,
name: app.name,
variables: app.env,
};
return merged;
return acc;
}, {});
}
export async function setEnv(
appId: string,
variables: Record<string, EnvVariable>
) {
const nodes = await getNodes();
nodes.forEach(async (n) => {
await axios.post(
`http://${n.ip}:3000/api/env`,
{ appId, variables },
{
headers: { Authorization: `ApiKey ${n.key}` },
if (await verifySession()) {
throw new Error("Unauthorized");
}
);
});
}
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 });
}
+19 -7
View File
@@ -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<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.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<string | undefined> {
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;
}
+11 -1
View File
@@ -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",
});
+7 -1
View File
@@ -52,9 +52,9 @@ export interface Application {
id: string;
name: string;
repo: string;
nodeId: string;
createdAt: string;
deployments: string[];
env: Record<string, EnvVariable>;
}
export interface Deployment {
@@ -88,3 +88,9 @@ export interface Env {
name: string;
variables: Record<string, EnvVariable>;
}
export interface Port {
containerPort: string;
hostPort: string;
protocol: "tcp" | "udp";
}