Files
stackpilot/frontend/src/components/stacks/LogViewer.tsx
T
menzeljandClaude Opus 4.8 efb468560e 0.34.0: stack log viewer — fixed height, container filter, severity coloring
Frontend-only release. Overhauls the stack Logs tab:
- Fix the log panel growing down the page (AppShell <main> has no definite
  height, so the page h-full/flex-1 chain collapsed to auto): the scroll
  area now uses a fixed h-[65vh] instead of flex-1.
- Filter by container (service <select>) plus a free-text search; the line
  count shows filtered / total.
- Dozzle-style per-line severity coloring (error/warn/debug via regex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:49:57 +00:00

226 lines
7.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownToLine, Pause, Play, Search, X } from "lucide-react";
import { Button, Input } from "@/components/ui";
import { useAuthStore } from "@/store/auth";
const MAX_LINES = 2000;
const serviceColors = [
"text-sky-400",
"text-emerald-400",
"text-amber-400",
"text-fuchsia-400",
"text-rose-400",
"text-lime-400",
];
function colorFor(service: string | null): string {
if (!service) return "text-slate-300";
let h = 0;
for (const c of service) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return serviceColors[h % serviceColors.length];
}
type Level = "error" | "warn" | "info" | "debug";
const errorRe = /\b(error|err|fatal|fail(?:ed|ure)?|panic|exception|critical|crit)\b/i;
const warnRe = /\b(warn(?:ing)?|deprecat(?:ed|ion)?)\b/i;
const debugRe = /\b(debug|trace)\b/i;
function levelFor(line: string): Level {
if (errorRe.test(line)) return "error";
if (warnRe.test(line)) return "warn";
if (debugRe.test(line)) return "debug";
return "info";
}
// Dozzle-style visual coding per severity.
const levelStyles: Record<Level, { text: string; row: string }> = {
error: {
text: "text-rose-300",
row: "border-l-2 border-rose-500 bg-rose-500/10",
},
warn: {
text: "text-amber-300",
row: "border-l-2 border-amber-500 bg-amber-500/10",
},
info: { text: "text-slate-200", row: "border-l-2 border-transparent" },
debug: { text: "text-slate-400", row: "border-l-2 border-transparent" },
};
type LogLine = { service: string | null; line: string; level: Level };
export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: number }) {
const [lines, setLines] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string>("__all__");
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const token = useAuthStore((s) => s.accessToken);
useEffect(() => {
if (!token) return;
setError(null);
let gotError = false;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-logs/${agentId}/${stackId}`
: `/ws/logs/${stackId}`;
const url = `${proto}://${window.location.host}${path}?token=${token}`;
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = (ev) => {
setConnected(false);
// Auth rejection from the proxy/agent (JWT or agent token) closes 4401.
if (!gotError && ev.code === 4401) {
setError("Not authorized to stream logs (session or agent token).");
}
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "log") {
setLines((prev) => {
const next = [
...prev,
{ service: msg.service, line: msg.line, level: levelFor(msg.line) },
];
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
});
} else if (msg.type === "error") {
gotError = true;
setError(msg.detail || "Log stream error");
}
} catch {
/* ignore */
}
};
return () => ws.close();
}, [stackId, agentId, token]);
// Unique services seen so far, for the container filter.
const services = useMemo(() => {
const set = new Set<string>();
for (const l of lines) if (l.service) set.add(l.service);
return [...set].sort();
}, [lines]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return lines.filter((l) => {
if (serviceFilter !== "__all__" && l.service !== serviceFilter) return false;
if (q && !l.line.toLowerCase().includes(q)) return false;
return true;
});
}, [lines, serviceFilter, search]);
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [filtered, autoScroll]);
const download = () => {
const blob = new Blob([filtered.map((l) => l.line).join("\n")], {
type: "text/plain",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${stackId}-logs.txt`;
a.click();
};
return (
<div className="flex h-full flex-col">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
{connected ? (
<span className="text-green-500"> live</span>
) : (
<span className="text-slate-400"> disconnected</span>
)}
</span>
<select
value={serviceFilter}
onChange={(e) => setServiceFilter(e.target.value)}
className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 outline-none focus:border-accent focus:ring-1 focus:ring-accent dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
>
<option value="__all__">All containers</option>
{services.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter…"
className="w-44 py-1.5 pl-8 pr-7"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
aria-label="Clear filter"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<span className="text-xs text-slate-500">
{filtered.length === lines.length
? `${lines.length} lines`
: `${filtered.length} / ${lines.length} lines`}
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
{autoScroll ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{autoScroll ? "Pause scroll" : "Auto-scroll"}
</Button>
<Button variant="outline" onClick={download}>
<ArrowDownToLine className="h-4 w-4" /> Download
</Button>
</div>
</div>
<div
ref={containerRef}
className="h-[65vh] overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
>
{error && (
<div className="mb-1 whitespace-pre-wrap break-words text-rose-400">
{error}
</div>
)}
{lines.length === 0 && !error && (
<div className="text-slate-500">Waiting for log output</div>
)}
{lines.length > 0 && filtered.length === 0 && !error && (
<div className="text-slate-500">No lines match the current filter.</div>
)}
{filtered.map((l, i) => {
const style = levelStyles[l.level];
return (
<div
key={i}
className={`whitespace-pre-wrap break-all py-0.5 pl-2 ${style.row}`}
>
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className={style.text}>{l.line}</span>
</div>
);
})}
</div>
</div>
);
}