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:
co-authored by
Claude Opus 4.8
parent
786c346c40
commit
efb468560e
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.33.0",
|
||||
"version": "0.34.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user