Files
honeyDueAPI/admin/src/components/monitoring/log-viewer.tsx
Trey t eb127fda20 Add real-time log monitoring and system stats dashboard
Implements a comprehensive monitoring system for the admin interface:

Backend:
- New monitoring package with Redis ring buffer for log storage
- Zerolog MultiWriter to capture logs to Redis
- System stats collection (CPU, memory, disk, goroutines, GC)
- HTTP metrics middleware (request counts, latency, error rates)
- Asynq queue stats for worker process
- WebSocket endpoint for real-time log streaming
- Admin auth middleware now accepts token in query params (for WebSocket)

Frontend:
- New monitoring page with tabs (Overview, Logs, API Stats, Worker Stats)
- Real-time log viewer with level filtering and search
- System stats cards showing CPU, memory, goroutines, uptime
- HTTP endpoint statistics table
- Asynq queue depth visualization
- Enable/disable monitoring toggle in settings

Memory safeguards:
- Max 200 unique endpoints tracked
- Hourly stats reset to prevent unbounded growth
- Max 1000 log entries in ring buffer
- Max 1000 latency samples for P95 calculation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 10:26:40 -06:00

258 lines
9.1 KiB
TypeScript

'use client';
import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, Wifi, WifiOff, RefreshCw, Search, X } from 'lucide-react';
import type { LogEntry, LogLevel } from '@/types/monitoring';
import { LOG_LEVEL_BG_COLORS, LOG_LEVELS } from '@/types/monitoring';
import type { WSStatus } from '@/hooks/use-monitoring';
import { formatDistanceToNow } from 'date-fns';
interface LogViewerProps {
logs: LogEntry[];
wsStatus: WSStatus;
isLoading?: boolean;
onClearLogs?: () => void;
onRefresh?: () => void;
isClearingLogs?: boolean;
}
export function LogViewer({
logs,
wsStatus,
isLoading,
onClearLogs,
onRefresh,
isClearingLogs,
}: LogViewerProps) {
const [levelFilter, setLevelFilter] = useState<string>('all');
const [processFilter, setProcessFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (levelFilter !== 'all' && log.level !== levelFilter) return false;
if (processFilter !== 'all' && log.process !== processFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesMessage = log.message.toLowerCase().includes(query);
const matchesCaller = log.caller?.toLowerCase().includes(query);
const matchesFields = JSON.stringify(log.fields).toLowerCase().includes(query);
if (!matchesMessage && !matchesCaller && !matchesFields) return false;
}
return true;
});
}, [logs, levelFilter, processFilter, searchQuery]);
const wsStatusBadge = () => {
switch (wsStatus) {
case 'connected':
return (
<Badge variant="default" className="bg-green-500">
<Wifi className="h-3 w-3 mr-1" />
Live
</Badge>
);
case 'connecting':
return (
<Badge variant="secondary">
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Connecting
</Badge>
);
case 'error':
return (
<Badge variant="destructive">
<WifiOff className="h-3 w-3 mr-1" />
Error
</Badge>
);
default:
return (
<Badge variant="outline">
<WifiOff className="h-3 w-3 mr-1" />
Disconnected
</Badge>
);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Logs
{wsStatusBadge()}
<Badge variant="outline">{filteredLogs.length} entries</Badge>
</CardTitle>
<div className="flex items-center gap-2">
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
)}
{onClearLogs && (
<Button
variant="outline"
size="sm"
onClick={onClearLogs}
disabled={isClearingLogs}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={processFilter} onValueChange={setProcessFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Process" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="api">API</SelectItem>
<SelectItem value="worker">Worker</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1 h-6 w-6 p-0"
onClick={() => setSearchQuery('')}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px]">
<div className="space-y-1">
{filteredLogs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No logs to display
</div>
) : (
filteredLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-2 p-2 rounded hover:bg-muted cursor-pointer text-sm font-mono"
onClick={() => setSelectedLog(log)}
>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</span>
<Badge
variant="secondary"
className={`${LOG_LEVEL_BG_COLORS[log.level as LogLevel] || ''} text-xs`}
>
{log.level.toUpperCase()}
</Badge>
<Badge variant="outline" className="text-xs">
{log.process}
</Badge>
<span className="truncate flex-1">{log.message}</span>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<Dialog open={!!selectedLog} onOpenChange={() => setSelectedLog(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Log Details
{selectedLog && (
<Badge
variant="secondary"
className={LOG_LEVEL_BG_COLORS[selectedLog.level as LogLevel] || ''}
>
{selectedLog.level.toUpperCase()}
</Badge>
)}
</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Timestamp:</span>
<p className="font-mono">{new Date(selectedLog.timestamp).toISOString()}</p>
</div>
<div>
<span className="text-muted-foreground">Process:</span>
<p className="font-mono">{selectedLog.process}</p>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Caller:</span>
<p className="font-mono">{selectedLog.caller || 'N/A'}</p>
</div>
</div>
<div>
<span className="text-sm text-muted-foreground">Message:</span>
<p className="font-mono p-2 bg-muted rounded mt-1">{selectedLog.message}</p>
</div>
{selectedLog.fields && Object.keys(selectedLog.fields).length > 0 && (
<div>
<span className="text-sm text-muted-foreground">Fields:</span>
<pre className="font-mono text-xs p-2 bg-muted rounded mt-1 overflow-x-auto">
{JSON.stringify(selectedLog.fields, null, 2)}
</pre>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}