diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index af68cfd..76c7ed4 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -64,6 +64,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, const [envFileOptions, setEnvFileOptions] = useState([]); const [envFileSelected, setEnvFileSelected] = useState(""); + // Exec state + const [execOpen, setExecOpen] = useState(false); + const [execCmd, setExecCmd] = useState(""); + const [execLoading, setExecLoading] = useState(false); + const [execResult, setExecResult] = useState<{ output: string; exitCode: number } | null>(null); + const [execError, setExecError] = useState(null); + // Scroll modal to bottom when opened or when logs arrive useEffect(() => { if (logsModal && modalScrollRef.current) { @@ -125,6 +132,32 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, } }, [service.id, service.uid, token, onAction]); + const runExec = useCallback(async () => { + if (!execCmd.trim()) return; + setExecLoading(true); + setExecResult(null); + setExecError(null); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`/api/containers/${service.id}/exec`, { + method: "POST", + headers, + body: JSON.stringify({ cmd: execCmd }), + }); + const data = await res.json(); + if (res.ok && data.ok) { + setExecResult({ output: data.output, exitCode: data.exitCode }); + } else { + setExecError(data.error || "Exec failed"); + } + } catch { + setExecError("Network error"); + } finally { + setExecLoading(false); + } + }, [execCmd, service.id, token]); + // Re-subscribe logs when exiting processing state useEffect(() => { const wasProcessing = prevProcessingRef.current; @@ -823,6 +856,16 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, )}
+ {service.state === "running" && ( + + )}
+ {/* Exec panel — below logs header */} + {execOpen && ( +
+
+ setExecCmd(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && execCmd.trim() && !execLoading) { + e.preventDefault(); + runExec(); + } + }} + placeholder="e.g. python manage.py migrate" + className="flex-1 bg-slate-900 border border-slate-600 rounded px-2.5 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-purple-500" + autoFocus + /> + + +
+ {execError && ( +
{execError}
+ )} + {execResult && ( +
+
+ + Exit code: {execResult.exitCode} + +
+
{execResult.output || "(no output)"}
+
+ )} +
+ )}
{ } }); +app.post("/api/containers/:id/exec", async (c) => { + const id = c.req.param("id"); + if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); + try { + const body = await c.req.json(); + const cmd = body?.cmd; + if (!cmd || typeof cmd !== "string") return c.json({ error: "Missing cmd" }, 400); + + // Parse command respecting quotes + const parts: string[] = []; + let current = ""; + let inQuote: string | null = null; + for (const ch of cmd) { + if (inQuote) { + if (ch === inQuote) { inQuote = null; } + else { current += ch; } + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === " ") { + if (current) { parts.push(current); current = ""; } + } else { + current += ch; + } + } + if (current) parts.push(current); + if (parts.length === 0) return c.json({ error: "Empty command" }, 400); + + const container = docker.getContainer(id); + const exec = await container.exec({ Cmd: parts, AttachStdout: true, AttachStderr: true }); + const stream = await exec.start({}); + + // Collect output using dockerode's demuxStream + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + await new Promise((resolve) => { + const passStdout = new (require("stream").PassThrough)(); + const passStderr = new (require("stream").PassThrough)(); + passStdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + passStderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + docker.modem.demuxStream(stream, passStdout, passStderr); + stream.on("end", resolve); + stream.on("error", resolve); + setTimeout(resolve, 30000); + }); + + const stdout = Buffer.concat(stdoutChunks).toString("utf-8"); + const stderr = Buffer.concat(stderrChunks).toString("utf-8"); + const output = (stdout + stderr).trim(); + + const inspect = await exec.inspect(); + return c.json({ ok: true, output, exitCode: inspect.ExitCode ?? -1 }); + } catch (err: any) { + return c.json({ error: err?.message || "Failed to exec" }, 500); + } +}); + app.get("/api/logs/:id", async (c) => { const id = c.req.param("id"); if (!/^[a-f0-9]{12,64}$/.test(id)) {