Add Ask Claude button for deploy error analysis

- When deploy/merge fails, "Ask Claude" button appears
- Calls claude CLI with error context (logs, docker ps, git log)
- Streams Claude's response in real-time via SSE
- Claude response panel with basic markdown formatting
- Claude runs in project directory so it can fix files directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ondrej Trochta
2026-03-08 00:17:21 +01:00
parent a538b800e5
commit 3d84aabd40
3 changed files with 168 additions and 2 deletions
+70
View File
@@ -109,6 +109,76 @@ async function runSSESteps(res: http.ServerResponse, project: ProjectConfig, ste
res.end();
}
export async function handleAskClaude(req: http.IncomingMessage, res: http.ServerResponse, body: string) {
const { project: projectKey, step, error, action } = JSON.parse(body);
const project = PROJECTS[projectKey];
if (!project) {
res.writeHead(404);
res.end("Unknown project");
return;
}
// Gather context
const dockerLogs = exec(`docker compose -f ${project.composePath} logs --tail=50 2>&1`, project.path);
const gitLog = exec("git log --oneline -5", project.path);
const dockerPs = exec(`docker compose -f ${project.composePath} ps -a 2>&1`, project.path);
const prompt = [
`Project "${project.name}" failed during ${action} at step "${step}".`,
``,
`Error output:`,
error,
``,
`Docker container status:`,
dockerPs.stdout,
``,
`Recent docker logs:`,
dockerLogs.stdout.replace(/\x1b\[[0-9;]*m/g, ""),
``,
`Recent git log:`,
gitLog.stdout,
``,
`Docker compose file: ${project.composePath}`,
`Project path: ${project.path}`,
``,
`Analyze the error, explain the root cause, and fix the issue. If you need to modify files or run commands, do it.`,
].join("\n");
log("INFO", project.name, `Ask Claude started for step "${step}"`);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const child = spawn("claude", ["-p", prompt, "--no-input"], {
cwd: project.path,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "dumb" },
});
let fullOutput = "";
child.stdout.on("data", (data) => {
const text = data.toString();
fullOutput += text;
res.write(`event: chunk\ndata: ${JSON.stringify({ text })}\n\n`);
});
child.stderr.on("data", (data) => {
const text = data.toString();
fullOutput += text;
res.write(`event: chunk\ndata: ${JSON.stringify({ text })}\n\n`);
});
child.on("close", (code) => {
log("INFO", project.name, `Ask Claude finished (exit ${code})`);
res.write(`event: done\ndata: ${JSON.stringify({ exitCode: code })}\n\n`);
res.end();
});
}
export async function handleMerge(req: http.IncomingMessage, res: http.ServerResponse, projectKey: string) {
const project = PROJECTS[projectKey];
if (!project) {
+4 -1
View File
@@ -2,7 +2,7 @@
import http from "node:http";
import { getStatusAll, getHealthAll, actionProject } from "./api.js";
import { handleDeploy, handleMerge } from "./deploy.js";
import { handleDeploy, handleMerge, handleAskClaude } from "./deploy.js";
import { getContainerLogs } from "./docker.js";
import { getAppLogs } from "./logs.js";
import { getIndexHtml } from "./web-ui.js";
@@ -46,6 +46,9 @@ 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/ask-claude" && req.method === "POST") {
const body = await readBody(req);
await handleAskClaude(req, res, body);
} else if (path === "/api/logs/app" && req.method === "GET") {
const lines = parseInt(url.searchParams.get("lines") ?? "200");
text(res, getAppLogs(lines));
+94 -1
View File
@@ -49,6 +49,17 @@ export function getIndexHtml(): string {
.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; }
.btn-claude { border-color:#d97706; color:#d97706; background:rgba(217,119,6,0.08); }
.btn-claude:hover { background:rgba(217,119,6,0.18); }
.claude-panel { margin-top:4px; border-top:1px solid var(--border); background:rgba(217,119,6,0.03); padding:12px 18px; border-radius:0 0 10px 10px; }
.claude-panel .claude-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:13px; font-weight:600; color:#d97706; }
.claude-response {
font-family:'SF Mono',Consolas,monospace; font-size:12px; line-height:1.6;
white-space:pre-wrap; word-break:break-word; color:var(--text);
max-height:400px; overflow-y:auto; padding:8px 12px;
background:var(--bg); border-radius:6px; border:1px solid var(--border);
}
.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; }
@@ -165,7 +176,21 @@ function render() {
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>';
let claudeHtml = '';
if (ds.error && !ds.running) {
const failedStep = ds.steps[ds.stepStatuses.indexOf('error')] || '';
const failedOutput = ds.outputs[ds.stepStatuses.indexOf('error')] || '';
const claudeRunning = ds.claudeRunning;
const claudeBtn = '<button class="btn btn-claude" onclick="askClaude(\\''+p.key+'\\', \\''+esc(failedStep).replace(/'/g,'\\\\\\'')+ '\\', \\''+ds.action+'\\')" '+(claudeRunning?'disabled':'')+'>'+
(claudeRunning ? '<span class="spinning">&#9881;</span> Claude is thinking...' : '&#10024; Ask Claude') + '</button>';
claudeHtml = '<div style="padding:8px 18px 4px">'+claudeBtn+'</div>';
if (ds.claudeResponse) {
claudeHtml += '<div class="claude-panel"><div class="claude-head">&#10024; Claude response</div><div class="claude-response" id="claude-resp-'+p.key+'">'+formatClaude(ds.claudeResponse)+'</div></div>';
}
}
panelHtml = '<div class="deploy-panel"><div class="deploy-steps">'+stepsHtml+'</div>'+claudeHtml+'</div>';
} else {
panelHtml = '<div class="deploy-panel hidden"></div>';
}
@@ -289,6 +314,74 @@ function runSSE(key, endpoint, actionName, confirmMsg) {
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 askClaude(key, failedStep, action) {
const ds = deployState[key];
if (!ds) return;
const errorIdx = ds.stepStatuses.indexOf('error');
const errorOutput = errorIdx >= 0 ? ds.outputs[errorIdx] : '';
ds.claudeRunning = true;
ds.claudeResponse = '';
render();
fetch('/api/ask-claude', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ project: key, step: failedStep, error: errorOutput, action })
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function read() {
reader.read().then(({done, value}) => {
if (done) {
ds.claudeRunning = false;
render();
return;
}
buffer += decoder.decode(value, {stream:true});
const lines = buffer.split('\\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const d = JSON.parse(line.slice(6));
if (d.text) {
ds.claudeResponse = (ds.claudeResponse || '') + d.text;
const el = document.getElementById('claude-resp-'+key);
if (el) { el.innerHTML = formatClaude(ds.claudeResponse); el.scrollTop = el.scrollHeight; }
else render();
}
if (d.exitCode !== undefined) {
ds.claudeRunning = false;
render();
}
} catch(e) {}
}
}
read();
});
}
read();
}).catch(err => {
ds.claudeRunning = false;
ds.claudeResponse = 'Error: ' + err.message;
render();
});
}
function formatClaude(text) {
let h = esc(text);
var bt = String.fromCharCode(96);
var codeBlockRe = new RegExp(bt+bt+bt+'([\\\\s\\\\S]*?)'+bt+bt+bt, 'g');
var inlineCodeRe = new RegExp(bt+'([^'+bt+']+)'+bt, 'g');
h = h.replace(codeBlockRe, '<code style="display:block;background:var(--surface2);padding:8px;border-radius:4px;margin:4px 0">$1</code>');
h = h.replace(inlineCodeRe, '<code style="background:var(--surface2);padding:1px 4px;border-radius:3px">$1</code>');
h = h.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
return h;
}
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}) });