Restructured applications
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user