Deploy console: real progress bar for image pulls (0.39.0)
Compose is now run with `--progress json` (probed once, falls back to the plain text stream on older compose/agents). The console folds the event stream into a weighted progress bar — download bytes per layer, then container create/start — with a per-image bar and a byte/layer counter, and renders the raw output one line per layer (updated in place) instead of a wall of scrolling text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,31 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, Loader2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { DeployTracker, parseDeployLine, type DeployStats } from "@/lib/deployProgress";
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
/** Compose emits status changes far faster than the UI needs to repaint. */
|
||||
const FLUSH_MS = 150;
|
||||
|
||||
type Phase = "running" | "success" | "failed" | "error";
|
||||
type LogLine = { id: string; text: string; tone: "plain" | "error" };
|
||||
|
||||
const EMPTY_STATS: DeployStats = {
|
||||
pct: 0,
|
||||
indeterminate: true,
|
||||
phase: "preparing",
|
||||
label: "Preparing…",
|
||||
detail: "",
|
||||
images: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket and
|
||||
* streams its output (image pulls, container creation) live, so the user sees
|
||||
* deploy progress instead of a blind spinner. The compose subprocess keeps
|
||||
* running on the server even if this modal is closed early.
|
||||
* Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket. The
|
||||
* stream is folded into a progress bar (image pulls weighted by download size,
|
||||
* then container create/start) with the raw output kept below it, so the deploy
|
||||
* is readable at a glance instead of a wall of scrolling text. The compose
|
||||
* subprocess keeps running on the server even if this modal is closed early.
|
||||
*/
|
||||
export function DeployConsole({
|
||||
stackId,
|
||||
@@ -22,14 +36,58 @@ export function DeployConsole({
|
||||
agentId?: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [stats, setStats] = useState<DeployStats>(EMPTY_STATS);
|
||||
const [phase, setPhase] = useState<Phase>("running");
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const [showLog, setShowLog] = useState(true);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const token = useAuthStore((s) => s.accessToken);
|
||||
|
||||
const tracker = useMemo(() => new DeployTracker(), []);
|
||||
const bufferRef = useRef<LogLine[]>([]);
|
||||
const indexRef = useRef(new Map<string, number>());
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
// One line per layer/container, updated in place — the same way compose
|
||||
// renders on a TTY — so status changes replace their line instead of
|
||||
// rattling past. Lines without an id (plain text, errors) just append.
|
||||
const push = (id: string, text: string, tone: "plain" | "error") => {
|
||||
const buf = bufferRef.current;
|
||||
const known = id ? indexRef.current.get(id) : undefined;
|
||||
if (known !== undefined) {
|
||||
buf[known] = { id, text, tone };
|
||||
} else {
|
||||
buf.push({ id, text, tone });
|
||||
if (id) indexRef.current.set(id, buf.length - 1);
|
||||
}
|
||||
if (buf.length > MAX_LINES) {
|
||||
bufferRef.current = buf.slice(-MAX_LINES);
|
||||
const rebuilt = new Map<string, number>();
|
||||
bufferRef.current.forEach((l, i) => {
|
||||
if (l.id) rebuilt.set(l.id, i);
|
||||
});
|
||||
indexRef.current = rebuilt;
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
const parsed = parseDeployLine(line);
|
||||
if (!parsed) return;
|
||||
if (parsed.kind === "text") {
|
||||
push("", parsed.text, parsed.tone);
|
||||
return;
|
||||
}
|
||||
const ev = parsed.event;
|
||||
tracker.apply(ev);
|
||||
const details = ev.details && ev.details !== "0B" ? ` ${ev.details}` : "";
|
||||
push(ev.id, `${ev.id} ${ev.text}${details}`, ev.status === "error" ? "error" : "plain");
|
||||
};
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const path =
|
||||
agentId != null
|
||||
@@ -41,13 +99,15 @@ export function DeployConsole({
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "log") {
|
||||
setLines((prev) => {
|
||||
const next = [...prev, msg.line as string];
|
||||
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||
});
|
||||
handleLine(msg.line as string);
|
||||
} else if (msg.type === "done") {
|
||||
setPhase(msg.returncode === 0 ? "success" : "failed");
|
||||
const ok = msg.returncode === 0;
|
||||
tracker.finish(ok);
|
||||
dirtyRef.current = true;
|
||||
setPhase(ok ? "success" : "failed");
|
||||
} else if (msg.type === "error") {
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
setPhase("error");
|
||||
setErrorDetail(msg.detail || "Deploy error");
|
||||
}
|
||||
@@ -57,53 +117,156 @@ export function DeployConsole({
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// If the socket dropped before a done/error frame, surface it.
|
||||
setPhase((p) => (p === "running" ? "error" : p));
|
||||
setPhase((p) => {
|
||||
if (p !== "running") return p;
|
||||
tracker.finish(false);
|
||||
dirtyRef.current = true;
|
||||
return "error";
|
||||
});
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [stackId, agentId, token]);
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (!dirtyRef.current) return;
|
||||
dirtyRef.current = false;
|
||||
setLines(bufferRef.current.slice());
|
||||
setStats(tracker.snapshot());
|
||||
}, FLUSH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
ws.close();
|
||||
};
|
||||
}, [stackId, agentId, token, tracker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||
}, [lines]);
|
||||
}, [lines, showLog]);
|
||||
|
||||
const done = phase !== "running";
|
||||
const failed = phase === "failed" || phase === "error";
|
||||
const barTone = failed
|
||||
? "bg-red-500"
|
||||
: phase === "success"
|
||||
? "bg-sp-green"
|
||||
: "bg-accent dark:bg-accent-dark";
|
||||
const pct = phase === "success" ? 100 : Math.min(stats.pct, 100);
|
||||
const indeterminate = stats.indeterminate && !done;
|
||||
const label = failed ? "Deploy failed" : stats.label;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||
{phase === "running" && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{phase === "success" && <CheckCircle2 className="h-5 w-5 text-green-500" />}
|
||||
{(phase === "failed" || phase === "error") && (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<h2 className="sp-heading text-lg">
|
||||
{phase === "running" && `Deploying ${stackId}…`}
|
||||
{phase === "success" && `Deployed ${stackId} ✓`}
|
||||
{phase === "failed" && `Deploy of ${stackId} failed`}
|
||||
{phase === "error" && `Deploy of ${stackId} errored`}
|
||||
</h2>
|
||||
<div className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-card border border-sp-border bg-sp-surface shadow-xl">
|
||||
{/* Header + progress */}
|
||||
<div className="border-b border-sp-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{phase === "running" && (
|
||||
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
{phase === "success" && <CheckCircle2 className="h-5 w-5 shrink-0 text-sp-green" />}
|
||||
{failed && <XCircle className="h-5 w-5 shrink-0 text-red-500" />}
|
||||
<h2 className="sp-heading flex-1 truncate text-lg">
|
||||
{phase === "running" && `Deploying ${stackId}…`}
|
||||
{phase === "success" && `Deployed ${stackId}`}
|
||||
{phase === "failed" && `Deploy of ${stackId} failed`}
|
||||
{phase === "error" && `Deploy of ${stackId} errored`}
|
||||
</h2>
|
||||
<span className="sp-display shrink-0 text-lg tabular-nums">
|
||||
{indeterminate ? "…" : `${Math.round(pct)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-sp-surface-2 ring-1 ring-inset ring-sp-border">
|
||||
{indeterminate ? (
|
||||
<div className="h-full w-1/3 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
) : (
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${barTone}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="shrink-0 text-sp-text-2">{label}</span>
|
||||
<span className="truncate text-right tabular-nums text-sp-text-3">
|
||||
{stats.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="min-h-[200px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
|
||||
>
|
||||
{lines.length === 0 && phase === "running" ? (
|
||||
<span className="text-slate-500">Starting compose up…</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{l}
|
||||
{/* Per-image pull progress */}
|
||||
{stats.images.length > 0 && (
|
||||
<div className="max-h-40 space-y-2 overflow-auto border-b border-sp-border bg-sp-surface-2 px-5 py-3">
|
||||
{stats.images.map((img) => (
|
||||
<div key={img.name} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3 text-xs">
|
||||
<span className="truncate font-medium text-sp-text-1">{img.name}</span>
|
||||
<span
|
||||
className={`shrink-0 tabular-nums ${
|
||||
img.error ? "text-red-500" : "text-sp-text-3"
|
||||
}`}
|
||||
>
|
||||
{img.detail}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-sp-border">
|
||||
{img.measured || img.error ? (
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-300 ease-out ${
|
||||
img.error ? "bg-red-500" : img.done ? "bg-sp-green" : "bg-accent dark:bg-accent-dark"
|
||||
}`}
|
||||
style={{ width: `${img.error ? 100 : Math.min(img.pct, 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/4 rounded-full bg-accent animate-indeterminate dark:bg-accent-dark" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||
{/* Raw compose output */}
|
||||
<button
|
||||
onClick={() => setShowLog((v) => !v)}
|
||||
className="flex items-center gap-1 border-b border-sp-border px-5 py-2 text-left text-xs text-sp-text-2 hover:text-sp-text-1"
|
||||
>
|
||||
{showLog ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{showLog ? "Hide output" : "Show output"}
|
||||
<span className="text-sp-text-3">({lines.length})</span>
|
||||
</button>
|
||||
|
||||
{showLog && (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className="min-h-[180px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
|
||||
>
|
||||
{lines.length === 0 && phase === "running" ? (
|
||||
<span className="text-slate-500">Starting compose up…</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`whitespace-pre-wrap break-all ${
|
||||
l.tone === "error" ? "text-red-400" : ""
|
||||
}`}
|
||||
>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
|
||||
</div>
|
||||
)}
|
||||
{!showLog && errorDetail && (
|
||||
<div className="px-5 py-3 text-xs text-red-500">{errorDetail}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-sp-border px-5 py-3">
|
||||
<Button variant={done ? "primary" : "outline"} onClick={onClose}>
|
||||
{done ? "Close" : "Close (deploy continues)"}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user