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>
This commit is contained in:
menzelj
2026-06-16 07:49:57 +00:00
co-authored by Claude Opus 4.8
parent 786c346c40
commit efb468560e
2 changed files with 121 additions and 26 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.33.0",
"version": "0.34.0",
"type": "module",
"scripts": {
"dev": "vite",
+120 -25
View File
@@ -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<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<{ service: string | null; line: string }[]>([]);
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);
@@ -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<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;
}
}, [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 (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-slate-500">
{connected ? (
<span className="text-green-500"> live</span>
) : (
<span className="text-slate-400"> disconnected</span>
)}
<span className="ml-2">{lines.length} lines</span>
</span>
<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" />}
@@ -106,7 +192,7 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
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">
@@ -116,14 +202,23 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
{lines.length === 0 && !error && (
<div className="text-slate-500">Waiting for log output</div>
)}
{lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className="text-slate-200">{l.line}</span>
</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>
);