Initial commit: DocMaster - Docker project manager
Web UI + CLI for managing dockerized projects (start/stop/deploy). Features: live deploy workflow with SSE streaming, health checks, port map. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/>
|
||||
<stop offset="100%" stop-color="#0f1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="24" fill="url(#bg)"/>
|
||||
<rect x="24" y="32" width="80" height="10" rx="3" fill="#3b82f6" opacity="0.9"/>
|
||||
<rect x="24" y="48" width="60" height="8" rx="3" fill="#22c55e" opacity="0.7"/>
|
||||
<rect x="24" y="62" width="70" height="8" rx="3" fill="#22c55e" opacity="0.7"/>
|
||||
<rect x="24" y="76" width="50" height="8" rx="3" fill="#eab308" opacity="0.7"/>
|
||||
<circle cx="96" cy="52" r="6" fill="#22c55e"/>
|
||||
<circle cx="96" cy="66" r="6" fill="#22c55e"/>
|
||||
<circle cx="96" cy="80" r="6" fill="#ef4444"/>
|
||||
<text x="64" y="108" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="bold" fill="#e4e4e7">DocMaster</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 934 B |
Generated
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "pm-cli",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pm-cli",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pm": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "docmaster",
|
||||
"version": "1.0.0",
|
||||
"description": "DocMaster - Web UI for managing Docker-based projects",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pm": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"start": "node dist/cli.js",
|
||||
"web": "node dist/server.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0",
|
||||
"chalk": "^5.3.0"
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { getAllProjects, PROJECTS } from "./config.js";
|
||||
import { getRunningContainers, composeUp, composeDown, composeBuild } from "./docker.js";
|
||||
import { getGitStatus, gitPull } from "./git.js";
|
||||
import { checkHealth } from "./health.js";
|
||||
|
||||
export interface ProjectStatusDTO {
|
||||
name: string;
|
||||
key: string;
|
||||
branch: string;
|
||||
behindRemote: number;
|
||||
devAheadOfMain: number;
|
||||
lastCommit: string;
|
||||
lastCommitDev: string;
|
||||
services: ServiceStatusDTO[];
|
||||
}
|
||||
|
||||
export interface ServiceStatusDTO {
|
||||
label: string;
|
||||
container: string;
|
||||
port?: number;
|
||||
healthUrl?: string;
|
||||
running: boolean;
|
||||
status: string;
|
||||
isApp: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_KEYS: Record<string, string> = {};
|
||||
for (const [key, cfg] of Object.entries(PROJECTS)) {
|
||||
PROJECT_KEYS[cfg.name] = key;
|
||||
}
|
||||
|
||||
export function getStatusAll(): ProjectStatusDTO[] {
|
||||
const containers = getRunningContainers();
|
||||
return getAllProjects().map((p) => {
|
||||
const git = getGitStatus(p);
|
||||
return {
|
||||
name: p.name,
|
||||
key: PROJECT_KEYS[p.name] ?? p.name.toLowerCase(),
|
||||
branch: git.currentBranch,
|
||||
behindRemote: git.behindRemote,
|
||||
devAheadOfMain: git.devAheadOfMain,
|
||||
lastCommit: git.lastCommit,
|
||||
lastCommitDev: git.lastCommitDev,
|
||||
services: p.services.map((s) => {
|
||||
const c = containers.find((ct) => ct.name === s.container);
|
||||
return {
|
||||
label: s.label,
|
||||
container: s.container,
|
||||
port: s.port,
|
||||
healthUrl: s.healthUrl,
|
||||
running: !!c?.status.startsWith("Up"),
|
||||
status: c?.status ?? "Stopped",
|
||||
isApp: p.appContainers.includes(s.container),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getHealthAll() {
|
||||
return getAllProjects().flatMap((p) =>
|
||||
checkHealth(p).map((h) => ({ ...h, project: p.name }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function actionProject(key: string, action: string): Promise<string> {
|
||||
const project = PROJECTS[key];
|
||||
if (!project) return `Unknown project: ${key}`;
|
||||
|
||||
switch (action) {
|
||||
case "up":
|
||||
await composeUp(project);
|
||||
return `${project.name} started.`;
|
||||
case "down":
|
||||
await composeDown(project, true);
|
||||
return `${project.name} stopped.`;
|
||||
case "restart":
|
||||
await composeDown(project, true);
|
||||
await composeBuild(project);
|
||||
await composeUp(project);
|
||||
return `${project.name} restarted.`;
|
||||
case "update":
|
||||
await composeDown(project, true);
|
||||
await gitPull(project);
|
||||
await composeBuild(project);
|
||||
await composeUp(project);
|
||||
return `${project.name} updated.`;
|
||||
case "build":
|
||||
await composeBuild(project);
|
||||
return `${project.name} built.`;
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { resolveProject, getAllProjects } from "./config.js";
|
||||
import { getRunningContainers, composeUp, composeDown, composeBuild, composeLogs, composePs } from "./docker.js";
|
||||
import { getGitStatus, gitPull } from "./git.js";
|
||||
import { checkHealth } from "./health.js";
|
||||
import { printStatusTable, printPortMap, printHealth } from "./display.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("pm").description("Project Manager CLI").version("1.0.0");
|
||||
|
||||
// --- pm status ---
|
||||
program
|
||||
.command("status")
|
||||
.alias("s")
|
||||
.description("Show status of all projects, containers, and git branches")
|
||||
.action(() => {
|
||||
const projects = getAllProjects();
|
||||
const containers = getRunningContainers();
|
||||
const gitStatuses = new Map(projects.map((p) => [p.name, getGitStatus(p)]));
|
||||
printStatusTable(projects, containers, gitStatuses);
|
||||
});
|
||||
|
||||
// --- pm up <project|all> ---
|
||||
program
|
||||
.command("up [project]")
|
||||
.description("Start project containers (default: all)")
|
||||
.action(async (name?: string) => {
|
||||
const projects = name ? [requireProject(name)] : getAllProjects();
|
||||
for (const p of projects) {
|
||||
await composeUp(p);
|
||||
}
|
||||
console.log(chalk.green("\nDone."));
|
||||
});
|
||||
|
||||
// --- pm down <project|all> ---
|
||||
program
|
||||
.command("down [project]")
|
||||
.description("Stop project containers (default: all)")
|
||||
.option("-a, --all-containers", "Stop all containers including DBs")
|
||||
.action(async (name: string | undefined, opts: { allContainers?: boolean }) => {
|
||||
const projects = name ? [requireProject(name)] : getAllProjects();
|
||||
for (const p of projects) {
|
||||
await composeDown(p, !opts.allContainers);
|
||||
}
|
||||
console.log(chalk.green("\nDone."));
|
||||
});
|
||||
|
||||
// --- pm restart <project> ---
|
||||
program
|
||||
.command("restart <project>")
|
||||
.alias("r")
|
||||
.description("Restart project (stop app containers, rebuild, start)")
|
||||
.action(async (name: string) => {
|
||||
const project = requireProject(name);
|
||||
await composeDown(project, true);
|
||||
await composeBuild(project);
|
||||
await composeUp(project);
|
||||
console.log(chalk.green(`\n${project.name} restarted.`));
|
||||
});
|
||||
|
||||
// --- pm update <project|all> ---
|
||||
program
|
||||
.command("update [project]")
|
||||
.alias("u")
|
||||
.description("Pull latest code, rebuild, and restart (default: all)")
|
||||
.option("--no-cache", "Build without Docker cache")
|
||||
.action(async (name?: string) => {
|
||||
const projects = name ? [requireProject(name)] : getAllProjects();
|
||||
for (const project of projects) {
|
||||
console.log(chalk.bold(`\n=== Updating ${project.name} ===`));
|
||||
|
||||
// 1. Stop app containers
|
||||
await composeDown(project, true);
|
||||
|
||||
// 2. Git pull
|
||||
const pullResult = await gitPull(project);
|
||||
if (pullResult !== 0) {
|
||||
console.log(chalk.red(`Git pull failed for ${project.name}, skipping.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Build
|
||||
const buildResult = await composeBuild(project);
|
||||
if (buildResult !== 0) {
|
||||
console.log(chalk.red(`Build failed for ${project.name}.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Start
|
||||
await composeUp(project);
|
||||
|
||||
// 5. Health check
|
||||
const results = checkHealth(project);
|
||||
if (results.length > 0) {
|
||||
printHealth(results, project.name);
|
||||
}
|
||||
|
||||
console.log(chalk.green(`${project.name} updated successfully.`));
|
||||
}
|
||||
});
|
||||
|
||||
// --- pm logs <project> ---
|
||||
program
|
||||
.command("logs <project>")
|
||||
.alias("l")
|
||||
.description("Show Docker logs for a project")
|
||||
.option("-f, --follow", "Follow log output", false)
|
||||
.option("-n, --tail <lines>", "Number of lines to show", "50")
|
||||
.action(async (name: string, opts: { follow: boolean; tail: string }) => {
|
||||
const project = requireProject(name);
|
||||
await composeLogs(project, opts.follow, parseInt(opts.tail));
|
||||
});
|
||||
|
||||
// --- pm ports ---
|
||||
program
|
||||
.command("ports")
|
||||
.alias("p")
|
||||
.description("Show port map for all services")
|
||||
.action(() => {
|
||||
const projects = getAllProjects();
|
||||
const containers = getRunningContainers();
|
||||
printPortMap(projects, containers);
|
||||
});
|
||||
|
||||
// --- pm health [project] ---
|
||||
program
|
||||
.command("health [project]")
|
||||
.alias("h")
|
||||
.description("Check health endpoints (default: all)")
|
||||
.action((name?: string) => {
|
||||
const projects = name ? [requireProject(name)] : getAllProjects();
|
||||
for (const project of projects) {
|
||||
const results = checkHealth(project);
|
||||
if (results.length > 0) {
|
||||
printHealth(results, project.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- pm ps [project] ---
|
||||
program
|
||||
.command("ps [project]")
|
||||
.description("Show Docker compose ps for a project")
|
||||
.action(async (name?: string) => {
|
||||
const projects = name ? [requireProject(name)] : getAllProjects();
|
||||
for (const p of projects) {
|
||||
console.log(chalk.bold(`\n${p.name}:`));
|
||||
await composePs(p);
|
||||
}
|
||||
});
|
||||
|
||||
// --- pm build <project> ---
|
||||
program
|
||||
.command("build <project>")
|
||||
.alias("b")
|
||||
.description("Build Docker images for a project (without restart)")
|
||||
.action(async (name: string) => {
|
||||
const project = requireProject(name);
|
||||
await composeBuild(project);
|
||||
console.log(chalk.green(`\n${project.name} built.`));
|
||||
});
|
||||
|
||||
function requireProject(name: string) {
|
||||
const project = resolveProject(name);
|
||||
if (!project) {
|
||||
console.error(chalk.red(`Unknown project: "${name}"`));
|
||||
console.error(`Available: csai, centralstore (cs), crypto (robot)`);
|
||||
process.exit(1);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface ServiceInfo {
|
||||
container: string;
|
||||
port?: number;
|
||||
healthUrl?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
composePath: string;
|
||||
branch: string;
|
||||
services: ServiceInfo[];
|
||||
/** Container names to stop/build (excludes DBs) */
|
||||
appContainers: string[];
|
||||
}
|
||||
|
||||
export const PROJECTS: Record<string, ProjectConfig> = {
|
||||
csai: {
|
||||
name: "CSAI",
|
||||
path: "/home/zuf/dev/CSAI",
|
||||
composePath: "/home/zuf/dev/CSAI/docker/docker-compose.yml",
|
||||
branch: "main",
|
||||
services: [
|
||||
{ container: "csai-api", port: 5208, healthUrl: "http://localhost:5208/health", label: "API" },
|
||||
{ container: "csai-web", port: 5274, healthUrl: "http://localhost:5274", label: "Web (Blazor)" },
|
||||
{ container: "csai-postgres", port: 5435, label: "PostgreSQL" },
|
||||
],
|
||||
appContainers: ["csai-api", "csai-web"],
|
||||
},
|
||||
|
||||
centralstore: {
|
||||
name: "CentralStore",
|
||||
path: "/home/zuf/dev/CentralStore",
|
||||
composePath: "/home/zuf/dev/CentralStore/docker/docker-compose.yml",
|
||||
branch: "main",
|
||||
services: [
|
||||
{ container: "centralstore-postgres", port: 5434, label: "PostgreSQL" },
|
||||
{ container: "centralstore-rabbitmq", port: 5672, healthUrl: "http://localhost:15672", label: "RabbitMQ" },
|
||||
{ container: "centralstore-pgadmin", port: 5050, healthUrl: "http://localhost:5050", label: "pgAdmin" },
|
||||
],
|
||||
appContainers: [],
|
||||
},
|
||||
|
||||
crypto: {
|
||||
name: "OtCryptorobot",
|
||||
path: "/home/zuf/dev/OtCryptorobot",
|
||||
composePath: "/home/zuf/dev/OtCryptorobot/_Docker/docker-compose.yml",
|
||||
branch: "main",
|
||||
services: [
|
||||
{ container: "crypto-robotcbbe", port: 5010, healthUrl: "http://localhost:5010/health", label: "Backend API" },
|
||||
{ container: "crypto-robotfe", port: 5001, healthUrl: "http://localhost:5001", label: "Frontend (Blazor)" },
|
||||
{ container: "crypto-marketdataservice", port: 5020, healthUrl: "http://localhost:5020/health", label: "MarketDataService" },
|
||||
{ container: "crypto-postgres-prod", port: 5432, label: "PostgreSQL (prod)" },
|
||||
{ container: "crypto-postgres-dev", port: 5433, label: "PostgreSQL (dev)" },
|
||||
],
|
||||
appContainers: ["crypto-robotcbbe", "crypto-robotfe", "crypto-marketdataservice"],
|
||||
},
|
||||
};
|
||||
|
||||
export const PROJECT_ALIASES: Record<string, string> = {
|
||||
csai: "csai",
|
||||
centralstore: "centralstore",
|
||||
cs: "centralstore",
|
||||
crypto: "crypto",
|
||||
otcryptorobot: "crypto",
|
||||
robot: "crypto",
|
||||
};
|
||||
|
||||
export function resolveProject(name: string): ProjectConfig | undefined {
|
||||
const key = PROJECT_ALIASES[name.toLowerCase()];
|
||||
return key ? PROJECTS[key] : undefined;
|
||||
}
|
||||
|
||||
export function getAllProjects(): ProjectConfig[] {
|
||||
return Object.values(PROJECTS);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { PROJECTS, type ProjectConfig } from "./config.js";
|
||||
import { exec } from "./shell.js";
|
||||
|
||||
type StepStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
interface Step {
|
||||
name: string;
|
||||
cmd: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function getDeploySteps(project: ProjectConfig): Step[] {
|
||||
const composePath = project.composePath;
|
||||
const appContainers = project.appContainers.join(" ");
|
||||
const steps: Step[] = [
|
||||
{ name: "git fetch", cmd: "git fetch --all", cwd: project.path },
|
||||
{ name: "git checkout main", cmd: "git checkout main", cwd: project.path },
|
||||
{ name: "git merge dev", cmd: "git merge dev --no-edit", cwd: project.path },
|
||||
];
|
||||
|
||||
if (appContainers) {
|
||||
steps.push(
|
||||
{ name: "docker build", cmd: `docker compose -f ${composePath} build ${appContainers}`, cwd: project.path },
|
||||
{ name: "docker stop", cmd: `docker compose -f ${composePath} stop ${appContainers}`, cwd: project.path },
|
||||
{ name: "docker start", cmd: `docker compose -f ${composePath} up -d`, cwd: project.path },
|
||||
);
|
||||
} else {
|
||||
steps.push(
|
||||
{ name: "docker restart", cmd: `docker compose -f ${composePath} up -d`, cwd: project.path },
|
||||
);
|
||||
}
|
||||
|
||||
steps.push(
|
||||
{ name: "git push", cmd: "git push origin main", cwd: project.path },
|
||||
);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function sendSSE(res: http.ServerResponse, event: string, data: unknown) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function runStep(step: Step): Promise<{ exitCode: number; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
const child = spawn("bash", ["-c", step.cmd], {
|
||||
cwd: step.cwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (d) => (output += d.toString()));
|
||||
child.stderr.on("data", (d) => (output += d.toString()));
|
||||
child.on("close", (code) => resolve({ exitCode: code ?? 1, output }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDeploy(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) {
|
||||
const project = PROJECTS[projectKey];
|
||||
if (!project) {
|
||||
res.writeHead(404);
|
||||
res.end("Unknown project");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
|
||||
const steps = getDeploySteps(project);
|
||||
|
||||
sendSSE(res, "init", {
|
||||
project: project.name,
|
||||
steps: steps.map((s) => s.name),
|
||||
});
|
||||
|
||||
let failed = false;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
sendSSE(res, "step", { index: i, name: step.name, status: "running" });
|
||||
|
||||
const result = await runStep(step);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
sendSSE(res, "step", { index: i, name: step.name, status: "error", output: result.output });
|
||||
sendSSE(res, "done", { success: false, error: `Step "${step.name}" failed` });
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
sendSSE(res, "step", { index: i, name: step.name, status: "done", output: result.output });
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
sendSSE(res, "done", { success: true });
|
||||
}
|
||||
|
||||
res.end();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import chalk from "chalk";
|
||||
import type { ProjectConfig } from "./config.js";
|
||||
import type { ContainerStatus } from "./docker.js";
|
||||
import type { GitStatus } from "./git.js";
|
||||
import type { HealthResult } from "./health.js";
|
||||
|
||||
function pad(str: string, len: number): string {
|
||||
return str.length >= len ? str.substring(0, len) : str + " ".repeat(len - str.length);
|
||||
}
|
||||
|
||||
export function printStatusTable(
|
||||
projects: ProjectConfig[],
|
||||
containers: ContainerStatus[],
|
||||
gitStatuses: Map<string, GitStatus>
|
||||
) {
|
||||
console.log();
|
||||
console.log(chalk.bold("PROJECT STATUS"));
|
||||
console.log(chalk.gray("─".repeat(90)));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`${pad("Project", 18)} ${pad("Branch", 12)} ${pad("Behind", 8)} ${pad("Container", 28)} ${pad("Status", 14)} Port`
|
||||
)
|
||||
);
|
||||
console.log(chalk.gray("─".repeat(90)));
|
||||
|
||||
for (const project of projects) {
|
||||
const git = gitStatuses.get(project.name);
|
||||
let firstRow = true;
|
||||
|
||||
for (const service of project.services) {
|
||||
const container = containers.find((c) => c.name === service.container);
|
||||
const isUp = container?.status.startsWith("Up");
|
||||
const statusText = container ? container.status.split(" (")[0] : "Stopped";
|
||||
const statusColor = isUp ? chalk.green : chalk.red;
|
||||
|
||||
const projectCol = firstRow ? chalk.bold.white(pad(project.name, 18)) : pad("", 18);
|
||||
const branchCol = firstRow && git ? chalk.cyan(pad(git.currentBranch, 12)) : pad("", 12);
|
||||
const behindCol =
|
||||
firstRow && git
|
||||
? git.behindRemote > 0
|
||||
? chalk.yellow(pad(`${git.behindRemote}`, 8))
|
||||
: chalk.green(pad("0", 8))
|
||||
: pad("", 8);
|
||||
|
||||
console.log(
|
||||
`${projectCol} ${branchCol} ${behindCol} ${pad(service.label, 28)} ${statusColor(pad(statusText, 14))} ${service.port ?? ""}`
|
||||
);
|
||||
firstRow = false;
|
||||
}
|
||||
console.log(chalk.gray("─".repeat(90)));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function printPortMap(projects: ProjectConfig[], containers: ContainerStatus[]) {
|
||||
console.log();
|
||||
console.log(chalk.bold("PORT MAP"));
|
||||
console.log(chalk.gray("─".repeat(60)));
|
||||
console.log(chalk.gray(`${pad("Port", 8)} ${pad("Service", 30)} ${pad("Project", 18)} Status`));
|
||||
console.log(chalk.gray("─".repeat(60)));
|
||||
|
||||
const allServices = projects.flatMap((p) =>
|
||||
p.services
|
||||
.filter((s) => s.port)
|
||||
.map((s) => ({ ...s, projectName: p.name }))
|
||||
);
|
||||
allServices.sort((a, b) => (a.port ?? 0) - (b.port ?? 0));
|
||||
|
||||
for (const svc of allServices) {
|
||||
const container = containers.find((c) => c.name === svc.container);
|
||||
const isUp = container?.status.startsWith("Up");
|
||||
const status = isUp ? chalk.green("UP") : chalk.red("DOWN");
|
||||
console.log(`${pad(String(svc.port), 8)} ${pad(svc.label, 30)} ${pad(svc.projectName, 18)} ${status}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function printHealth(results: HealthResult[], projectName: string) {
|
||||
console.log();
|
||||
console.log(chalk.bold(`HEALTH CHECK: ${projectName}`));
|
||||
console.log(chalk.gray("─".repeat(70)));
|
||||
|
||||
for (const r of results) {
|
||||
const status = r.ok ? chalk.green(`${r.statusCode} OK`) : chalk.red(`${r.statusCode} FAIL`);
|
||||
const time = chalk.gray(`${r.responseTime}ms`);
|
||||
console.log(` ${pad(r.service, 25)} ${status} ${time} ${chalk.gray(r.url)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { exec, execLive } from "./shell.js";
|
||||
import type { ProjectConfig } from "./config.js";
|
||||
|
||||
export interface ContainerStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
ports: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export function getRunningContainers(): ContainerStatus[] {
|
||||
const result = exec('docker ps --format "{{.Names}}||{{.Status}}||{{.Ports}}||{{.Image}}"');
|
||||
if (!result.stdout) return [];
|
||||
return result.stdout.split("\n").map((line) => {
|
||||
const [name, status, ports, image] = line.split("||");
|
||||
return { name, status, ports, image };
|
||||
});
|
||||
}
|
||||
|
||||
function composeCmd(project: ProjectConfig, action: string): string {
|
||||
return `docker compose -f ${project.composePath} ${action}`;
|
||||
}
|
||||
|
||||
export async function composeUp(project: ProjectConfig): Promise<number> {
|
||||
console.log(`Starting ${project.name}...`);
|
||||
return execLive(composeCmd(project, "up -d"));
|
||||
}
|
||||
|
||||
export async function composeDown(project: ProjectConfig, appOnly = true): Promise<number> {
|
||||
if (appOnly && project.appContainers.length > 0) {
|
||||
console.log(`Stopping ${project.name} app containers: ${project.appContainers.join(", ")}...`);
|
||||
return execLive(composeCmd(project, `stop ${project.appContainers.join(" ")}`));
|
||||
}
|
||||
console.log(`Stopping all ${project.name} containers...`);
|
||||
return execLive(composeCmd(project, "stop"));
|
||||
}
|
||||
|
||||
export async function composeBuild(project: ProjectConfig): Promise<number> {
|
||||
if (project.appContainers.length === 0) {
|
||||
console.log(`${project.name} has no app containers to build (infra only).`);
|
||||
return 0;
|
||||
}
|
||||
console.log(`Building ${project.name}: ${project.appContainers.join(", ")}...`);
|
||||
return execLive(composeCmd(project, `build ${project.appContainers.join(" ")}`));
|
||||
}
|
||||
|
||||
export async function composeLogs(project: ProjectConfig, follow: boolean, tail: number): Promise<number> {
|
||||
const containers = project.appContainers.length > 0 ? project.appContainers.join(" ") : "";
|
||||
const flags = follow ? `-f --tail=${tail}` : `--tail=${tail}`;
|
||||
return execLive(composeCmd(project, `logs ${flags} ${containers}`));
|
||||
}
|
||||
|
||||
export async function composePs(project: ProjectConfig): Promise<number> {
|
||||
return execLive(composeCmd(project, "ps"));
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { exec, execLive } from "./shell.js";
|
||||
import type { ProjectConfig } from "./config.js";
|
||||
|
||||
export interface GitStatus {
|
||||
currentBranch: string;
|
||||
behindRemote: number;
|
||||
devAheadOfMain: number;
|
||||
lastCommit: string;
|
||||
lastCommitDev: string;
|
||||
}
|
||||
|
||||
export function getGitStatus(project: ProjectConfig): GitStatus {
|
||||
const branch = exec("git branch --show-current", project.path).stdout || "unknown";
|
||||
exec("git fetch --all --quiet", project.path);
|
||||
const behind = exec(`git rev-list HEAD..origin/${branch} --count`, project.path);
|
||||
const behindCount = parseInt(behind.stdout) || 0;
|
||||
const lastCommit = exec("git log -1 --oneline", project.path).stdout || "";
|
||||
|
||||
const devAhead = exec("git rev-list main..dev --count 2>/dev/null", project.path);
|
||||
const devAheadCount = parseInt(devAhead.stdout) || 0;
|
||||
|
||||
const lastCommitDev = exec("git log dev -1 --oneline 2>/dev/null", project.path).stdout || "";
|
||||
|
||||
return {
|
||||
currentBranch: branch,
|
||||
behindRemote: behindCount,
|
||||
devAheadOfMain: devAheadCount,
|
||||
lastCommit,
|
||||
lastCommitDev,
|
||||
};
|
||||
}
|
||||
|
||||
export async function gitPull(project: ProjectConfig): Promise<number> {
|
||||
console.log(`Pulling latest changes for ${project.name}...`);
|
||||
return execLive("git pull", project.path);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { exec } from "./shell.js";
|
||||
import type { ProjectConfig } from "./config.js";
|
||||
|
||||
export interface HealthResult {
|
||||
service: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
export function checkHealth(project: ProjectConfig): HealthResult[] {
|
||||
return project.services
|
||||
.filter((s) => s.healthUrl)
|
||||
.map((service) => {
|
||||
const start = Date.now();
|
||||
const result = exec(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 ${service.healthUrl}`);
|
||||
const elapsed = Date.now() - start;
|
||||
const code = parseInt(result.stdout) || 0;
|
||||
return {
|
||||
service: service.label,
|
||||
url: service.healthUrl!,
|
||||
ok: code >= 200 && code < 400,
|
||||
statusCode: code,
|
||||
responseTime: elapsed,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import { getStatusAll, getHealthAll, actionProject } from "./api.js";
|
||||
import { handleDeploy } from "./deploy.js";
|
||||
import { getIndexHtml } from "./web-ui.js";
|
||||
|
||||
const PORT = 5080;
|
||||
|
||||
function json(res: http.ServerResponse, data: unknown, status = 200) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function html(res: http.ServerResponse, content: string) {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
if (path === "/" && req.method === "GET") {
|
||||
html(res, getIndexHtml());
|
||||
} else if (path === "/api/status" && req.method === "GET") {
|
||||
json(res, getStatusAll());
|
||||
} else if (path === "/api/health" && req.method === "GET") {
|
||||
json(res, getHealthAll());
|
||||
} else if (path === "/api/action" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
const { project, action } = JSON.parse(body);
|
||||
const result = await actionProject(project, action);
|
||||
json(res, { message: result });
|
||||
} else if (path.startsWith("/api/deploy/") && req.method === "GET") {
|
||||
const projectKey = path.replace("/api/deploy/", "");
|
||||
await handleDeploy(req, res, projectKey);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
}
|
||||
} catch (err: any) {
|
||||
json(res, { error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => (data += chunk));
|
||||
req.on("end", () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`DocMaster running at http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export function exec(cmd: string, cwd?: string): ExecResult {
|
||||
try {
|
||||
const stdout = execSync(cmd, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 300_000,
|
||||
});
|
||||
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
stdout: (err.stdout ?? "").toString().trim(),
|
||||
stderr: (err.stderr ?? "").toString().trim(),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function execLive(cmd: string, cwd?: string): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("bash", ["-c", cmd], {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("close", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function execCapture(cmd: string, cwd?: string): Promise<{ exitCode: number; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
const child = spawn("bash", ["-c", cmd], {
|
||||
cwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (d) => (output += d.toString()));
|
||||
child.stderr.on("data", (d) => (output += d.toString()));
|
||||
child.on("close", (code) => resolve({ exitCode: code ?? 1, output }));
|
||||
});
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
export function getIndexHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DocMaster</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117; --surface: #1a1d27; --surface2: #22252f;
|
||||
--border: #2a2d3a; --text: #e4e4e7; --muted: #71717a;
|
||||
--green: #22c55e; --red: #ef4444; --yellow: #eab308;
|
||||
--blue: #3b82f6; --orange: #f97316;
|
||||
--err-bg: #1c1012; --err-border: #5c2020;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background:var(--bg); color:var(--text); }
|
||||
|
||||
.header { background:var(--surface); border-bottom:1px solid var(--border); padding:12px 24px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.header h1 { font-size:18px; font-weight:600; letter-spacing:-0.5px; }
|
||||
.header h1 span { color:var(--blue); }
|
||||
.header-right { display:flex; align-items:center; gap:12px; }
|
||||
.last-update { color:var(--muted); font-size:11px; }
|
||||
|
||||
.container { max-width:1100px; margin:0 auto; padding:20px; }
|
||||
|
||||
/* Project card */
|
||||
.card {
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:10px;
|
||||
margin-bottom:16px; transition: border-color 0.3s;
|
||||
}
|
||||
.card.error { border-color: var(--err-border); background: var(--err-bg); }
|
||||
.card-head {
|
||||
padding:14px 18px; display:flex; align-items:center; justify-content:space-between;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.card-left { display:flex; align-items:center; gap:14px; }
|
||||
.card-title { font-size:16px; font-weight:600; }
|
||||
.badge { padding:2px 9px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||
.badge-branch { background:rgba(59,130,246,0.15); color:var(--blue); }
|
||||
.badge-ahead { background:rgba(234,179,8,0.15); color:var(--yellow); cursor:help; }
|
||||
.badge-synced { background:rgba(34,197,94,0.1); color:var(--green); }
|
||||
|
||||
.card-actions { display:flex; gap:6px; }
|
||||
.btn {
|
||||
padding:5px 12px; border-radius:6px; font-size:12px; font-weight:500;
|
||||
border:1px solid var(--border); background:var(--surface2); color:var(--text);
|
||||
cursor:pointer; transition:all 0.15s; display:inline-flex; align-items:center; gap:5px;
|
||||
}
|
||||
.btn:hover { filter:brightness(1.2); }
|
||||
.btn:disabled { opacity:0.4; cursor:not-allowed; }
|
||||
.btn-deploy { border-color:var(--orange); color:var(--orange); }
|
||||
.btn-deploy:hover { background:rgba(249,115,22,0.12); }
|
||||
.btn-up { border-color:var(--green); color:var(--green); }
|
||||
.btn-down { border-color:var(--red); color:var(--red); }
|
||||
|
||||
/* Services grid */
|
||||
.services { padding:10px 18px 14px; display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.svc {
|
||||
display:flex; align-items:center; gap:7px; padding:5px 12px;
|
||||
background:var(--surface2); border-radius:6px; font-size:13px;
|
||||
}
|
||||
.dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
|
||||
.dot.up { background:var(--green); box-shadow:0 0 5px var(--green); }
|
||||
.dot.down { background:var(--red); box-shadow:0 0 5px var(--red); }
|
||||
.svc-port { color:var(--muted); font-size:11px; }
|
||||
.svc a { color:var(--blue); text-decoration:none; font-size:11px; }
|
||||
.svc a:hover { text-decoration:underline; }
|
||||
|
||||
/* Deploy log panel */
|
||||
.deploy-panel {
|
||||
margin-top:6px; border-top:1px solid var(--border);
|
||||
background:rgba(0,0,0,0.2); border-radius:0 0 10px 10px;
|
||||
}
|
||||
.deploy-panel.hidden { display:none; }
|
||||
.deploy-steps { padding:12px 18px; }
|
||||
.step {
|
||||
display:flex; align-items:center; gap:10px; padding:4px 0;
|
||||
font-size:13px; font-family:'SF Mono',Consolas,monospace;
|
||||
}
|
||||
.step-icon { width:18px; text-align:center; flex-shrink:0; }
|
||||
.step-pending .step-icon { color:var(--muted); }
|
||||
.step-running .step-icon { color:var(--yellow); }
|
||||
.step-done .step-icon { color:var(--green); }
|
||||
.step-error .step-icon { color:var(--red); }
|
||||
.step-output {
|
||||
font-size:11px; color:var(--muted); margin-left:28px;
|
||||
white-space:pre-wrap; word-break:break-all; max-height:120px; overflow-y:auto;
|
||||
}
|
||||
.step-output.err { color:var(--red); }
|
||||
|
||||
/* Spinner animation */
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
.spinning { display:inline-block; animation:spin 1s linear infinite; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position:fixed; bottom:20px; right:20px; padding:10px 18px;
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:8px;
|
||||
font-size:13px; z-index:100; animation:fadeIn 0.2s;
|
||||
}
|
||||
.toast.ok { border-color:var(--green); }
|
||||
.toast.fail { border-color:var(--red); }
|
||||
@keyframes fadeIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1><span>Doc</span>Master</h1>
|
||||
<div class="header-right">
|
||||
<span class="last-update" id="ts"></span>
|
||||
<button class="btn" onclick="refresh()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="app">
|
||||
<div style="text-align:center;padding:60px;color:var(--muted)">Loading...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let projects = [];
|
||||
let deployState = {}; // key -> { steps[], stepStatuses[], outputs[], running, error }
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/api/status');
|
||||
projects = await r.json();
|
||||
render();
|
||||
document.getElementById('ts').textContent = new Date().toLocaleTimeString('cs-CZ');
|
||||
} catch(e) { toast('Refresh failed','fail'); }
|
||||
}
|
||||
|
||||
function render() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = projects.map(p => {
|
||||
const ds = deployState[p.key];
|
||||
const hasError = ds?.error;
|
||||
const cardClass = hasError ? 'card error' : 'card';
|
||||
|
||||
// dev ahead badge
|
||||
let aheadHtml = '';
|
||||
if (p.devAheadOfMain > 0) {
|
||||
aheadHtml = '<span class="badge badge-ahead" title="dev is ' + p.devAheadOfMain + ' commit(s) ahead of main">dev +' + p.devAheadOfMain + '</span>';
|
||||
} else {
|
||||
aheadHtml = '<span class="badge badge-synced">synced</span>';
|
||||
}
|
||||
|
||||
// services
|
||||
const svcsHtml = p.services.map(s => {
|
||||
const dot = s.running ? 'up' : 'down';
|
||||
const link = s.healthUrl ? '<a href="'+s.healthUrl+'" target="_blank">:'+s.port+'</a>' : (s.port ? '<span class="svc-port">:'+s.port+'</span>' : '');
|
||||
return '<div class="svc"><span class="dot '+dot+'"></span>'+esc(s.label)+' '+link+'</div>';
|
||||
}).join('');
|
||||
|
||||
// deploy button
|
||||
const isRunning = ds?.running;
|
||||
const canDeploy = p.services.some(s => s.isApp);
|
||||
const deployBtn = canDeploy
|
||||
? '<button class="btn btn-deploy" onclick="deploy(\\''+p.key+'\\')" '+(isRunning?'disabled':'')+'>'+
|
||||
(isRunning ? '<span class="spinning">⚙</span> Deploying...' : '▶ Deploy') + '</button>'
|
||||
: '';
|
||||
|
||||
// deploy panel
|
||||
let panelHtml = '';
|
||||
if (ds && ds.steps) {
|
||||
const stepsHtml = ds.steps.map((name, i) => {
|
||||
const st = ds.stepStatuses[i] || 'pending';
|
||||
const icons = { pending:'○', running:'<span class="spinning">⚙</span>', done:'✓', error:'✗' };
|
||||
const out = ds.outputs[i] ? '<div class="step-output'+(st==='error'?' err':'')+'">'+esc(ds.outputs[i])+'</div>' : '';
|
||||
return '<div class="step step-'+st+'"><span class="step-icon">'+icons[st]+'</span><span>'+esc(name)+'</span></div>'+out;
|
||||
}).join('');
|
||||
panelHtml = '<div class="deploy-panel"><div class="deploy-steps">'+stepsHtml+'</div></div>';
|
||||
} else {
|
||||
panelHtml = '<div class="deploy-panel hidden"></div>';
|
||||
}
|
||||
|
||||
return '<div class="'+cardClass+'" id="card-'+p.key+'">'+
|
||||
'<div class="card-head">'+
|
||||
'<div class="card-left">'+
|
||||
'<span class="card-title">'+esc(p.name)+'</span>'+
|
||||
'<span class="badge badge-branch">'+esc(p.branch)+'</span>'+
|
||||
aheadHtml+
|
||||
'</div>'+
|
||||
'<div class="card-actions">'+
|
||||
deployBtn+
|
||||
'<button class="btn btn-up" onclick="act(\\''+p.key+'\\',\\'up\\')" '+(isRunning?'disabled':'')+'>Start</button>'+
|
||||
'<button class="btn btn-down" onclick="act(\\''+p.key+'\\',\\'down\\')" '+(isRunning?'disabled':'')+'>Stop</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'<div class="services">'+svcsHtml+'</div>'+
|
||||
panelHtml+
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function deploy(key) {
|
||||
if (!confirm('Deploy ' + key + '? (merge dev→main, build, restart)')) return;
|
||||
|
||||
deployState[key] = { steps:[], stepStatuses:[], outputs:[], running:true, error:false };
|
||||
render();
|
||||
|
||||
const es = new EventSource('/api/deploy/' + key);
|
||||
|
||||
es.addEventListener('init', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
deployState[key].steps = d.steps;
|
||||
deployState[key].stepStatuses = d.steps.map(() => 'pending');
|
||||
deployState[key].outputs = d.steps.map(() => '');
|
||||
render();
|
||||
});
|
||||
|
||||
es.addEventListener('step', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
deployState[key].stepStatuses[d.index] = d.status;
|
||||
if (d.output) deployState[key].outputs[d.index] = d.output;
|
||||
if (d.status === 'error') deployState[key].error = true;
|
||||
render();
|
||||
});
|
||||
|
||||
es.addEventListener('done', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
deployState[key].running = false;
|
||||
if (!d.success) {
|
||||
deployState[key].error = true;
|
||||
toast(d.error || 'Deploy failed', 'fail');
|
||||
} else {
|
||||
toast(key + ' deployed!', 'ok');
|
||||
setTimeout(refresh, 1500);
|
||||
}
|
||||
render();
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
deployState[key].running = false;
|
||||
deployState[key].error = true;
|
||||
render();
|
||||
es.close();
|
||||
};
|
||||
}
|
||||
|
||||
async function act(key, action) {
|
||||
try {
|
||||
const r = await fetch('/api/action', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({project:key, action})
|
||||
});
|
||||
const d = await r.json();
|
||||
toast(d.message||d.error, d.error?'fail':'ok');
|
||||
setTimeout(refresh, 1000);
|
||||
} catch(e) { toast('Error: '+e.message,'fail'); }
|
||||
}
|
||||
|
||||
function esc(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
||||
|
||||
function toast(msg,type) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast '+ type;
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(()=>t.remove(), 4000);
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user