Fix service names, add log viewer, strip ANSI codes
- Separate container names from compose service names (fixes "no such service" error) - Add app log with deploy/merge history (persisted to logs/app.log) - Add Docker log viewer modal per container (with ANSI stripping) - App Log button in header for deploy history - Log button on each service chip for Docker container logs - Colorized log output (errors in red) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
deployed-hashes.json
|
||||
logs/
|
||||
|
||||
+1
-1
@@ -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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
+17
-16
@@ -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<string, ProjectConfig> = {
|
||||
@@ -22,11 +23,11 @@ export const PROJECTS: Record<string, ProjectConfig> = {
|
||||
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<string, ProjectConfig> = {
|
||||
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<string, ProjectConfig> = {
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+13
-7
@@ -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);
|
||||
});
|
||||
|
||||
+16
-10
@@ -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<number> {
|
||||
}
|
||||
|
||||
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(" ")}`));
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
|
||||
+25
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+102
-78
@@ -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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -112,6 +102,7 @@ export function getIndexHtml(): string {
|
||||
<h1><span>Doc</span>Master</h1>
|
||||
<div class="header-right">
|
||||
<span class="last-update" id="ts"></span>
|
||||
<button class="btn btn-log" onclick="showAppLog()">App Log</button>
|
||||
<button class="btn" onclick="refresh()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,9 +111,11 @@ export function getIndexHtml(): string {
|
||||
<div style="text-align:center;padding:60px;color:var(--muted)">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script>
|
||||
let projects = [];
|
||||
let deployState = {}; // key -> { steps[], stepStatuses[], outputs[], running, error }
|
||||
let deployState = {};
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
@@ -140,7 +133,6 @@ function render() {
|
||||
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>';
|
||||
@@ -148,14 +140,13 @@ function render() {
|
||||
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>';
|
||||
const logBtn = '<button class="btn btn-log" onclick="showDockerLog(\\''+s.container+'\\', \\''+esc(s.label)+'\\')">log</button>';
|
||||
return '<div class="svc"><span class="dot '+dot+'"></span>'+esc(s.label)+' '+link+' '+logBtn+'</div>';
|
||||
}).join('');
|
||||
|
||||
// action buttons
|
||||
const isRunning = ds?.running;
|
||||
const canDeploy = p.services.some(s => s.isApp);
|
||||
const deployDisabled = isRunning || !p.deployNeeded;
|
||||
@@ -166,7 +157,6 @@ function render() {
|
||||
const mergeBtn = '<button class="btn btn-merge" onclick="merge(\\''+p.key+'\\')" '+(isRunning || p.devAheadOfMain===0?'disabled':'')+'>'+
|
||||
(isRunning && ds?.action==='merge' ? '<span class="spinning">⚙</span> Merging...' : '▶ Merge Dev → Main') + '</button>';
|
||||
|
||||
// deploy panel
|
||||
let panelHtml = '';
|
||||
if (ds && ds.steps) {
|
||||
const stepsHtml = ds.steps.map((name, i) => {
|
||||
@@ -200,14 +190,77 @@ function render() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// --- Log modal ---
|
||||
async function showDockerLog(container, label) {
|
||||
openLogModal('Docker Log: ' + label + ' (' + container + ')', '/api/logs/docker/' + container + '?tail=200');
|
||||
}
|
||||
|
||||
async function showAppLog() {
|
||||
openLogModal('DocMaster App Log', '/api/logs/app?lines=200');
|
||||
}
|
||||
|
||||
async function openLogModal(title, url) {
|
||||
const modal = document.getElementById('modal-root');
|
||||
modal.innerHTML =
|
||||
'<div class="modal-bg" onclick="closeModal(event)">'+
|
||||
'<div class="modal">'+
|
||||
'<div class="modal-head">'+
|
||||
'<h3>'+esc(title)+'</h3>'+
|
||||
'<div style="display:flex;gap:6px">'+
|
||||
'<button class="btn btn-log" onclick="refreshLog()">Refresh</button>'+
|
||||
'<button class="btn btn-log" onclick="closeModal()">Close</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'<div class="modal-body">'+
|
||||
'<div class="log-content" id="log-text">Loading...</div>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.dataset.url = url;
|
||||
await refreshLog();
|
||||
}
|
||||
|
||||
async function refreshLog() {
|
||||
const url = document.getElementById('modal-root').dataset.url;
|
||||
if (!url) return;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
const text = await r.text();
|
||||
const el = document.getElementById('log-text');
|
||||
if (el) {
|
||||
el.innerHTML = colorizeLog(text);
|
||||
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||
}
|
||||
} catch(e) {
|
||||
const el = document.getElementById('log-text');
|
||||
if (el) el.textContent = 'Failed to load logs: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function colorizeLog(text) {
|
||||
return text.split('\\n').map(line => {
|
||||
if (/\\[(ERROR|FAIL|ERR|error|fail|crit)/i.test(line) || /Exception|Error:/i.test(line)) {
|
||||
return '<span class="log-error">' + esc(line) + '</span>';
|
||||
}
|
||||
if (/\\[(INFO|info)\\]/i.test(line)) {
|
||||
return '<span class="log-info">' + esc(line) + '</span>';
|
||||
}
|
||||
return esc(line);
|
||||
}).join('\\n');
|
||||
}
|
||||
|
||||
function closeModal(event) {
|
||||
if (event && event.target !== event.currentTarget) return;
|
||||
document.getElementById('modal-root').innerHTML = '';
|
||||
}
|
||||
|
||||
// --- SSE actions ---
|
||||
function runSSE(key, endpoint, actionName, confirmMsg) {
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
deployState[key] = { steps:[], stepStatuses:[], outputs:[], running:true, error:false, action:actionName };
|
||||
render();
|
||||
|
||||
const es = new EventSource(endpoint);
|
||||
|
||||
es.addEventListener('init', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
deployState[key].steps = d.steps;
|
||||
@@ -215,7 +268,6 @@ function runSSE(key, endpoint, actionName, confirmMsg) {
|
||||
deployState[key].outputs = d.steps.map(() => '');
|
||||
render();
|
||||
});
|
||||
|
||||
es.addEventListener('step', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
deployState[key].stepStatuses[d.index] = d.status;
|
||||
@@ -223,44 +275,23 @@ function runSSE(key, endpoint, actionName, confirmMsg) {
|
||||
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 || actionName + ' failed', 'fail');
|
||||
} else {
|
||||
toast(key + ' ' + actionName + ' done!', 'ok');
|
||||
setTimeout(refresh, 1500);
|
||||
}
|
||||
if (!d.success) { deployState[key].error = true; toast(d.error || actionName+' failed','fail'); }
|
||||
else { toast(key+' '+actionName+' done!','ok'); setTimeout(refresh,1500); }
|
||||
render();
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
deployState[key].running = false;
|
||||
deployState[key].error = true;
|
||||
render();
|
||||
es.close();
|
||||
};
|
||||
es.onerror = () => { deployState[key].running=false; deployState[key].error=true; render(); es.close(); };
|
||||
}
|
||||
|
||||
function deploy(key) {
|
||||
runSSE(key, '/api/deploy/' + key, 'deploy', 'Deploy ' + key + '? (merge dev\\u2192main, build, restart)');
|
||||
}
|
||||
|
||||
function merge(key) {
|
||||
runSSE(key, '/api/merge/' + key, 'merge', 'Merge dev \\u2192 main for ' + key + '?');
|
||||
}
|
||||
function deploy(key) { runSSE(key, '/api/deploy/'+key, 'deploy', 'Deploy '+key+'? (merge dev\\u2192main, build, restart)'); }
|
||||
function merge(key) { runSSE(key, '/api/merge/'+key, 'merge', 'Merge dev \\u2192 main for '+key+'?'); }
|
||||
|
||||
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 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);
|
||||
@@ -268,14 +299,7 @@ async function act(key, action) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user