mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.19
This commit is contained in:
@@ -64,6 +64,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
const [envFileOptions, setEnvFileOptions] = useState<string[]>([]);
|
const [envFileOptions, setEnvFileOptions] = useState<string[]>([]);
|
||||||
const [envFileSelected, setEnvFileSelected] = 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
|
// Scroll modal to bottom when opened or when logs arrive
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logsModal && modalScrollRef.current) {
|
if (logsModal && modalScrollRef.current) {
|
||||||
@@ -125,6 +132,32 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
}
|
}
|
||||||
}, [service.id, service.uid, token, onAction]);
|
}, [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
|
// Re-subscribe logs when exiting processing state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasProcessing = prevProcessingRef.current;
|
const wasProcessing = prevProcessingRef.current;
|
||||||
@@ -823,6 +856,16 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAutoScroll((v) => {
|
setAutoScroll((v) => {
|
||||||
@@ -845,6 +888,54 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
|
|||||||
@@ -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) => {
|
app.get("/api/logs/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!/^[a-f0-9]{12,64}$/.test(id)) {
|
if (!/^[a-f0-9]{12,64}$/.test(id)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user