diff --git a/.gitignore b/.gitignore index 0054aa1..a05a16f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.js.map deployed-hashes.json +logs/ diff --git a/src/api.ts b/src/api.ts index 7e682aa..aca3ecf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -53,7 +53,7 @@ export function getStatusAll(): ProjectStatusDTO[] { healthUrl: s.healthUrl, running: !!c?.status.startsWith("Up"), status: c?.status ?? "Stopped", - isApp: p.appContainers.includes(s.container), + isApp: p.appServices.includes(s.service), }; }), }; diff --git a/src/config.ts b/src/config.ts index 1e93b1d..ddb6107 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ export interface ServiceInfo { container: string; + service: string; port?: number; healthUrl?: string; label: string; @@ -11,8 +12,8 @@ export interface ProjectConfig { composePath: string; branch: string; services: ServiceInfo[]; - /** Container names to stop/build (excludes DBs) */ - appContainers: string[]; + /** Compose service names for app containers (excludes DBs) */ + appServices: string[]; } export const PROJECTS: Record = { @@ -22,11 +23,11 @@ export const PROJECTS: Record = { 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" }, + { container: "csai-api", service: "csai-api", port: 5208, healthUrl: "http://localhost:5208/health", label: "API" }, + { container: "csai-web", service: "csai-web", port: 5274, healthUrl: "http://localhost:5274", label: "Web (Blazor)" }, + { container: "csai-postgres", service: "postgres", port: 5435, label: "PostgreSQL" }, ], - appContainers: ["csai-api", "csai-web"], + appServices: ["csai-api", "csai-web"], }, centralstore: { @@ -35,11 +36,11 @@ export const PROJECTS: Record = { 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" }, + { container: "centralstore-postgres", service: "postgres", port: 5434, label: "PostgreSQL" }, + { container: "centralstore-rabbitmq", service: "rabbitmq", port: 5672, healthUrl: "http://localhost:15672", label: "RabbitMQ" }, + { container: "centralstore-pgadmin", service: "pgadmin", port: 5050, healthUrl: "http://localhost:5050", label: "pgAdmin" }, ], - appContainers: [], + appServices: [], }, crypto: { @@ -48,13 +49,13 @@ export const PROJECTS: Record = { 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)" }, + { container: "crypto-robotcbbe", service: "robotcbbe", port: 5010, healthUrl: "http://localhost:5010/health", label: "Backend API" }, + { container: "crypto-robotfe", service: "robotfe", port: 5001, healthUrl: "http://localhost:5001", label: "Frontend (Blazor)" }, + { container: "crypto-marketdataservice", service: "marketdataservice", port: 5020, healthUrl: "http://localhost:5020/health", label: "MarketDataService" }, + { container: "crypto-postgres-prod", service: "postgres-prod", port: 5432, label: "PostgreSQL (prod)" }, + { container: "crypto-postgres-dev", service: "postgres-dev", port: 5433, label: "PostgreSQL (dev)" }, ], - appContainers: ["crypto-robotcbbe", "crypto-robotfe", "crypto-marketdataservice"], + appServices: ["robotcbbe", "robotfe", "marketdataservice"], }, }; diff --git a/src/deploy.ts b/src/deploy.ts index 23bd72e..ecd3391 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { PROJECTS, type ProjectConfig } from "./config.js"; import { exec } from "./shell.js"; import { saveDeployedHash } from "./git.js"; +import { log } from "./logs.js"; type StepStatus = "pending" | "running" | "done" | "error"; @@ -14,17 +15,17 @@ interface Step { function getDeploySteps(project: ProjectConfig): Step[] { const composePath = project.composePath; - const appContainers = project.appContainers.join(" "); + const appServices = project.appServices.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) { + if (appServices) { 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 build", cmd: `docker compose -f ${composePath} build ${appServices}`, cwd: project.path }, + { name: "docker stop", cmd: `docker compose -f ${composePath} stop ${appServices}`, cwd: project.path }, { name: "docker start", cmd: `docker compose -f ${composePath} up -d`, cwd: project.path }, ); } else { @@ -66,13 +67,15 @@ function getMergeSteps(project: ProjectConfig): Step[] { ]; } -async function runSSESteps(res: http.ServerResponse, project: ProjectConfig, steps: Step[], onSuccess?: () => void) { +async function runSSESteps(res: http.ServerResponse, project: ProjectConfig, steps: Step[], actionName: string, onSuccess?: () => void) { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); + log("INFO", project.name, `${actionName} started`); + sendSSE(res, "init", { project: project.name, steps: steps.map((s) => s.name), @@ -86,17 +89,20 @@ async function runSSESteps(res: http.ServerResponse, project: ProjectConfig, ste const result = await runStep(step); if (result.exitCode !== 0) { + log("ERROR", project.name, `${actionName} failed at "${step.name}": ${result.output}`); 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; } + log("INFO", project.name, `${actionName} step "${step.name}" done`); sendSSE(res, "step", { index: i, name: step.name, status: "done", output: result.output }); } if (!failed) { onSuccess?.(); + log("INFO", project.name, `${actionName} completed successfully`); sendSSE(res, "done", { success: true }); } @@ -110,7 +116,7 @@ export async function handleMerge(req: http.IncomingMessage, res: http.ServerRes res.end("Unknown project"); return; } - await runSSESteps(res, project, getMergeSteps(project)); + await runSSESteps(res, project, getMergeSteps(project), "merge"); } export async function handleDeploy(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) { @@ -120,7 +126,7 @@ export async function handleDeploy(req: http.IncomingMessage, res: http.ServerRe res.end("Unknown project"); return; } - await runSSESteps(res, project, getDeploySteps(project), () => { + await runSSESteps(res, project, getDeploySteps(project), "deploy", () => { const hash = exec("git rev-parse main", project.path).stdout; if (hash) saveDeployedHash(projectKey, hash); }); diff --git a/src/docker.ts b/src/docker.ts index 3f640d7..9e59f49 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -1,4 +1,4 @@ -import { exec, execLive } from "./shell.js"; +import { exec, execLive, execCapture } from "./shell.js"; import type { ProjectConfig } from "./config.js"; export interface ContainerStatus { @@ -27,27 +27,33 @@ export async function composeUp(project: ProjectConfig): Promise { } export async function composeDown(project: ProjectConfig, appOnly = true): Promise { - if (appOnly && project.appContainers.length > 0) { - console.log(`Stopping ${project.name} app containers: ${project.appContainers.join(", ")}...`); - return execLive(composeCmd(project, `stop ${project.appContainers.join(" ")}`)); + if (appOnly && project.appServices.length > 0) { + console.log(`Stopping ${project.name} app services: ${project.appServices.join(", ")}...`); + return execLive(composeCmd(project, `stop ${project.appServices.join(" ")}`)); } console.log(`Stopping all ${project.name} containers...`); return execLive(composeCmd(project, "stop")); } export async function composeBuild(project: ProjectConfig): Promise { - if (project.appContainers.length === 0) { - console.log(`${project.name} has no app containers to build (infra only).`); + if (project.appServices.length === 0) { + console.log(`${project.name} has no app services to build (infra only).`); return 0; } - console.log(`Building ${project.name}: ${project.appContainers.join(", ")}...`); - return execLive(composeCmd(project, `build ${project.appContainers.join(" ")}`)); + console.log(`Building ${project.name}: ${project.appServices.join(", ")}...`); + return execLive(composeCmd(project, `build ${project.appServices.join(" ")}`)); } export async function composeLogs(project: ProjectConfig, follow: boolean, tail: number): Promise { - const containers = project.appContainers.length > 0 ? project.appContainers.join(" ") : ""; + const services = project.appServices.length > 0 ? project.appServices.join(" ") : ""; const flags = follow ? `-f --tail=${tail}` : `--tail=${tail}`; - return execLive(composeCmd(project, `logs ${flags} ${containers}`)); + return execLive(composeCmd(project, `logs ${flags} ${services}`)); +} + +export function getContainerLogs(containerName: string, tail: number = 100): string { + const result = exec(`docker logs --tail=${tail} ${containerName} 2>&1`); + const raw = result.stdout || result.stderr || "No logs available"; + return raw.replace(/\x1b\[[0-9;]*m/g, ""); } export async function composePs(project: ProjectConfig): Promise { diff --git a/src/logs.ts b/src/logs.ts new file mode 100644 index 0000000..defc744 --- /dev/null +++ b/src/logs.ts @@ -0,0 +1,25 @@ +import { readFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOG_DIR = join(__dirname, "..", "logs"); +const APP_LOG = join(LOG_DIR, "app.log"); + +function ensureLogDir() { + if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }); +} + +export function log(level: string, project: string, message: string) { + ensureLogDir(); + const ts = new Date().toISOString(); + const line = `${ts} [${level}] [${project}] ${message}\n`; + appendFileSync(APP_LOG, line); +} + +export function getAppLogs(lines: number = 200): string { + if (!existsSync(APP_LOG)) return "No logs yet."; + const content = readFileSync(APP_LOG, "utf-8"); + const allLines = content.split("\n").filter(Boolean); + return allLines.slice(-lines).join("\n"); +} diff --git a/src/server.ts b/src/server.ts index bea8a89..479e1d9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,8 @@ import http from "node:http"; import { getStatusAll, getHealthAll, actionProject } from "./api.js"; import { handleDeploy, handleMerge } from "./deploy.js"; +import { getContainerLogs } from "./docker.js"; +import { getAppLogs } from "./logs.js"; import { getIndexHtml } from "./web-ui.js"; const PORT = 5080; @@ -12,6 +14,11 @@ function json(res: http.ServerResponse, data: unknown, status = 200) { res.end(JSON.stringify(data)); } +function text(res: http.ServerResponse, content: string) { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(content); +} + function html(res: http.ServerResponse, content: string) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(content); @@ -39,6 +46,13 @@ const server = http.createServer(async (req, res) => { } else if (path.startsWith("/api/merge/") && req.method === "GET") { const projectKey = path.replace("/api/merge/", ""); await handleMerge(req, res, projectKey); + } else if (path === "/api/logs/app" && req.method === "GET") { + const lines = parseInt(url.searchParams.get("lines") ?? "200"); + text(res, getAppLogs(lines)); + } else if (path.startsWith("/api/logs/docker/") && req.method === "GET") { + const container = path.replace("/api/logs/docker/", ""); + const tail = parseInt(url.searchParams.get("tail") ?? "100"); + text(res, getContainerLogs(container, tail)); } else { res.writeHead(404); res.end("Not found"); diff --git a/src/web-ui.ts b/src/web-ui.ts index 5ac0fbd..df87d38 100644 --- a/src/web-ui.ts +++ b/src/web-ui.ts @@ -24,16 +24,9 @@ export function getIndexHtml(): string { .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 { 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-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; } @@ -55,13 +48,10 @@ export function getIndexHtml(): string { .btn-merge:hover { background:rgba(234,179,8,0.12); } .btn-up { border-color:var(--green); color:var(--green); } .btn-down { border-color:var(--red); color:var(--red); } + .btn-log { border-color:var(--muted); color:var(--muted); font-size:11px; padding:3px 8px; } - /* 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; - } + .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); } @@ -69,41 +59,41 @@ export function getIndexHtml(): string { .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 { 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 { 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 { 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 { 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:1000; 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)} } + + /* Log modal */ + .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; display:flex; align-items:center; justify-content:center; } + .modal { background:var(--surface); border:1px solid var(--border); border-radius:12px; width:90%; max-width:900px; max-height:80vh; display:flex; flex-direction:column; } + .modal-head { padding:12px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; } + .modal-head h3 { font-size:14px; font-weight:600; } + .modal-body { flex:1; overflow-y:auto; padding:12px 18px; } + .log-content { + font-family:'SF Mono',Consolas,monospace; font-size:12px; line-height:1.5; + white-space:pre-wrap; word-break:break-all; color:var(--muted); + } + .log-content .log-error { color:var(--red); } + .log-content .log-info { color:var(--text); } + .log-tabs { display:flex; gap:4px; } + .log-tab { padding:4px 10px; border-radius:5px; font-size:11px; cursor:pointer; border:1px solid var(--border); background:var(--surface2); color:var(--muted); } + .log-tab.active { border-color:var(--blue); color:var(--blue); } @@ -112,6 +102,7 @@ export function getIndexHtml(): string {

DocMaster

+
@@ -120,9 +111,11 @@ export function getIndexHtml(): string {
Loading...
+ +