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>
258 lines
9.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|