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:
@@ -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
@@ -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
@@ -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">⚙</span> Claude is thinking...' : '✨ Ask Claude') + '</button>';
|
||||
claudeHtml = '<div style="padding:8px 18px 4px">'+claudeBtn+'</div>';
|
||||
if (ds.claudeResponse) {
|
||||
claudeHtml += '<div class="claude-panel"><div class="claude-head">✨ 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}) });
|
||||
|
||||
Reference in New Issue
Block a user