Merge pull request #8 from orus-dev/simple-deploy

Simple deploy
This commit is contained in:
Klesti Selimaj
2025-12-29 21:56:55 +01:00
committed by GitHub
17 changed files with 752 additions and 222 deletions
+111
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@@ -20,6 +21,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dockerode": "^4.0.9", "dockerode": "^4.0.9",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
@@ -1322,6 +1324,99 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -2548,6 +2643,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+2
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@@ -20,6 +21,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dockerode": "^4.0.9", "dockerode": "^4.0.9",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
+4 -2
View File
@@ -4,9 +4,9 @@ import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusIndicator } from "@/components/dashboard/StatusIndicator";
import { Play, Square, RotateCcw, MoreVertical, Plus } from "lucide-react"; import { Play, Square, RotateCcw, MoreVertical, Plus } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import DeployApplication from "@/components/dialogs/DeployApplication";
const applications = [ const applications = [
{ {
@@ -98,10 +98,12 @@ export default function ApplicationsPage() {
</Badge> </Badge>
</div> </div>
<DeployApplication>
<Button size="sm" className="gap-2 w-full sm:w-auto"> <Button size="sm" className="gap-2 w-full sm:w-auto">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Deploy Application Deploy Application
</Button> </Button>
</DeployApplication>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -111,7 +113,7 @@ export default function ApplicationsPage() {
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:items-center"> <div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:items-center">
{/* Status & Name */} {/* Status & Name */}
<div className="flex items-center gap-3 md:col-span-3"> <div className="flex items-center gap-3 md:col-span-3">
<StatusIndicator status={app.status} /> {/* {app.status} */}
<div className="min-w-0"> <div className="min-w-0">
<div className="font-mono text-sm font-medium"> <div className="font-mono text-sm font-medium">
{app.name} {app.name}
+79 -124
View File
@@ -4,113 +4,80 @@ import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusIndicator } from "@/components/dashboard/StatusIndicator"; import {
import { Terminal, MoreVertical, Play, Square, Trash2 } from "lucide-react"; MoreVertical,
Play,
Square,
Trash2,
Container,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Docker } from "@/lib/types";
import { getDocker } from "@/core/docker";
import { getNodes } from "@/core/node";
import { ContainerInfo } from "dockerode";
const containers = [ type ContainerSummary = {
{ id: string;
id: "c-a1b2c3d4", name: string;
name: "api-gateway-1", image: string;
image: "dockship/api-gateway:v2.4.1", node: string;
node: "node-01", status: "running" | "stopped";
status: "running" as const, uptime: string;
uptime: "3d 12h 45m", cpu: string;
cpu: "12%", memory: string;
memory: "256MB / 512MB", };
},
{ function summarizeContainer(
id: "c-e5f6g7h8", container: ContainerInfo,
name: "api-gateway-2", nodeName: string
image: "dockship/api-gateway:v2.4.1", ): ContainerSummary {
node: "node-02", // 1. Shorten the long container ID
status: "running" as const, const shortId = container.Id.slice(0, 12);
uptime: "3d 12h 45m",
cpu: "18%", // 2. Clean the name
memory: "312MB / 512MB", const name = container.Names[0]?.replace(/^\//, "") ?? "unknown";
},
{ // 3. Normalize status
id: "c-i9j0k1l2", const status = container.State === "running" ? "running" : "stopped";
name: "api-gateway-3",
image: "dockship/api-gateway:v2.4.1", // 4. Compute uptime from Status string (if available)
node: "node-03", const uptime = container.Status?.replace(/^Up\s*/, "") ?? "unknown";
status: "running" as const,
uptime: "3d 12h 45m", // 5. Placeholder for CPU and memory
cpu: "15%", const cpu = "4%";
memory: "284MB / 512MB", const memory = "64MB / 128MB";
},
{ return {
id: "c-m3n4o5p6", id: shortId,
name: "auth-service-1", name,
image: "dockship/auth:v1.8.0", image: container.Image,
node: "node-01", node: nodeName,
status: "running" as const, status,
uptime: "5d 8h 22m", uptime,
cpu: "8%", cpu,
memory: "128MB / 256MB", memory,
}, };
{ }
id: "c-q7r8s9t0",
name: "auth-service-2",
image: "dockship/auth:v1.8.0",
node: "node-02",
status: "running" as const,
uptime: "5d 8h 22m",
cpu: "6%",
memory: "142MB / 256MB",
},
{
id: "c-u1v2w3x4",
name: "postgres-db-1",
image: "postgres:15-alpine",
node: "node-01",
status: "running" as const,
uptime: "12d 4h 15m",
cpu: "22%",
memory: "1.2GB / 2GB",
},
{
id: "c-y5z6a7b8",
name: "redis-cache-1",
image: "redis:7-alpine",
node: "node-02",
status: "running" as const,
uptime: "8d 16h 33m",
cpu: "4%",
memory: "64MB / 128MB",
},
{
id: "c-c9d0e1f2",
name: "worker-queue-1",
image: "dockship/worker:v3.1.2",
node: "node-03",
status: "running" as const,
uptime: "1d 2h 18m",
cpu: "34%",
memory: "512MB / 1GB",
},
{
id: "c-g3h4i5j6",
name: "worker-queue-2",
image: "dockship/worker:v3.1.2",
node: "node-03",
status: "pending" as const,
uptime: "—",
cpu: "—",
memory: "—",
},
{
id: "c-k7l8m9n0",
name: "metrics-collector-1",
image: "dockship/metrics:v1.2.0",
node: "node-01",
status: "error" as const,
uptime: "—",
cpu: "—",
memory: "—",
},
];
export default function ContainersPage() { export default function ContainersPage() {
const [containers, setContainers] = useState<ContainerSummary[]>([]);
useEffect(() => {
const fetchNodes = async () => {
const nodes = await getNodes();
const dockerNodes = await getDocker(nodes);
setContainers(
nodes.flatMap((node, i) =>
dockerNodes[i].containers.map((c) => summarizeContainer(c, node.name))
)
);
};
fetchNodes();
}, []);
return ( return (
<DashboardLayout <DashboardLayout
title="Containers" title="Containers"
@@ -122,18 +89,12 @@ export default function ContainersPage() {
<Badge> <Badge>
{containers.filter((c) => c.status === "running").length} running {containers.filter((c) => c.status === "running").length} running
</Badge> </Badge>
<Badge>
{containers.filter((c) => c.status === "pending").length} pending
</Badge>
<Badge variant="destructive">
{containers.filter((c) => c.status === "error").length} error
</Badge>
</div> </div>
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{/* ================= DESKTOP TABLE ================= */} {/* ================= DESKTOP TABLE ================= */}
<div className="hidden md:block"> <div className="hidden xl:block">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border"> <tr className="border-b border-border">
@@ -164,7 +125,7 @@ export default function ContainersPage() {
<tr key={container.id} className="data-table-row"> <tr key={container.id} className="data-table-row">
<td className="py-3 px-4"> <td className="py-3 px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-muted-foreground" /> <Container className="w-4 h-4 text-muted-foreground" />
<div> <div>
<div className="font-mono text-sm"> <div className="font-mono text-sm">
{container.name} {container.name}
@@ -180,13 +141,9 @@ export default function ContainersPage() {
{container.image} {container.image}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">{container.node}</td>
<Badge>{container.node}</Badge>
</td>
<td className="py-3 px-4"> <td className="py-3 px-4">{/* {container.status} */}</td>
<StatusIndicator status={container.status} showLabel />
</td>
<td className="py-3 px-4 font-mono text-xs"> <td className="py-3 px-4 font-mono text-xs">
{container.uptime} {container.uptime}
@@ -226,16 +183,16 @@ export default function ContainersPage() {
</div> </div>
{/* ================= MOBILE CARDS ================= */} {/* ================= MOBILE CARDS ================= */}
<div className="md:hidden divide-y divide-border"> <div className="xl:hidden divide-y divide-border">
{containers.map((container) => ( {containers.map((container) => (
<div key={container.id} className="p-4 space-y-3"> <div key={container.id} className="p-4 space-y-3">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-muted-foreground" /> <Container className="w-4 h-4 text-muted-foreground" />
<span className="font-mono text-sm">{container.name}</span> <span className="font-mono text-sm">{container.name}</span>
</div> </div>
<StatusIndicator status={container.status} /> {/* {container.status} */}
</div> </div>
<div className="text-[10px] font-mono text-muted-foreground"> <div className="text-[10px] font-mono text-muted-foreground">
@@ -249,9 +206,7 @@ export default function ContainersPage() {
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2 text-xs">
<div> <div>
<span className="text-muted-foreground">Node</span> <span className="text-muted-foreground">Node</span>
<div> <div>{container.node}</div>
<Badge>{container.node}</Badge>
</div>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Uptime</span> <span className="text-muted-foreground">Uptime</span>
+2 -5
View File
@@ -4,7 +4,6 @@ import { DashboardLayout } from "@/components/dashboard/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusIndicator } from "@/components/dashboard/StatusIndicator";
import { import {
Rocket, Rocket,
RotateCcw, RotateCcw,
@@ -153,9 +152,7 @@ export default function DeploymentsPage() {
</div> </div>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">{dep.status}</td>
<StatusIndicator status={dep.status} showLabel />
</td>
<td className="py-3 px-4 font-mono text-sm"> <td className="py-3 px-4 font-mono text-sm">
{dep.replicas} {dep.replicas}
@@ -201,7 +198,7 @@ export default function DeploymentsPage() {
<Rocket className="w-4 h-4 text-muted-foreground" /> <Rocket className="w-4 h-4 text-muted-foreground" />
<span className="font-mono text-sm">{dep.app}</span> <span className="font-mono text-sm">{dep.app}</span>
</div> </div>
<StatusIndicator status={dep.status} /> {dep.status}
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
+2 -2
View File
@@ -56,8 +56,8 @@ export default function MonitoringPage() {
const fetchNodes = async () => { const fetchNodes = async () => {
const nodes = await getLiveNodes(); const nodes = await getLiveNodes();
setLiveData({ setLiveData({
cpuUsage: average(nodes, (n) => n.liveData.cpu.usage), cpuUsage: average(nodes, (n) => n.liveData?.cpu.usage || 0),
ramUsage: average(nodes, (n) => n.liveData.memory.usage), ramUsage: average(nodes, (n) => n.liveData?.memory.usage || 0),
}); });
}; };
fetchNodes(); fetchNodes();
+20 -13
View File
@@ -8,12 +8,15 @@ import { Server, Plus, Settings, Trash2 } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Docker, Node, NodeLiveData } from "@/lib/types"; import { Docker, Node, NodeLiveData } from "@/lib/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getDockerNode, getLiveNodes } from "@/core/node"; import { getLiveNodes } from "@/core/node";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import { setNodes as updateNodes } from "@/core/node"; import { setNodes as updateNodes } from "@/core/node";
import AddNode from "@/components/dialogs/AddNode"; import AddNode from "@/components/dialogs/AddNode";
import { getDocker } from "@/core/docker";
function DiskUsage({ liveData }: NodeLiveData) { function DiskUsage({ liveData }: NodeLiveData) {
if (!liveData) return null;
const usage = (liveData.disk.used / liveData.disk.size) * 100; const usage = (liveData.disk.used / liveData.disk.size) * 100;
return ( return (
@@ -44,7 +47,7 @@ export default function NodesPage() {
useEffect(() => { useEffect(() => {
const fetchDocker = async () => { const fetchDocker = async () => {
setDockerNodes(await Promise.all(nodes.map(getDockerNode))); setDockerNodes(await getDocker(nodes));
}; };
fetchDocker(); fetchDocker();
}, [nodes]); }, [nodes]);
@@ -54,9 +57,7 @@ export default function NodesPage() {
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-2"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge>{nodes.length} nodes</Badge> <Badge>{nodes.length} nodes</Badge>
<Badge> <Badge>{nodes.filter((node) => node.liveData).length} online</Badge>
{/* {nodes.filter((n) => n.status === "running").length} online */}
</Badge>
</div> </div>
<AddNode> <AddNode>
<Button size="sm" className="gap-2"> <Button size="sm" className="gap-2">
@@ -75,8 +76,12 @@ export default function NodesPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* <StatusIndicator status={node.status} /> */}
<CardTitle className="font-mono">{node.name}</CardTitle> <CardTitle className="font-mono">{node.name}</CardTitle>
{node.liveData ? (
<span className="size-2 rounded-full bg-chart-2" />
) : (
<span className="size-2 rounded-full bg-chart-5" />
)}
</div> </div>
<div className="text-xs text-muted-foreground font-mono"> <div className="text-xs text-muted-foreground font-mono">
{node.ip} • {node.ip} {node.ip} • {node.ip}
@@ -135,13 +140,13 @@ export default function NodesPage() {
{/* CPU */} {/* CPU */}
<div> <div>
<div className="text-xs text-muted-foreground mb-2"> <div className="text-xs text-muted-foreground mb-2">
CPU ({node.liveData.cpu.cores} cores) CPU ({node.liveData?.cpu.cores} cores)
</div> </div>
<div className="stat-value mb-2"> <div className="stat-value mb-2">
{node.liveData.cpu.usage.toFixed(0)}% {node.liveData?.cpu.usage.toFixed(0)}%
</div> </div>
<Progress <Progress
value={node.liveData.cpu.usage} value={node.liveData?.cpu.usage}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -152,15 +157,17 @@ export default function NodesPage() {
Memory Memory
</div> </div>
<div className="stat-value mb-2"> <div className="stat-value mb-2">
{node.liveData.memory.usage.toFixed(0)}% {node.liveData?.memory.usage.toFixed(0)}%
</div> </div>
<Progress <Progress
value={node.liveData.memory.usage} value={node.liveData?.memory.usage}
className="w-full" className="w-full"
/> />
<div className="text-[10px] text-muted-foreground mt-1 font-mono"> <div className="text-[10px] text-muted-foreground mt-1 font-mono">
{formatBytes(node.liveData.memory.used)} /{" "} {node.liveData &&
{formatBytes(node.liveData.memory.total)} formatBytes(node.liveData?.memory.used) +
"/" +
formatBytes(node.liveData?.memory.total)}
</div> </div>
</div> </div>
+18 -14
View File
@@ -5,7 +5,6 @@ import { StatCard } from "@/components/dashboard/StatCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusIndicator } from "@/components/dashboard/StatusIndicator";
import { import {
Box, Box,
Server, Server,
@@ -18,10 +17,11 @@ import { Progress } from "@/components/ui/progress";
import Link from "next/link"; import Link from "next/link";
import { getLiveNodes } from "@/core/node"; import { getLiveNodes } from "@/core/node";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Node, NodeLiveData } from "@/lib/types"; import { Docker, Node, NodeLiveData } from "@/lib/types";
import axios from "axios"; import axios from "axios";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { getSession } from "@/core/auth/session"; import { getSession } from "@/core/auth/session";
import { getDocker } from "@/core/docker";
const recentDeployments = [ const recentDeployments = [
{ {
@@ -94,11 +94,13 @@ const applications = [
export default function OverviewPage() { export default function OverviewPage() {
const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]); const [nodes, setNodes] = useState<(NodeLiveData & Node)[]>([]);
const [dockerNodes, setDockerNodes] = useState<Docker[]>([]);
useEffect(() => { useEffect(() => {
const fetchNodes = async () => { const fetchNodes = async () => {
const nodes = await getLiveNodes(); const nodes = await getLiveNodes();
setNodes(nodes); setNodes(nodes);
setDockerNodes(await getDocker(nodes));
}; };
fetchNodes(); fetchNodes();
const interval = setInterval(fetchNodes, 3000); const interval = setInterval(fetchNodes, 3000);
@@ -118,13 +120,13 @@ export default function OverviewPage() {
/> />
<StatCard <StatCard
title="Containers" title="Containers"
value="47" value={dockerNodes.reduce((sum, n) => sum + n.containers.length, 0)}
subtitle="all healthy" subtitle="running"
icon={<Container className="w-4 h-4" />} icon={<Container className="w-4 h-4" />}
/> />
<StatCard <StatCard
title="Nodes" title="Nodes"
value="3" value={nodes.filter((n) => n.liveData).length}
subtitle="online" subtitle="online"
icon={<Server className="w-4 h-4" />} icon={<Server className="w-4 h-4" />}
/> />
@@ -178,9 +180,7 @@ export default function OverviewPage() {
{app.memory}% {app.memory}%
</span> </span>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">{app.status}</div>
<StatusIndicator status={app.status} showLabel />
</div>
</div> </div>
))} ))}
</div> </div>
@@ -205,7 +205,7 @@ export default function OverviewPage() {
className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0" className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-2 border-b border-border last:border-0"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusIndicator status={dep.status} size="sm" /> {dep.status}
<div> <div>
<div className="font-mono text-sm">{dep.app}</div> <div className="font-mono text-sm">{dep.app}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@@ -235,7 +235,11 @@ export default function OverviewPage() {
<div key={i} className="border border-border p-4 bg-secondary/30"> <div key={i} className="border border-border p-4 bg-secondary/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-3 gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusIndicator status={"running"} /> {node.liveData ? (
<span className="size-2 rounded-full bg-chart-2" />
) : (
<span className="size-2 rounded-full bg-chart-5" />
)}
<span className="font-mono text-sm">{node.name}</span> <span className="font-mono text-sm">{node.name}</span>
</div> </div>
<span className="text-xs text-muted-foreground font-mono"> <span className="text-xs text-muted-foreground font-mono">
@@ -246,21 +250,21 @@ export default function OverviewPage() {
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">CPU</span> <span className="text-muted-foreground">CPU</span>
<span className="font-mono"> <span className="font-mono">
{node.liveData.cpu.usage.toFixed(0)}% {node.liveData?.cpu.usage.toFixed(0)}%
</span> </span>
</div> </div>
<Progress <Progress
value={node.liveData.cpu.usage} value={node.liveData?.cpu.usage}
className="[&>div]:bg-chart-1" className="[&>div]:bg-chart-1"
/> />
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Memory</span> <span className="text-muted-foreground">Memory</span>
<span className="font-mono"> <span className="font-mono">
{node.liveData.memory.usage.toFixed(0)}% {node.liveData?.memory.usage.toFixed(0)}%
</span> </span>
</div> </div>
<Progress <Progress
value={node.liveData.memory.usage} value={node.liveData?.memory.usage}
className="w-full [&>div]:bg-chart-3" className="w-full [&>div]:bg-chart-3"
/> />
<div className="flex items-center justify-between text-xs pt-2 border-t border-border mt-2"> <div className="flex items-center justify-between text-xs pt-2 border-t border-border mt-2">
@@ -1,33 +0,0 @@
import { cn } from "@/lib/utils";
interface StatusIndicatorProps {
status: "running" | "stopped" | "error" | "pending";
showLabel?: boolean;
size?: "sm" | "md";
}
const statusConfig = {
running: { label: "Running", className: "status-running" },
stopped: { label: "Stopped", className: "status-stopped" },
error: { label: "Error", className: "status-error" },
pending: { label: "Pending", className: "status-pending" },
};
export function StatusIndicator({ status, showLabel = false, size = "md" }: StatusIndicatorProps) {
const config = statusConfig[status];
return (
<div className="flex items-center gap-2">
<div
className={cn(
"rounded-full",
config.className,
size === "sm" ? "w-1.5 h-1.5" : "w-2 h-2"
)}
/>
{showLabel && (
<span className="text-xs text-muted-foreground">{config.label}</span>
)}
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, ReactNode, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getLiveNodes } from "@/core/node";
import { Combobox } from "../ui/combobox";
import { Node, NodeLiveData } from "@/lib/types";
import { deployNewApp } from "@/core/application";
export default function DeployApplication({
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 deployNewApp(name, repo, node));
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle>Deploy Application</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name">
Application name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My app"
/>
</div>
<div className="grid gap-3">
<Label htmlFor="repo">
Repository <span className="text-destructive">*</span>
</Label>
<Input
id="repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
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>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={deploy}>Deploy</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useState } from "react";
export type ComboData = { value: string; label: string }[];
export function Combobox({
data,
value,
setValue,
placeholder,
listPlaceholder,
}: {
data: ComboData;
value: string;
setValue: (v: string) => void;
placeholder?: string;
listPlaceholder?: string;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-50 justify-between"
>
{value ? data.find((itm) => itm.value === value)?.label : placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-50 p-0">
<Command>
<CommandInput placeholder="Search framework..." className="h-9" />
<CommandList>
<CommandEmpty>{listPlaceholder}</CommandEmpty>
<CommandGroup>
{data.map((itm) => (
<CommandItem
key={itm.value}
value={itm.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
{itm.label}
<Check
className={cn(
"ml-auto",
value === itm.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+72
View File
@@ -0,0 +1,72 @@
"use server";
import { verifySession } from "./auth/session";
import { randomUUID } from "crypto";
import fs from "fs/promises";
import path from "path";
import { exec } from "child_process";
import util from "util";
const execAsync = util.promisify(exec);
const DATA_DIR = path.join(process.cwd(), "data", "apps");
export async function deployNewApp(name: string, repo: string, nodeId: string) {
if (await verifySession()) {
throw new Error("Unauthorized");
}
// Create app ID
const appId = `app_${randomUUID()}`;
const appDir = path.join(DATA_DIR, appId);
const repoDir = path.join(appDir, "repo");
// Create directories
await fs.mkdir(repoDir, { recursive: true });
// Write app.json
const appConfig = {
id: appId,
name,
repo,
nodeId,
createdAt: new Date().toISOString(),
};
await fs.writeFile(
path.join(appDir, "app.json"),
JSON.stringify(appConfig, 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 {
await fs.access(dockerfilePath);
} catch {
throw new Error("Dockerfile not found in repository root");
}
const imageTag = `dockship/${appId}:latest`;
try {
await execAsync(`docker build -t ${imageTag} .`, {
cwd: repoDir,
});
} catch {
throw new Error("Docker build failed");
}
return {
success: true,
appId,
image: imageTag,
};
}
+12 -3
View File
@@ -1,12 +1,21 @@
"use server"; "use server";
import axios from "axios"; import axios from "axios";
import { Node } from "@/lib/types"; import { Docker, Node } from "@/lib/types";
import { verifySession } from "./auth/session";
export async function getDocker(node: Node): Promise<{ version: string }> { export async function getDocker(nodes: Node[]): Promise<Docker[]> {
return await ( if (await verifySession()) return [];
return await Promise.all(
nodes.map(async (node) => {
const data = await (
await axios.get(`http://${node.ip}:3000/api/get-docker`, { await axios.get(`http://${node.ip}:3000/api/get-docker`, {
headers: { Authorization: `ApiKey ${node.key}` }, headers: { Authorization: `ApiKey ${node.key}` },
}) })
).data; ).data;
return { ...node, containers: data.containers, version: data.version };
})
);
} }
+5 -18
View File
@@ -1,12 +1,12 @@
"use server"; "use server";
import { read, write } from "@/app/api/file"; import { read, write } from "@/app/api/file";
import { Docker, Node, NodeLiveData } from "@/lib/types"; import { Node, NodeLiveData } from "@/lib/types";
import axios from "axios"; import axios from "axios";
import { verifySession } from "./auth/session"; import { verifySession } from "./auth/session";
export async function getNodes(): Promise<Node[] | undefined> { export async function getNodes(): Promise<Node[]> {
if (await verifySession()) return; if (await verifySession()) return [];
return await read(() => [], "data", "nodes.json"); return await read(() => [], "data", "nodes.json");
} }
@@ -17,10 +17,8 @@ export async function setNodes(nodes: Node[]) {
await write(nodes, "data", "nodes.json"); await write(nodes, "data", "nodes.json");
} }
export async function getLiveNodes(): Promise< export async function getLiveNodes(): Promise<(NodeLiveData & Node)[]> {
(NodeLiveData & Node)[] | undefined if (await verifySession()) return [];
> {
if (await verifySession()) return;
const nodes: Node[] = await read(() => [], "data", "nodes.json"); const nodes: Node[] = await read(() => [], "data", "nodes.json");
@@ -36,17 +34,6 @@ export async function getLiveNodes(): Promise<
); );
} }
export async function getDockerNode(node: Node): Promise<Docker | undefined> {
if (await verifySession()) return;
const data = await (
await axios.get(`http://${node.ip}:3000/api/get-docker`, {
headers: { Authorization: `ApiKey ${node.key}` },
})
).data;
return { ...node, containers: data.containers, version: data.version };
}
export async function authNode(ip: string, key: string) { export async function authNode(ip: string, key: string) {
if (await verifySession()) return; if (await verifySession()) return;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface Node {
} }
export interface NodeLiveData { export interface NodeLiveData {
liveData: { liveData?: {
cpu: { cpu: {
manufacturer: string; manufacturer: string;
brand: string; brand: string;