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>
This commit is contained in:
Trey t
2025-12-09 10:26:40 -06:00
parent 12eac24632
commit eb127fda20
31 changed files with 2880 additions and 213 deletions
+43
View File
@@ -14,6 +14,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -25,6 +26,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"next": "16.0.5",
"next-themes": "^0.4.6",
@@ -1925,6 +1927,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -3921,6 +3954,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+2
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -26,6 +27,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"next": "16.0.5",
"next-themes": "^0.4.6",
@@ -0,0 +1,147 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { RefreshCw, AlertCircle, Settings } from 'lucide-react';
import { toast } from 'sonner';
import {
StatsCards,
SystemOverview,
LogViewer,
HTTPStatsOverview,
HTTPEndpointStats,
QueueOverview,
QueueDetails,
} from '@/components/monitoring';
import { useMonitoring, useLogStream } from '@/hooks/use-monitoring';
import { settingsApi } from '@/lib/api';
export default function MonitoringPage() {
const [activeTab, setActiveTab] = useState('overview');
const queryClient = useQueryClient();
const { logs: initialLogs, stats, isLoading, logsError, statsError, refetchLogs, refetchStats, clearLogs, isClearingLogs } = useMonitoring();
const { logs: liveLogs, status: wsStatus, clearLogs: clearLiveLogs } = useLogStream(activeTab === 'logs');
// Fetch monitoring enabled setting
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
// Mutation to update monitoring setting
const updateMonitoringMutation = useMutation({
mutationFn: (enabled: boolean) => settingsApi.update({ enable_monitoring: enabled }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
toast.success(data.enable_monitoring ? 'Monitoring enabled' : 'Monitoring disabled');
},
onError: () => {
toast.error('Failed to update monitoring setting');
},
});
// Combine live logs with initial logs (live logs take precedence)
const allLogs = useMemo(() => {
const initial = initialLogs?.logs || [];
return [...liveLogs, ...initial].slice(0, 1000);
}, [liveLogs, initialLogs?.logs]);
const handleRefresh = () => {
refetchLogs();
refetchStats();
};
const handleClearLogs = () => {
clearLogs();
clearLiveLogs();
};
const handleMonitoringToggle = () => {
if (settings) {
updateMonitoringMutation.mutate(!settings.enable_monitoring);
}
};
const hasError = logsError || statsError;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Monitoring</h1>
<p className="text-muted-foreground">
Real-time logs and system statistics
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="monitoring-toggle"
checked={settings?.enable_monitoring ?? true}
onCheckedChange={handleMonitoringToggle}
disabled={updateMonitoringMutation.isPending}
/>
<Label htmlFor="monitoring-toggle" className="text-sm">
{settings?.enable_monitoring ? 'Enabled' : 'Disabled'}
</Label>
</div>
<Button onClick={handleRefresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{hasError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Error</AlertTitle>
<AlertDescription>
Unable to connect to the monitoring service. Make sure the API server is running with Redis connected.
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="api">API Stats</TabsTrigger>
<TabsTrigger value="worker">Worker Stats</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<SystemOverview apiStats={stats?.api} workerStats={stats?.worker} />
<StatsCards apiStats={stats?.api} workerStats={stats?.worker} />
</TabsContent>
<TabsContent value="logs">
<LogViewer
logs={allLogs}
wsStatus={wsStatus}
isLoading={isLoading}
onClearLogs={handleClearLogs}
onRefresh={() => refetchLogs()}
isClearingLogs={isClearingLogs}
/>
</TabsContent>
<TabsContent value="api" className="space-y-4">
<HTTPStatsOverview stats={stats?.api?.http} />
<HTTPEndpointStats stats={stats?.api?.http} />
</TabsContent>
<TabsContent value="worker" className="space-y-4">
<QueueOverview stats={stats?.worker?.asynq} />
<QueueDetails stats={stats?.worker?.asynq} />
</TabsContent>
</Tabs>
</div>
);
}
+2
View File
@@ -1,6 +1,7 @@
'use client';
import {
Activity,
Home,
Users,
Building2,
@@ -75,6 +76,7 @@ const limitationsItems = [
];
const settingsItems = [
{ title: 'Monitoring', url: '/admin/monitoring', icon: Activity },
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
@@ -0,0 +1,150 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { HTTPStats } from '@/types/monitoring';
import { formatLatency, formatPercent } from '@/hooks/use-monitoring';
import { Activity, AlertTriangle, Clock, Gauge } from 'lucide-react';
interface HTTPStatsProps {
stats?: HTTPStats;
}
export function HTTPStatsOverview({ stats }: HTTPStatsProps) {
if (!stats) {
return (
<Card>
<CardHeader>
<CardTitle>HTTP Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No HTTP statistics available
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats.total_requests ?? 0).toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
{(stats.requests_per_second ?? 0).toFixed(2)} req/s
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Latency</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatLatency(stats.avg_latency_ms)}</div>
<p className="text-xs text-muted-foreground">
P95: {formatLatency(stats.p95_latency_ms)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.error_rate)}</div>
<p className="text-xs text-muted-foreground">
{(stats.errors_total ?? 0).toLocaleString()} total errors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Throughput</CardTitle>
<Gauge className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats.requests_per_second ?? 0).toFixed(2)}</div>
<p className="text-xs text-muted-foreground">requests per second</p>
</CardContent>
</Card>
</div>
);
}
export function HTTPEndpointStats({ stats }: HTTPStatsProps) {
if (!stats?.by_endpoint || Object.keys(stats.by_endpoint).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Endpoint Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No endpoint statistics available
</div>
</CardContent>
</Card>
);
}
// Sort endpoints by request count (descending)
const sortedEndpoints = Object.entries(stats.by_endpoint)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 20); // Show top 20
return (
<Card>
<CardHeader>
<CardTitle>Top Endpoints by Traffic</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
<TableHead className="text-right">Errors</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedEndpoints.map(([endpoint, data]) => {
const errorRate = data.count > 0 ? (data.errors / data.count) * 100 : 0;
return (
<TableRow key={endpoint}>
<TableCell className="font-mono text-sm">{endpoint}</TableCell>
<TableCell className="text-right">{data.count.toLocaleString()}</TableCell>
<TableCell className="text-right">{formatLatency(data.avg_latency_ms)}</TableCell>
<TableCell className="text-right">{data.errors}</TableCell>
<TableCell className="text-right">
<span className={errorRate > 5 ? 'text-red-500' : ''}>
{formatPercent(errorRate)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { StatsCards, SystemOverview } from './stats-cards';
export { LogViewer } from './log-viewer';
export { HTTPStatsOverview, HTTPEndpointStats } from './http-stats';
export { QueueOverview, QueueDetails } from './queue-stats';
@@ -0,0 +1,257 @@
'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>
</>
);
}
@@ -0,0 +1,189 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { AsynqStats } from '@/types/monitoring';
import { Clock, AlertTriangle, CheckCircle, PlayCircle, RotateCcw, Archive } from 'lucide-react';
interface QueueStatsProps {
stats?: AsynqStats;
}
export function QueueOverview({ stats }: QueueStatsProps) {
if (!stats) {
return (
<Card>
<CardHeader>
<CardTitle>Queue Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No queue statistics available
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_pending}</div>
<p className="text-xs text-muted-foreground">tasks waiting</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<PlayCircle className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-500">{stats.total_active}</div>
<p className="text-xs text-muted-foreground">currently running</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Scheduled</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_scheduled}</div>
<p className="text-xs text-muted-foreground">future tasks</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Retry</CardTitle>
<RotateCcw className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">{stats.total_retry}</div>
<p className="text-xs text-muted-foreground">awaiting retry</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Failed</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{stats.total_failed}</div>
<p className="text-xs text-muted-foreground">permanently failed</p>
</CardContent>
</Card>
</div>
);
}
export function QueueDetails({ stats }: QueueStatsProps) {
if (!stats?.queues || Object.keys(stats.queues).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Queue Details</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No queue details available
</div>
</CardContent>
</Card>
);
}
// Sort queues by priority (critical first)
const priorityOrder = ['critical', 'default', 'low'];
const sortedQueues = Object.entries(stats.queues).sort((a, b) => {
const aIndex = priorityOrder.indexOf(a[0]);
const bIndex = priorityOrder.indexOf(b[0]);
if (aIndex === -1 && bIndex === -1) return a[0].localeCompare(b[0]);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
const getPriorityBadge = (queueName: string) => {
switch (queueName) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'default':
return <Badge variant="default">Default</Badge>;
case 'low':
return <Badge variant="secondary">Low</Badge>;
default:
return <Badge variant="outline">{queueName}</Badge>;
}
};
return (
<Card>
<CardHeader>
<CardTitle>Queue Details</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue</TableHead>
<TableHead className="text-right">Size</TableHead>
<TableHead className="text-right">Pending</TableHead>
<TableHead className="text-right">Active</TableHead>
<TableHead className="text-right">Scheduled</TableHead>
<TableHead className="text-right">Retry</TableHead>
<TableHead className="text-right">Archived</TableHead>
<TableHead className="text-right">Completed</TableHead>
<TableHead className="text-right">Failed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedQueues.map(([queueName, queue]) => (
<TableRow key={queueName}>
<TableCell>{getPriorityBadge(queueName)}</TableCell>
<TableCell className="text-right">{queue.size}</TableCell>
<TableCell className="text-right">{queue.pending}</TableCell>
<TableCell className="text-right">
<span className={queue.active > 0 ? 'text-blue-500 font-medium' : ''}>
{queue.active}
</span>
</TableCell>
<TableCell className="text-right">{queue.scheduled}</TableCell>
<TableCell className="text-right">
<span className={queue.retry > 0 ? 'text-yellow-500' : ''}>
{queue.retry}
</span>
</TableCell>
<TableCell className="text-right">{queue.archived}</TableCell>
<TableCell className="text-right">
<span className="text-green-500">{queue.completed}</span>
</TableCell>
<TableCell className="text-right">
<span className={queue.failed > 0 ? 'text-red-500 font-medium' : ''}>
{queue.failed}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,159 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { SystemStats } from '@/types/monitoring';
import { formatBytes, formatUptime, formatPercent, safeToFixed } from '@/hooks/use-monitoring';
import { Activity, Cpu, HardDrive, Server, Timer, Zap } from 'lucide-react';
interface StatsCardsProps {
apiStats?: SystemStats;
workerStats?: SystemStats;
}
interface ProcessCardProps {
stats: SystemStats;
title: string;
}
function ProcessCard({ stats, title }: ProcessCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Badge variant="default" className="bg-green-500">Online</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" />
CPU
</div>
<p className="text-lg font-semibold">{formatPercent(stats.cpu?.usage_percent)}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
Memory
</div>
<p className="text-lg font-semibold">{formatBytes(stats.memory?.go_alloc)}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
Goroutines
</div>
<p className="text-lg font-semibold">{stats.runtime?.goroutines ?? 0}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Timer className="h-3 w-3" />
Uptime
</div>
<p className="text-lg font-semibold">{formatUptime(stats.runtime?.uptime_seconds)}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function OfflineCard({ title }: { title: string }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Badge variant="destructive">Offline</Badge>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</CardContent>
</Card>
);
}
export function StatsCards({ apiStats, workerStats }: StatsCardsProps) {
return (
<div className="grid gap-4 md:grid-cols-2">
{apiStats ? (
<ProcessCard stats={apiStats} title="API Server" />
) : (
<OfflineCard title="API Server" />
)}
{workerStats ? (
<ProcessCard stats={workerStats} title="Worker" />
) : (
<OfflineCard title="Worker" />
)}
</div>
);
}
interface SystemOverviewProps {
apiStats?: SystemStats;
workerStats?: SystemStats;
}
export function SystemOverview({ apiStats, workerStats }: SystemOverviewProps) {
const stats = apiStats || workerStats;
if (!stats) return null;
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System CPU</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.cpu?.usage_percent)}</div>
<p className="text-xs text-muted-foreground">
Load: {safeToFixed(stats.cpu?.load_avg_1)} / {safeToFixed(stats.cpu?.load_avg_5)} / {safeToFixed(stats.cpu?.load_avg_15)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Memory</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.memory?.system_percent)}</div>
<p className="text-xs text-muted-foreground">
{formatBytes(stats.memory?.system_used)} / {formatBytes(stats.memory?.system_total)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.disk?.percent)}</div>
<p className="text-xs text-muted-foreground">
{formatBytes(stats.disk?.used)} / {formatBytes(stats.disk?.total)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">GC Runs</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(apiStats?.runtime.gc_runs || 0) + (workerStats?.runtime.gc_runs || 0)}</div>
<p className="text-xs text-muted-foreground">
Total pause: {((apiStats?.runtime.gc_pause_total_ns || 0) + (workerStats?.runtime.gc_pause_total_ns || 0)) / 1000000}ms
</p>
</CardContent>
</Card>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+230
View File
@@ -0,0 +1,230 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, useCallback, useRef } from 'react';
import { monitoringApi } from '@/lib/api';
import type { LogEntry, LogFilters, LogsResponse, StatsResponse, SystemStats } from '@/types/monitoring';
// Query keys
export const monitoringKeys = {
all: ['monitoring'] as const,
logs: (filters?: LogFilters) => [...monitoringKeys.all, 'logs', filters] as const,
stats: () => [...monitoringKeys.all, 'stats'] as const,
};
// Hook for fetching logs
export function useLogs(filters?: LogFilters) {
return useQuery({
queryKey: monitoringKeys.logs(filters),
queryFn: () => monitoringApi.getLogs(filters),
refetchInterval: 10000, // Refetch every 10 seconds
});
}
// Hook for fetching stats
export function useStats() {
return useQuery({
queryKey: monitoringKeys.stats(),
queryFn: () => monitoringApi.getStats(),
refetchInterval: 5000, // Refetch every 5 seconds
});
}
// Hook for clearing logs
export function useClearLogs() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => monitoringApi.clearLogs(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: monitoringKeys.logs() });
},
});
}
// WebSocket connection status
export type WSStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
// Hook for real-time log streaming via WebSocket
export function useLogStream(enabled: boolean = true) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [status, setStatus] = useState<WSStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const enabledRef = useRef(enabled);
const maxLogs = 500; // Keep at most 500 logs in memory
// Keep enabledRef in sync
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
}, []);
const connect = useCallback(() => {
// Only run in browser
if (typeof window === 'undefined') return;
if (!enabledRef.current) return;
// Clean up existing connection
if (wsRef.current) {
wsRef.current.close();
}
setStatus('connecting');
try {
const wsUrl = monitoringApi.getWebSocketUrl();
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('connected');
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'log' && message.data) {
const newLog = message.data as LogEntry;
setLogs((prev) => {
// Add new log at the beginning, limit total logs
const updated = [newLog, ...prev].slice(0, maxLogs);
return updated;
});
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onerror = () => {
setStatus('error');
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
// Attempt to reconnect after 3 seconds
if (enabledRef.current && !reconnectTimeoutRef.current) {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = null;
connect();
}, 3000);
}
};
wsRef.current = ws;
} catch (err) {
console.error('Failed to create WebSocket:', err);
setStatus('error');
}
}, []);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
useEffect(() => {
if (enabled) {
connect();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
logs,
status,
connect,
disconnect,
clearLogs,
};
}
// Combined hook for monitoring page
export function useMonitoring(filters?: LogFilters) {
const { data: logsData, isLoading: logsLoading, error: logsError, refetch: refetchLogs } = useLogs(filters);
const { data: statsData, isLoading: statsLoading, error: statsError, refetch: refetchStats } = useStats();
const clearLogsMutation = useClearLogs();
return {
logs: logsData,
stats: statsData,
isLoading: logsLoading || statsLoading,
logsError,
statsError,
refetchLogs,
refetchStats,
clearLogs: clearLogsMutation.mutate,
isClearingLogs: clearLogsMutation.isPending,
};
}
// Utility to format bytes to human-readable
export function formatBytes(bytes: number | undefined | null): string {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
// Utility to format uptime
export function formatUptime(seconds: number | undefined | null): string {
if (seconds == null) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (parts.length === 0) parts.push(`${Math.floor(seconds)}s`);
return parts.join(' ');
}
// Utility to format percentage
export function formatPercent(value: number | undefined | null): string {
if (value == null) return '0%';
return `${value.toFixed(1)}%`;
}
// Utility to format latency
export function formatLatency(ms: number | undefined | null): string {
if (ms == null) return '0ms';
if (ms < 1) {
return `${(ms * 1000).toFixed(0)}µs`;
}
if (ms < 1000) {
return `${ms.toFixed(1)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
// Safe toFixed helper
export function safeToFixed(value: number | undefined | null, digits: number = 2): string {
if (value == null) return '0';
return value.toFixed(digits);
}
+39
View File
@@ -665,10 +665,12 @@ export const notificationPrefsApi = {
// Settings types
export interface SystemSettings {
enable_limitations: boolean;
enable_monitoring: boolean;
}
export interface UpdateSettingsRequest {
enable_limitations?: boolean;
enable_monitoring?: boolean;
}
// Settings API
@@ -1598,4 +1600,41 @@ export const onboardingEmailsApi = {
},
};
// Monitoring Types
import type { LogEntry, LogFilters, LogsResponse, StatsResponse } from '@/types/monitoring';
// Monitoring API
export const monitoringApi = {
getLogs: async (filters?: LogFilters): Promise<LogsResponse> => {
const response = await api.get<LogsResponse>('/monitoring/logs', { params: filters });
return response.data;
},
getStats: async (): Promise<StatsResponse> => {
const response = await api.get<StatsResponse>('/monitoring/stats');
return response.data;
},
clearLogs: async (): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>('/monitoring/logs');
return response.data;
},
// WebSocket URL for real-time log streaming
getWebSocketUrl: (): string => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
// Convert http(s) to ws(s)
let wsBase = baseUrl.replace(/^http/, 'ws');
if (!wsBase) {
// If no base URL, use current host
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsBase = `${protocol}//${typeof window !== 'undefined' ? window.location.host : 'localhost'}`;
}
return `${wsBase}/api/admin/monitoring/ws?token=${token || ''}`;
},
};
export default api;
+129
View File
@@ -0,0 +1,129 @@
// Monitoring Types
export interface LogEntry {
id: string;
timestamp: string;
level: string; // "debug", "info", "warn", "error"
message: string;
caller: string;
process: string; // "api" or "worker"
fields: Record<string, unknown>;
}
export interface CPUStats {
usage_percent: number;
load_avg_1: number;
load_avg_5: number;
load_avg_15: number;
}
export interface MemoryStats {
system_total: number;
system_used: number;
system_percent: number;
go_alloc: number;
go_total_alloc: number;
go_sys: number;
go_heap_alloc: number;
go_heap_sys: number;
}
export interface DiskStats {
total: number;
used: number;
percent: number;
}
export interface RuntimeStats {
goroutines: number;
gc_runs: number;
gc_pause_ns: number;
gc_pause_total_ns: number;
uptime_seconds: number;
}
export interface EndpointStats {
count: number;
total_latency_ms: number;
avg_latency_ms: number;
errors: number;
}
export interface HTTPStats {
total_requests: number;
requests_per_second: number;
avg_latency_ms: number;
p95_latency_ms: number;
errors_total: number;
error_rate: number;
by_endpoint: Record<string, EndpointStats>;
}
export interface QueueStats {
size: number;
pending: number;
active: number;
scheduled: number;
retry: number;
archived: number;
completed: number;
failed: number;
}
export interface AsynqStats {
queues: Record<string, QueueStats>;
total_pending: number;
total_active: number;
total_scheduled: number;
total_retry: number;
total_failed: number;
}
export interface SystemStats {
timestamp: string;
process: string; // "api" or "worker"
cpu: CPUStats;
memory: MemoryStats;
disk: DiskStats;
runtime: RuntimeStats;
http?: HTTPStats; // API only
asynq?: AsynqStats; // Worker only
}
export interface LogFilters {
level?: string;
process?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface LogsResponse {
logs: LogEntry[];
total: number;
offset: number;
limit: number;
}
export interface StatsResponse {
api?: SystemStats;
worker?: SystemStats;
}
export type LogLevel = "debug" | "info" | "warn" | "error";
export const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
export const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
debug: "text-gray-500",
info: "text-blue-500",
warn: "text-yellow-500",
error: "text-red-500",
};
export const LOG_LEVEL_BG_COLORS: Record<LogLevel, string> = {
debug: "bg-gray-100 text-gray-800",
info: "bg-blue-100 text-blue-800",
warn: "bg-yellow-100 text-yellow-800",
error: "bg-red-100 text-red-800",
};