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:
Generated
+43
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user