diff --git a/frontend/package.json b/frontend/package.json index c43ec99..7c36534 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.33.0", + "version": "0.34.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/LogViewer.tsx b/frontend/src/components/stacks/LogViewer.tsx index 99e7de4..dff2b7e 100644 --- a/frontend/src/components/stacks/LogViewer.tsx +++ b/frontend/src/components/stacks/LogViewer.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { ArrowDownToLine, Pause, Play } from "lucide-react"; -import { Button } from "@/components/ui"; +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; @@ -21,11 +21,42 @@ function colorFor(service: string | null): string { 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<{ service: string | null; line: string }[]>([]); + 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); @@ -53,7 +84,10 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num const msg = JSON.parse(ev.data); if (msg.type === "log") { setLines((prev) => { - const next = [...prev, { service: msg.service, line: msg.line }]; + 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") { @@ -67,14 +101,30 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num 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; } - }, [lines, autoScroll]); + }, [filtered, autoScroll]); const download = () => { - const blob = new Blob([lines.map((l) => l.line).join("\n")], { + const blob = new Blob([filtered.map((l) => l.line).join("\n")], { type: "text/plain", }); const a = document.createElement("a"); @@ -85,15 +135,51 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num return (
-
- - {connected ? ( - ● live - ) : ( - ○ disconnected - )} - {lines.length} lines - +
+
+ + {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 && (
@@ -116,14 +202,23 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num {lines.length === 0 && !error && (
Waiting for log output…
)} - {lines.map((l, i) => ( -
- {l.service && ( - {l.service} - )} - {l.line} -
- ))} + {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} +
+ ); + })}
);