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 = { 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([]); const [autoScroll, setAutoScroll] = useState(true); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); const [serviceFilter, setServiceFilter] = useState("__all__"); const [search, setSearch] = useState(""); const containerRef = useRef(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(); 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 (
{connected ? ( ● live ) : ( ○ disconnected )}
setSearch(e.target.value)} placeholder="Filter…" className="w-44 py-1.5 pl-8 pr-7" /> {search && ( )}
{filtered.length === lines.length ? `${lines.length} lines` : `${filtered.length} / ${lines.length} lines`}
{error && (
⚠ {error}
)} {lines.length === 0 && !error && (
Waiting for log output…
)} {lines.length > 0 && filtered.length === 0 && !error && (
No lines match the current filter.
)} {filtered.map((l, i) => { const style = levelStyles[l.level]; return (
{l.service && ( {l.service} )} {l.line}
); })}
); }