This commit is contained in:
RGJorge
2026-05-04 00:51:17 +00:00
parent 613495b7c2
commit 36080e466b
2 changed files with 147 additions and 0 deletions
+91
View File
@@ -64,6 +64,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
const [envFileOptions, setEnvFileOptions] = useState<string[]>([]);
const [envFileSelected, setEnvFileSelected] = useState<string>("");
// 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<string | null>(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<string, string> = { "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,
)}
</div>
<div className="flex items-center gap-1">
{service.state === "running" && (
<button
onClick={() => { setExecOpen((v) => { if (!v) setLogsExpanded(true); return !v; }); setExecResult(null); setExecError(null); }}
className={`flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${execOpen ? "text-purple-300 bg-purple-400/10" : "text-purple-400 hover:bg-purple-400/10"}`}
title="Exec command"
>
<Terminal size={12} />
Exec
</button>
)}
<button
onClick={() => {
setAutoScroll((v) => {
@@ -845,6 +888,54 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
</button>
</div>
</div>
{/* Exec panel — below logs header */}
{execOpen && (
<div className="px-4 py-2.5 bg-slate-800/90 border-b border-slate-700/60 shrink-0 space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
value={execCmd}
onChange={(e) => 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
/>
<button
onClick={runExec}
disabled={!execCmd.trim() || execLoading}
className="px-3 py-1.5 rounded text-[11px] font-medium text-white bg-purple-700 hover:bg-purple-600 transition-colors disabled:opacity-40"
>
{execLoading ? <Loader2 size={12} className="animate-spin" /> : "Run"}
</button>
<button
onClick={() => { setExecOpen(false); setExecResult(null); setExecError(null); }}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
title="Close"
>
<X size={14} />
</button>
</div>
{execError && (
<div className="text-xs text-red-400 bg-red-900/20 border border-red-800/40 rounded px-2.5 py-1.5">{execError}</div>
)}
{execResult && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px]">
<span className={execResult.exitCode === 0 ? "text-emerald-400" : "text-red-400"}>
Exit code: {execResult.exitCode}
</span>
</div>
<pre className="bg-slate-900 rounded px-2.5 py-2 text-xs font-mono text-slate-300 max-h-48 overflow-auto whitespace-pre-wrap break-all">{execResult.output || "(no output)"}</pre>
</div>
)}
</div>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
+56
View File
@@ -216,6 +216,62 @@ app.post("/api/containers/:id/remove", async (c) => {
}
});
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<void>((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)) {