mono/packages/ui/src/components/ChatLogBrowser.tsx
2026-03-21 20:18:25 +01:00

687 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ChatLogBrowser — Browse log entries with compact inline tree
*
* Transforms LogEntry[] into a navigable list.
* Drill into any entry to inspect its fields as a compact line-based tree.
* JSON-containing messages are auto-parsed into browsable sub-trees.
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { LogEntry } from '@/contexts/LogContext';
import { resolveNavItems } from '@/components/json/JSONTreeWalker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import {
ListFilter, Trash2, Search, X, ChevronRight, ArrowLeft, Home,
} from 'lucide-react';
// ── Types ────────────────────────────────────────────────────────────────
interface ChatLogBrowserProps {
logs: LogEntry[];
clearLogs: () => void;
title?: string;
}
type LevelFilter = 'all' | 'info' | 'debug' | 'warning' | 'error' | 'success';
// ── Helpers ──────────────────────────────────────────────────────────────
const LEVEL_COLORS: Record<string, string> = {
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
debug: 'text-purple-400',
success: 'text-green-500',
};
const LEVEL_BG: Record<string, string> = {
error: 'bg-red-500/10 border-red-500/20',
warning: 'bg-yellow-500/10 border-yellow-500/20',
info: 'bg-blue-500/10 border-blue-500/20',
debug: 'bg-purple-400/10 border-purple-400/20',
success: 'bg-green-500/10 border-green-500/20',
};
/** Try to extract JSON from a log message string. */
function tryParseJson(msg: string): any | null {
try {
const parsed = JSON.parse(msg);
if (typeof parsed === 'object' && parsed !== null) return parsed;
} catch { /* not pure JSON */ }
const firstBrace = msg.search(/[{\[]/);
if (firstBrace >= 0) {
const sub = msg.slice(firstBrace);
try {
const parsed = JSON.parse(sub);
if (typeof parsed === 'object' && parsed !== null) return parsed;
} catch { /* no valid JSON suffix */ }
}
return null;
}
/** Build a structured object from a LogEntry for tree browsing. */
function logEntryToTree(log: LogEntry): Record<string, any> {
const tree: Record<string, any> = {
level: log.level,
timestamp: log.timestamp.toLocaleTimeString([], {
hour: '2-digit', minute: '2-digit', second: '2-digit',
}),
message: log.message,
};
if (log.category) tree.category = log.category;
// Structured data from tools (preferred) or auto-parsed JSON from message
if (log.data) {
tree.data = log.data;
} else {
const parsed = tryParseJson(log.message);
if (parsed) tree.parsedContent = parsed;
}
return tree;
}
/** Short preview of a log message. */
function previewMessage(msg: string, maxLen = 80): string {
const line = msg.replace(/\n/g, ' ').trim();
return line.length > maxLen ? line.slice(0, maxLen) + '…' : line;
}
/** Format a value for compact display. */
function compactValue(val: any): string {
if (val === null) return 'null';
if (val === undefined) return 'undefined';
if (typeof val === 'object') {
if (Array.isArray(val)) return `[${val.length} items]`;
const keys = Object.keys(val);
return `{${keys.length} keys}`;
}
const s = String(val);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
}
// ── Compact Tree View ────────────────────────────────────────────────────
interface DeepSearchResult {
path: string[];
key: string;
value: any;
displayValue: string;
canDrillIn: boolean;
}
/** Recursively collect all matching key/value pairs with their full paths. */
function deepCollect(obj: any, terms: string[], currentPath: string[], results: DeepSearchResult[], maxResults = 100): void {
if (results.length >= maxResults || obj == null || typeof obj !== 'object') return;
const entries = Array.isArray(obj)
? obj.map((v, i) => [String(i), v] as [string, any])
: Object.entries(obj);
for (const [key, value] of entries) {
const keyLower = key.toLowerCase();
const isObj = value !== null && typeof value === 'object';
const isArr = Array.isArray(value);
const valStr = isObj
? (isArr ? `[${(value as any[]).length} items]` : `{${Object.keys(value as object).length} keys}`)
: String(value ?? 'null');
const valLower = valStr.toLowerCase();
const fullPath = [...currentPath, key];
// Check if this entry matches
const keyMatches = terms.every(t => keyLower.includes(t));
const valMatches = !isObj && terms.every(t => valLower.includes(t));
if (keyMatches || valMatches) {
results.push({
path: currentPath,
key,
value,
displayValue: valStr,
canDrillIn: isObj,
});
}
// Recurse into children regardless (to find deeper matches)
if (isObj && results.length < maxResults) {
deepCollect(value, terms, fullPath, results, maxResults);
}
}
}
export const CompactTreeView: React.FC<{
data: any;
onExit: () => void;
header: React.ReactNode;
}> = ({ data, onExit, header }) => {
const [path, setPath] = useState<string[]>([]);
const [selectedIdx, setSelectedIdx] = useState(0);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
// Resolve items at current path (normal browsing)
const rawItems = useMemo(() => {
return resolveNavItems(data, path) || [];
}, [data, path]);
// Deep search results (flat list with paths)
const searchResults = useMemo(() => {
if (!search.trim()) return null;
const terms = search.toLowerCase().split(' ').filter(Boolean);
const results: DeepSearchResult[] = [];
deepCollect(data, terms, [], results);
return results;
}, [data, search]);
// Active list: search results or normal items
const isSearchMode = searchResults !== null;
const listLength = isSearchMode ? searchResults!.length : rawItems.length;
// Clamp selection
useEffect(() => {
if (selectedIdx >= listLength) setSelectedIdx(Math.max(0, listLength - 1));
}, [listLength, selectedIdx]);
// Auto-focus container on mount so keyboard nav works immediately
useEffect(() => {
requestAnimationFrame(() => containerRef.current?.focus());
}, []);
// Reset search on path change (selectedIdx is managed by goUp / drill-in)
useEffect(() => {
setSearch('');
}, [path]);
// Navigate to a deep search result
const navigateToResult = useCallback((result: DeepSearchResult) => {
// Navigate to the parent path, so the matched key is visible in the list
if (result.canDrillIn) {
setPath([...result.path, result.key]);
} else {
setPath(result.path);
}
setSelectedIdx(0);
}, []);
// Keyboard nav
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) {
if (e.key === 'Escape') {
e.preventDefault();
setSearch('');
containerRef.current?.focus();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (isSearchMode && searchResults![selectedIdx]) {
navigateToResult(searchResults![selectedIdx]);
}
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1);
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
e.preventDefault();
if (isSearchMode) {
const r = searchResults![selectedIdx];
if (r) navigateToResult(r);
} else {
const item = rawItems[selectedIdx];
if (item?.canDrillIn) {
setPath(p => [...p, item.key]);
setSelectedIdx(0);
}
}
} else if (e.key === 'ArrowLeft' || e.key === 'Backspace') {
e.preventDefault();
if (path.length > 0) {
const lastSeg = path[path.length - 1];
const parentPath = path.slice(0, -1);
const parentItems = resolveNavItems(data, parentPath) || [];
const restoredIdx = parentItems.findIndex((it: any) => it.key === lastSeg);
setPath(parentPath);
setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0);
} else {
onExit();
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (path.length > 0) {
const firstSeg = path[0];
const rootItems = resolveNavItems(data, []) || [];
const restoredIdx = rootItems.findIndex((it: any) => it.key === firstSeg);
setPath([]);
setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0);
}
else onExit();
} else if (e.key === 'Home') {
e.preventDefault();
setSelectedIdx(0);
} else if (e.key === 'End') {
e.preventDefault();
setSelectedIdx(listLength - 1);
} else if (e.key === '/' || (e.key === 'f' && e.ctrlKey)) {
e.preventDefault();
searchRef.current?.focus();
}
};
el.addEventListener('keydown', handler);
return () => el.removeEventListener('keydown', handler);
}, [listLength, selectedIdx, path, onExit, isSearchMode, searchResults, rawItems, navigateToResult]);
// Scroll selection into view
useEffect(() => {
const el = containerRef.current?.querySelector(`[data-tree-idx="${selectedIdx}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [selectedIdx]);
return (
<div className="flex flex-col h-full">
{/* Header with back */}
{header}
{/* Search bar */}
<div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/10 flex-shrink-0">
<Search className="h-2.5 w-2.5 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Deep search... (/ to focus)"
className="h-5 text-[10px] bg-background flex-1 border-0 shadow-none focus-visible:ring-0 px-1 font-mono"
value={search}
onChange={e => { setSearch(e.target.value); setSelectedIdx(0); }}
/>
{search && (
<button onClick={() => { setSearch(''); containerRef.current?.focus(); }} className="text-muted-foreground hover:text-foreground">
<X className="h-2.5 w-2.5" />
</button>
)}
{search && (
<span className="text-[9px] text-muted-foreground/60 tabular-nums">{listLength}</span>
)}
</div>
{/* Breadcrumb (only in browse mode) */}
{!isSearchMode && path.length > 0 && (
<div className="flex items-center gap-0.5 px-2 py-1 border-b bg-muted/10 text-[10px] font-mono flex-shrink-0 overflow-x-auto">
<button
onClick={() => {
const firstSeg = path[0];
const rootItems = resolveNavItems(data, []) || [];
const idx = rootItems.findIndex((it: any) => it.key === firstSeg);
setPath([]);
setSelectedIdx(idx >= 0 ? idx : 0);
}}
className="flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<Home className="h-2.5 w-2.5" />
</button>
{path.map((seg, i) => (
<React.Fragment key={i}>
<ChevronRight className="h-2.5 w-2.5 text-muted-foreground/40" />
<button
onClick={() => {
const targetPath = path.slice(0, i + 1);
const childSeg = path[i + 1];
if (childSeg) {
const items = resolveNavItems(data, targetPath) || [];
const idx = items.findIndex((it: any) => it.key === childSeg);
setSelectedIdx(idx >= 0 ? idx : 0);
} else {
setSelectedIdx(0);
}
setPath(targetPath);
}}
className={cn(
"px-1 py-0.5 rounded transition-colors",
i === path.length - 1
? "text-primary font-semibold"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{seg}
</button>
</React.Fragment>
))}
</div>
)}
{/* Items / Search Results */}
<div
ref={containerRef}
tabIndex={0}
className="flex-1 overflow-y-auto outline-none font-mono text-[11px]"
>
{isSearchMode ? (
/* ── Deep search results ── */
searchResults!.length === 0 ? (
<div className="text-muted-foreground text-xs text-center py-4 opacity-60">No matches</div>
) : (
searchResults!.map((result, idx) => (
<div
key={`${result.path.join('.')}.${result.key}-${idx}`}
data-tree-idx={idx}
onClick={() => { setSelectedIdx(idx); navigateToResult(result); }}
className={cn(
"px-2 py-1 rounded cursor-pointer transition-colors group",
selectedIdx === idx
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/50",
)}
>
{/* Path breadcrumb */}
{result.path.length > 0 && (
<div className="text-[9px] text-muted-foreground/50 truncate mb-0.5">
{result.path.join(' ')}
</div>
)}
{/* Key : Value */}
<div className="flex items-center gap-1.5">
<span className="text-blue-400 flex-shrink-0">{result.key}</span>
<span className="text-muted-foreground/40">:</span>
<span className={cn(
"flex-1 min-w-0 truncate",
result.canDrillIn ? "text-muted-foreground" : "text-foreground/80"
)}>
{result.displayValue.length > 120 ? result.displayValue.slice(0, 120) + '…' : result.displayValue}
</span>
{result.canDrillIn && (
<ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 transition-opacity" />
)}
</div>
</div>
))
)
) : (
/* ── Normal browse items ── */
rawItems.length === 0 ? (
<div className="text-muted-foreground text-xs text-center py-4 opacity-60">Empty</div>
) : (
rawItems.map((item, idx) => (
<div
key={item.key}
data-tree-idx={idx}
onClick={() => {
setSelectedIdx(idx);
if (item.canDrillIn) {
setPath(p => [...p, item.key]);
setSelectedIdx(0);
}
}}
className={cn(
"flex items-center gap-1.5 px-2 py-[3px] rounded cursor-pointer transition-colors group text-[11px] font-mono",
selectedIdx === idx
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/50",
)}
>
<span className="text-blue-400 flex-shrink-0">{item.key}</span>
<span className="text-muted-foreground/40">:</span>
<span className={cn(
"flex-1 min-w-0 truncate",
item.canDrillIn ? "text-muted-foreground" : "text-foreground/80"
)}>
{compactValue(item.value)}
</span>
{item.canDrillIn && (
<ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 transition-opacity" />
)}
</div>
))
)
)}
</div>
</div>
);
};
// ── Main Component ───────────────────────────────────────────────────────
const ChatLogBrowser: React.FC<ChatLogBrowserProps> = ({ logs, clearLogs, title = 'Chat Logs' }) => {
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
const [searchTerm, setSearchTerm] = useState('');
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
const [highlightedIdx, setHighlightedIdx] = useState<number>(0);
const listRef = useRef<HTMLDivElement>(null);
// Filtered logs
const filtered = useMemo(() => {
let result = logs;
if (levelFilter !== 'all') {
result = result.filter(l => l.level === levelFilter);
}
if (searchTerm.trim()) {
const terms = searchTerm.toLowerCase().split(' ').filter(Boolean);
result = result.filter(l =>
terms.every(t =>
l.message.toLowerCase().includes(t) ||
l.level.includes(t) ||
(l.category || '').toLowerCase().includes(t)
)
);
}
return result;
}, [logs, levelFilter, searchTerm]);
// Counts per level
const counts = useMemo(() => {
const c: Record<string, number> = { all: logs.length };
for (const l of logs) c[l.level] = (c[l.level] || 0) + 1;
return c;
}, [logs]);
// Reset highlight when filter changes
useEffect(() => {
setHighlightedIdx(0);
}, [levelFilter, searchTerm]);
// Keyboard nav in list mode
useEffect(() => {
if (selectedIdx !== null) return;
const el = listRef.current;
if (!el) return;
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIdx(i => i >= filtered.length - 1 ? 0 : i + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIdx(i => i <= 0 ? filtered.length - 1 : i - 1);
} else if (e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (filtered.length > 0) setSelectedIdx(highlightedIdx);
} else if (e.key === 'Home') {
e.preventDefault();
setHighlightedIdx(0);
} else if (e.key === 'End') {
e.preventDefault();
setHighlightedIdx(filtered.length - 1);
}
};
el.addEventListener('keydown', handler);
return () => el.removeEventListener('keydown', handler);
}, [selectedIdx, filtered, highlightedIdx]);
// Scroll highlighted into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-log-idx="${highlightedIdx}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [highlightedIdx]);
const handleBack = useCallback(() => {
setSelectedIdx(null);
requestAnimationFrame(() => listRef.current?.focus());
}, []);
// ── Drilled-in view (compact inline tree) ────────────────────────────
if (selectedIdx !== null) {
const log = filtered[selectedIdx];
if (!log) { setSelectedIdx(null); return null; }
const treeData = logEntryToTree(log);
return (
<div className="border border-border rounded-lg bg-card flex flex-col h-full">
<CompactTreeView
data={treeData}
onExit={handleBack}
header={
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/20 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 gap-1 text-xs"
onClick={handleBack}
>
<ArrowLeft className="h-3 w-3" />
Back
</Button>
<span className={cn("text-[10px] font-semibold", LEVEL_COLORS[log.level])}>
[{log.level.toUpperCase()}]
</span>
<span className="text-[10px] text-muted-foreground truncate flex-1 min-w-0">
{previewMessage(log.message, 40)}
</span>
</div>
}
/>
</div>
);
}
// ── List view ────────────────────────────────────────────────────────
const TABS: { key: LevelFilter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'info', label: 'Info' },
{ key: 'debug', label: 'Debug' },
{ key: 'warning', label: 'Warn' },
{ key: 'error', label: 'Err' },
{ key: 'success', label: 'OK' },
];
return (
<div className="border border-border rounded-lg bg-card flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-2 px-3 border-b bg-muted/20 flex-shrink-0">
<h3 className="text-sm font-bold flex items-center gap-1.5">
<ListFilter className="h-3.5 w-3.5" />
{title}
<span className="text-xs font-normal text-muted-foreground ml-1">
({filtered.length})
</span>
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={clearLogs}
disabled={logs.length === 0}
title="Clear logs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Search */}
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/10 flex-shrink-0">
<Search className="h-3 w-3 text-muted-foreground" />
<Input
placeholder="Filter logs..."
className="h-6 text-xs bg-background flex-1 border-0 shadow-none focus-visible:ring-0 px-1"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button onClick={() => setSearchTerm('')} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Level tabs */}
<div className="flex gap-0.5 px-2 py-1 border-b bg-muted/5 flex-shrink-0 overflow-x-auto">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setLevelFilter(tab.key)}
className={cn(
"text-[10px] px-1.5 py-0.5 rounded transition-colors whitespace-nowrap",
levelFilter === tab.key
? "bg-primary text-primary-foreground font-semibold"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{tab.label}
{(counts[tab.key] || 0) > 0 && (
<span className="ml-0.5 opacity-70">{counts[tab.key]}</span>
)}
</button>
))}
</div>
{/* Log list */}
<div
ref={listRef}
tabIndex={0}
className="flex-1 overflow-y-auto outline-none focus:ring-1 focus:ring-primary/20 focus:ring-inset"
>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-xs py-8 opacity-60">
<ListFilter className="h-6 w-6 mb-2 stroke-1" />
<p>No logs{searchTerm ? ' matching filter' : ''}</p>
</div>
) : (
<div className="p-1">
{filtered.map((log, idx) => (
<div
key={log.id}
data-log-idx={idx}
onClick={() => { setHighlightedIdx(idx); setSelectedIdx(idx); }}
className={cn(
"flex items-start gap-1.5 px-2 py-1 rounded cursor-pointer transition-colors group text-[11px] font-mono",
highlightedIdx === idx
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/50",
)}
>
{/* Time */}
<span className="text-muted-foreground/70 flex-shrink-0 tabular-nums">
{log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
{/* Level badge */}
<span className={cn(
"flex-shrink-0 px-1 rounded text-[10px] font-semibold border",
LEVEL_BG[log.level] || '',
LEVEL_COLORS[log.level] || 'text-muted-foreground'
)}>
{log.level.slice(0, 3).toUpperCase()}
</span>
{/* Message preview */}
<span className="flex-1 min-w-0 truncate text-foreground/90">
{previewMessage(log.message)}
</span>
{/* Drill-in hint */}
<ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 mt-0.5 transition-opacity" />
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ChatLogBrowser;