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>
226 lines
7.8 KiB
TypeScript
226 lines
7.8 KiB
TypeScript
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<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<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);
|
|
|
|
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<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;
|
|
}
|
|
}, [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 (
|
|
<div className="flex h-full flex-col">
|
|
<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" />}
|
|
{autoScroll ? "Pause scroll" : "Auto-scroll"}
|
|
</Button>
|
|
<Button variant="outline" onClick={download}>
|
|
<ArrowDownToLine className="h-4 w-4" /> Download
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
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">
|
|
⚠ {error}
|
|
</div>
|
|
)}
|
|
{lines.length === 0 && !error && (
|
|
<div className="text-slate-500">Waiting for log output…</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>
|
|
);
|
|
}
|