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:
Ondrej Trochta
2026-03-07 23:35:45 +01:00
commit 8af5a27b4f
16 changed files with 1167 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.js.map
+17
View File
@@ -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

+75
View File
@@ -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"
}
}
}
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+77
View File
@@ -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
View File
@@ -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();
}
+89
View File
@@ -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();
}
+55
View File
@@ -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
View File
@@ -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);
}
+28
View File
@@ -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,
};
});
}
+58
View File
@@ -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}`);
});
+48
View File
@@ -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
View File
@@ -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">&#9881;</span> Deploying...' : '&#9654; 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:'&#9675;', running:'<span class="spinning">&#9881;</span>', done:'&#10003;', error:'&#10007;' };
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>`;
}
+14
View File
@@ -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/**/*"]
}