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",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.33.0",
|
"version": "0.34.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowDownToLine, Pause, Play } from "lucide-react";
|
import { ArrowDownToLine, Pause, Play, Search, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
|
||||||
const MAX_LINES = 2000;
|
const MAX_LINES = 2000;
|
||||||
@@ -21,11 +21,42 @@ function colorFor(service: string | null): string {
|
|||||||
return serviceColors[h % serviceColors.length];
|
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 }) {
|
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 [autoScroll, setAutoScroll] = useState(true);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [serviceFilter, setServiceFilter] = useState<string>("__all__");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const token = useAuthStore((s) => s.accessToken);
|
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);
|
const msg = JSON.parse(ev.data);
|
||||||
if (msg.type === "log") {
|
if (msg.type === "log") {
|
||||||
setLines((prev) => {
|
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;
|
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
||||||
});
|
});
|
||||||
} else if (msg.type === "error") {
|
} else if (msg.type === "error") {
|
||||||
@@ -67,14 +101,30 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
|
|||||||
return () => ws.close();
|
return () => ws.close();
|
||||||
}, [stackId, agentId, token]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && containerRef.current) {
|
if (autoScroll && containerRef.current) {
|
||||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [lines, autoScroll]);
|
}, [filtered, autoScroll]);
|
||||||
|
|
||||||
const download = () => {
|
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",
|
type: "text/plain",
|
||||||
});
|
});
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -85,15 +135,51 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-xs text-slate-500">
|
<div className="flex items-center gap-2">
|
||||||
{connected ? (
|
<span className="text-xs text-slate-500">
|
||||||
<span className="text-green-500">● live</span>
|
{connected ? (
|
||||||
) : (
|
<span className="text-green-500">● live</span>
|
||||||
<span className="text-slate-400">○ disconnected</span>
|
) : (
|
||||||
)}
|
<span className="text-slate-400">○ disconnected</span>
|
||||||
<span className="ml-2">{lines.length} lines</span>
|
)}
|
||||||
</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">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
|
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
|
||||||
{autoScroll ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{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>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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 && (
|
{error && (
|
||||||
<div className="mb-1 whitespace-pre-wrap break-words text-rose-400">
|
<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 && (
|
{lines.length === 0 && !error && (
|
||||||
<div className="text-slate-500">Waiting for log output…</div>
|
<div className="text-slate-500">Waiting for log output…</div>
|
||||||
)}
|
)}
|
||||||
{lines.map((l, i) => (
|
{lines.length > 0 && filtered.length === 0 && !error && (
|
||||||
<div key={i} className="whitespace-pre-wrap break-all">
|
<div className="text-slate-500">No lines match the current filter.</div>
|
||||||
{l.service && (
|
)}
|
||||||
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
|
{filtered.map((l, i) => {
|
||||||
)}
|
const style = levelStyles[l.level];
|
||||||
<span className="text-slate-200">{l.line}</span>
|
return (
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user