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:
Ondrej Trochta
2026-03-08 00:04:52 +01:00
parent c7473378f5
commit a538b800e5
8 changed files with 189 additions and 112 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
*.js.map
deployed-hashes.json
logs/
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
+14
View File
@@ -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
View File
@@ -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">&#9881;</span> Merging...' : '&#9654; Merge Dev &#8594; 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);