Restructured applications

This commit is contained in:
2026-01-02 12:37:46 +01:00
parent dd8662cb90
commit 767afd9fef
6 changed files with 106 additions and 177 deletions
+32 -89
View File
@@ -4,20 +4,20 @@ 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 { Play, Square, MoreVertical, Plus, Rocket } from "lucide-react"; import { MoreVertical, Plus, Rocket } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import InstallApplicationDialog from "@/components/dialogs/InstallApplication"; import InstallApplicationDialog from "@/components/dialogs/InstallApplication";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageApp } from "@/lib/types";
import { getApplications, removeApp } from "@/core/application"; import { getApplications, removeApp } from "@/core/application";
import RemoveDialog from "@/components/dialogs/Remove"; import RemoveDialog from "@/components/dialogs/Remove";
import { Application } from "@/lib/types";
export default function ApplicationsPage() { export default function ApplicationsPage() {
const [applications, setApplications] = useState<ImageApp[]>([]); const [applications, setApplications] = useState<Application[]>([]);
useEffect(() => { useEffect(() => {
const fetch = async () => { const fetch = async () => {
setApplications(await getApplications()); const apps = await getApplications();
setApplications(apps);
}; };
fetch(); fetch();
@@ -40,110 +40,53 @@ export default function ApplicationsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-6"> <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"> <div className="flex flex-wrap items-center gap-2">
<Badge>{applications.length} apps</Badge> <Badge>{applications.length} apps</Badge>
<Badge>
{applications.filter((a) => a.status === "running").length} running
</Badge>
</div> </div>
<InstallApplicationDialog> <InstallApplicationDialog>
<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" /> Install Application
Install Application
</Button> </Button>
</InstallApplicationDialog> </InstallApplicationDialog>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-3"> {applications.map((app) => (
{applications.map((app, i) => ( <Card key={app.id}>
<Card key={i}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:items-center"> <div className="flex flex-col gap-4">
{/* Status & Name */} {/* Status & Name */}
<div className="flex items-center gap-3 md:col-span-3"> <div className="flex items-center gap-3 min-w-0">
{/* {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 truncate">
{app.app?.name || app.name} {app.name}
</div> </div>
<div className="text-xs text-muted-foreground font-mono truncate"> <div className="text-xs text-muted-foreground font-mono truncate">
{app.image} {app.repo}
</div> </div>
</div> </div>
</div> </div>
{/* Containers */} {/* Deployments */}
<div className="flex justify-between md:block md:col-span-1"> <div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground md:hidden"> <span className="text-xs text-muted-foreground">
Containers Deployments
</span> </span>
<div className="font-mono text-sm">{app.replicas}</div> <span className="font-mono text-sm">
</div> {app.deployments?.length ?? 0}
{/* CPU */}
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground mb-1">CPU</div>
<div className="flex items-center gap-2">
<Progress value={app.cpu} className="w-full" />
<span className="text-xs font-mono w-8">
{app.cpu.toFixed(1)}%
</span>
</div>
</div>
{/* Memory */}
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground mb-1">
Memory
</div>
<div className="flex items-center gap-2">
<Progress value={app.memory} className="w-full" />
<span className="text-xs font-mono w-8">
{app.memory.toFixed(1)}%
</span>
</div>
</div>
{/* Network */}
<div className="flex justify-between md:block md:col-span-1">
<span className="text-xs text-muted-foreground md:hidden">
Network
</span> </span>
<div className="font-mono text-xs">{app.network}</div>
</div>
{/* Ports */}
<div className="md:col-span-2">
<div className="text-xs text-muted-foreground mb-1">
Ports
</div>
<div className="flex gap-1 flex-wrap">
{app.ports?.length > 0 ? (
app.ports.map((port) => (
<Badge key={port} className="text-[10px]">
{port}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-end gap-1 md:col-span-1"> <div className="flex items-center justify-end gap-1">
{app.status === "running" ? ( <Button variant="ghost" size="icon-sm" aria-label="Deploy">
<Button variant="ghost" size="icon-sm"> <Rocket className="w-3 h-3" />
<Square className="w-3 h-3" /> </Button>
</Button>
) : ( {app.id && <RemoveDialog remove={() => remove(app.id)} />}
<Button variant="ghost" size="icon-sm">
<Rocket className="w-3 h-3" /> <Button
</Button> variant="ghost"
)} size="icon-sm"
<RemoveDialog aria-label="More options"
remove={() => app.app?.id && remove(app.app?.id)} >
/>
<Button variant="ghost" size="icon-sm">
<MoreVertical className="w-3 h-3" /> <MoreVertical className="w-3 h-3" />
</Button> </Button>
</div> </div>
+2 -2
View File
@@ -3,7 +3,7 @@
import { verifySession } from "./auth/session"; import { verifySession } from "./auth/session";
import { getNodes } from "./node"; import { getNodes } from "./node";
import axios from "axios"; import axios from "axios";
import { Env, EnvVariable, ImageApp } from "@/lib/types"; import { Env, EnvVariable, Application } from "@/lib/types";
export async function installApp(name: string, repo: string, nodeId: string) { export async function installApp(name: string, repo: string, nodeId: string) {
if (await verifySession()) { if (await verifySession()) {
@@ -25,7 +25,7 @@ export async function installApp(name: string, repo: string, nodeId: string) {
).data.applications; ).data.applications;
} }
export async function getApplications(): Promise<ImageApp[]> { export async function getApplications(): Promise<Application[]> {
const nodes = await getNodes(); const nodes = await getNodes();
return ( return (
+1 -1
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import axios from "axios"; import axios from "axios";
import { Docker, Log, Node, SimpleStats } from "@/lib/types"; import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types";
import { verifySession } from "./auth/session"; import { verifySession } from "./auth/session";
import { getNodes } from "./node"; import { getNodes } from "./node";
+14 -77
View File
@@ -1,80 +1,25 @@
"use server"; "use server";
import Docker, { Container, ContainerStats } from "dockerode"; import Docker from "dockerode";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { exec } from "child_process"; import { exec } from "child_process";
import util from "util"; import util from "util";
import { Env, EnvVariable, ImageApp } from "@/lib/types"; import { Application, Env, EnvVariable } from "@/lib/types";
import { CPUusage } from "@/lib/server/calc";
const docker = new Docker(); const docker = new Docker();
const execAsync = util.promisify(exec); const execAsync = util.promisify(exec);
const DATA_DIR = path.join(process.cwd(), "data", "apps"); const DATA_DIR = path.join(process.cwd(), "data", "apps");
export async function getApplications(): Promise<ImageApp[]> { export async function getApplications(): Promise<Application[]> {
const images = await docker.listImages(); return fs
const containers = await docker.listContainers({ all: true }); .readdirSync(DATA_DIR)
.map((appId) =>
const apps: ImageApp[] = []; JSON.parse(
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
for (const image of images) { )
const imageName = image.RepoTags?.[0]; );
if (!imageName) continue;
const relatedContainers = containers.filter((c) => c.Image === imageName);
let totalCpu = 0;
let totalMem = 0;
let running = 0;
const ports = new Set<string>();
for (const containerInfo of relatedContainers) {
const container = docker.getContainer(containerInfo.Id);
if (containerInfo.State === "running") running++;
containerInfo.Ports?.forEach((p) => {
if (p.PublicPort && p.PrivatePort) {
ports.add(`${p.PublicPort}:${p.PrivatePort}`);
}
});
if (containerInfo.State === "running") {
const stats = await container.stats({ stream: false });
totalMem += (stats.memory_stats.usage / stats.memory_stats.limit) * 100;
totalCpu += CPUusage(stats);
}
}
const name = imageName.split("/").pop()?.split(":")[0] ?? imageName;
var app;
try {
app = JSON.parse(
fs.readFileSync(path.join(DATA_DIR, name, "app.json")).toString()
);
} catch {}
apps.push({
app,
name,
id: image.Id.slice(7, 15),
image: imageName,
containers: relatedContainers.length,
replicas: `${running}/${relatedContainers.length}`,
cpu: totalCpu,
memory: totalMem,
network: "—",
status: running > 0 ? ("running" as const) : ("stopped" as const),
ports: [...ports],
});
}
return apps;
} }
export async function installApp(name: string, repo: string, nodeId: string) { export async function installApp(name: string, repo: string, nodeId: string) {
@@ -87,12 +32,13 @@ export async function installApp(name: string, repo: string, nodeId: string) {
fs.mkdirSync(repoDir, { recursive: true }); fs.mkdirSync(repoDir, { recursive: true });
// Write app.json // Write app.json
const appConfig = { const appConfig: Application = {
id: appId, id: appId,
name, name,
repo, repo,
nodeId, nodeId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
deployments: [],
}; };
fs.writeFileSync( fs.writeFileSync(
@@ -100,6 +46,7 @@ export async function installApp(name: string, repo: string, nodeId: string) {
JSON.stringify(appConfig, null, 2) JSON.stringify(appConfig, null, 2)
); );
// Write env.json
fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], null, 2)); fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], null, 2));
// Clone repository // Clone repository
@@ -115,23 +62,13 @@ export async function installApp(name: string, repo: string, nodeId: string) {
try { try {
fs.accessSync(dockerfilePath); fs.accessSync(dockerfilePath);
} catch { } catch {
fs.rmSync(appDir, { recursive: true });
throw new Error("Dockerfile not found in repository root"); 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 { return {
success: true, success: true,
appId, appId,
image: imageTag,
}; };
} }
+53
View File
@@ -0,0 +1,53 @@
"use server";
import Docker from "dockerode";
import { Application, Deployment } from "@/lib/types";
import { CPUusage } from "@/lib/server/calc";
const docker = new Docker();
export async function getDeployments(
apps: Application[]
): Promise<(null | Deployment)[]> {
const containers = await docker.listContainers({ all: true });
return await Promise.all(
apps
.map((app) => app.deployments)
.flat()
.map(async (imageId) => {
const containerInfo = containers.find(
(container) => container.ImageID === imageId
);
if (!containerInfo) return null;
const container = docker.getContainer(containerInfo.Id);
let cpu = 0;
let memory = 0;
let ports: Set<[number, number]> = new Set();
if (containerInfo.State === "running") {
const stats = await container.stats({ stream: false });
memory += (stats.memory_stats.usage / stats.memory_stats.limit) * 100;
cpu += CPUusage(stats);
}
containerInfo.Ports?.forEach((p) => {
if (p.PublicPort && p.PrivatePort) {
ports.add([p.PublicPort, p.PrivatePort]);
}
});
return {
image: containerInfo.ImageID,
container: containerInfo.Id,
cpu,
memory,
status: containerInfo.State === "running" ? "running" : "stopped",
ports: [...ports],
};
})
);
}
+4 -8
View File
@@ -54,20 +54,16 @@ export interface Application {
repo: string; repo: string;
nodeId: string; nodeId: string;
createdAt: string; createdAt: string;
deployments: string[];
} }
export interface ImageApp { export interface Deployment {
app?: Application;
id: string;
name: string;
image: string; image: string;
containers: number; container: string;
replicas: string;
cpu: number; cpu: number;
memory: number; memory: number;
network: string;
status: "running" | "stopped"; status: "running" | "stopped";
ports: string[]; ports: [number, number][];
} }
export interface SimpleStats { export interface SimpleStats {