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:
43
admin/package-lock.json
generated
43
admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
147
admin/src/app/(dashboard)/monitoring/page.tsx
Normal file
147
admin/src/app/(dashboard)/monitoring/page.tsx
Normal file
@@ -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 },
|
||||
|
||||
150
admin/src/components/monitoring/http-stats.tsx
Normal file
150
admin/src/components/monitoring/http-stats.tsx
Normal file
@@ -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
admin/src/components/monitoring/index.ts
Normal file
4
admin/src/components/monitoring/index.ts
Normal 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';
|
||||
257
admin/src/components/monitoring/log-viewer.tsx
Normal file
257
admin/src/components/monitoring/log-viewer.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
admin/src/components/monitoring/queue-stats.tsx
Normal file
189
admin/src/components/monitoring/queue-stats.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
159
admin/src/components/monitoring/stats-cards.tsx
Normal file
159
admin/src/components/monitoring/stats-cards.tsx
Normal file
@@ -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
admin/src/components/ui/alert.tsx
Normal file
66
admin/src/components/ui/alert.tsx
Normal 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
admin/src/components/ui/scroll-area.tsx
Normal file
58
admin/src/components/ui/scroll-area.tsx
Normal 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
admin/src/hooks/use-monitoring.ts
Normal file
230
admin/src/hooks/use-monitoring.ts
Normal 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);
|
||||
}
|
||||
@@ -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
admin/src/types/monitoring.ts
Normal file
129
admin/src/types/monitoring.ts
Normal 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",
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/database"
|
||||
"github.com/treytartt/casera-api/internal/i18n"
|
||||
"github.com/treytartt/casera-api/internal/monitoring"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/router"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
@@ -29,7 +30,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
// Initialize basic logger first (will be enhanced after Redis connects)
|
||||
utils.InitLogger(cfg.Server.Debug)
|
||||
|
||||
// Initialize i18n
|
||||
@@ -80,6 +81,27 @@ func main() {
|
||||
defer cache.Close()
|
||||
}
|
||||
|
||||
// Initialize monitoring service (if Redis is available)
|
||||
var monitoringService *monitoring.Service
|
||||
if cache != nil {
|
||||
monitoringService = monitoring.NewService(monitoring.Config{
|
||||
Process: "api",
|
||||
RedisClient: cache.Client(),
|
||||
DB: db, // Pass database for enable_monitoring setting sync
|
||||
})
|
||||
|
||||
// Reinitialize logger with monitoring writer
|
||||
utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter())
|
||||
|
||||
// Start stats collection
|
||||
monitoringService.Start()
|
||||
defer monitoringService.Stop()
|
||||
|
||||
log.Info().
|
||||
Bool("log_capture_enabled", monitoringService.IsEnabled()).
|
||||
Msg("Monitoring service initialized")
|
||||
}
|
||||
|
||||
// Initialize email service
|
||||
var emailService *services.EmailService
|
||||
log.Info().
|
||||
@@ -140,6 +162,7 @@ func main() {
|
||||
PDFService: pdfService,
|
||||
PushClient: pushClient,
|
||||
StorageService: storageService,
|
||||
MonitoringService: monitoringService,
|
||||
}
|
||||
r := router.SetupRouter(deps)
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/database"
|
||||
"github.com/treytartt/casera-api/internal/monitoring"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
@@ -70,6 +72,43 @@ func main() {
|
||||
log.Fatal().Err(err).Msg("Failed to parse Redis URL")
|
||||
}
|
||||
|
||||
// Initialize monitoring service (if Redis is available)
|
||||
var monitoringService *monitoring.Service
|
||||
redisClientOpt, ok := redisOpt.(asynq.RedisClientOpt)
|
||||
if ok {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisClientOpt.Addr,
|
||||
Password: redisClientOpt.Password,
|
||||
DB: redisClientOpt.DB,
|
||||
})
|
||||
|
||||
// Verify Redis connection
|
||||
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to connect to Redis for monitoring - monitoring disabled")
|
||||
} else {
|
||||
monitoringService = monitoring.NewService(monitoring.Config{
|
||||
Process: "worker",
|
||||
RedisClient: redisClient,
|
||||
DB: db, // Pass database for enable_monitoring setting sync
|
||||
})
|
||||
|
||||
// Reinitialize logger with monitoring writer
|
||||
utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter())
|
||||
|
||||
// Create Asynq inspector for queue statistics
|
||||
inspector := asynq.NewInspector(redisOpt)
|
||||
monitoringService.SetAsynqInspector(inspector)
|
||||
|
||||
// Start stats collection
|
||||
monitoringService.Start()
|
||||
defer monitoringService.Stop()
|
||||
|
||||
log.Info().
|
||||
Bool("log_capture_enabled", monitoringService.IsEnabled()).
|
||||
Msg("Monitoring service initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// Create Asynq server
|
||||
srv := asynq.NewServer(
|
||||
redisOpt,
|
||||
|
||||
60
go.mod
60
go.mod
@@ -9,14 +9,19 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||
github.com/redis/go-redis/v9 v9.17.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/sideshow/apns2 v0.25.0
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/text v0.27.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -24,47 +29,22 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.23.1 // indirect
|
||||
cloud.google.com/go v0.121.0 // indirect
|
||||
cloud.google.com/go/auth v0.16.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/storage v1.53.0 // indirect
|
||||
firebase.google.com/go/v4 v4.18.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -74,50 +54,34 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sideshow/apns2 v0.25.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
google.golang.org/api v0.231.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
||||
google.golang.org/grpc v1.72.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
158
go.sum
158
go.sum
@@ -1,33 +1,5 @@
|
||||
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
|
||||
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
|
||||
cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
|
||||
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
|
||||
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
|
||||
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -43,20 +15,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -69,13 +33,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -89,28 +48,19 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
||||
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -138,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@@ -158,16 +110,15 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
|
||||
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
@@ -175,6 +126,12 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
|
||||
@@ -189,8 +146,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -205,106 +160,49 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q=
|
||||
google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
|
||||
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
|
||||
@@ -29,6 +29,7 @@ func NewAdminSettingsHandler(db *gorm.DB) *AdminSettingsHandler {
|
||||
// SettingsResponse represents the settings response
|
||||
type SettingsResponse struct {
|
||||
EnableLimitations bool `json:"enable_limitations"`
|
||||
EnableMonitoring bool `json:"enable_monitoring"`
|
||||
}
|
||||
|
||||
// GetSettings handles GET /api/admin/settings
|
||||
@@ -37,7 +38,7 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
|
||||
if err := h.db.First(&settings, 1).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create default settings
|
||||
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
|
||||
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false, EnableMonitoring: true}
|
||||
h.db.Create(&settings)
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||
@@ -47,12 +48,14 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, SettingsResponse{
|
||||
EnableLimitations: settings.EnableLimitations,
|
||||
EnableMonitoring: settings.EnableMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSettingsRequest represents the update request
|
||||
type UpdateSettingsRequest struct {
|
||||
EnableLimitations *bool `json:"enable_limitations"`
|
||||
EnableMonitoring *bool `json:"enable_monitoring"`
|
||||
}
|
||||
|
||||
// UpdateSettings handles PUT /api/admin/settings
|
||||
@@ -66,7 +69,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
var settings models.SubscriptionSettings
|
||||
if err := h.db.First(&settings, 1).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
settings = models.SubscriptionSettings{ID: 1}
|
||||
settings = models.SubscriptionSettings{ID: 1, EnableMonitoring: true}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||
return
|
||||
@@ -77,6 +80,10 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
settings.EnableLimitations = *req.EnableLimitations
|
||||
}
|
||||
|
||||
if req.EnableMonitoring != nil {
|
||||
settings.EnableMonitoring = *req.EnableMonitoring
|
||||
}
|
||||
|
||||
if err := h.db.Save(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
@@ -84,6 +91,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, SettingsResponse{
|
||||
EnableLimitations: settings.EnableLimitations,
|
||||
EnableMonitoring: settings.EnableMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/admin/handlers"
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/middleware"
|
||||
"github.com/treytartt/casera-api/internal/monitoring"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
@@ -22,6 +23,7 @@ type Dependencies struct {
|
||||
EmailService *services.EmailService
|
||||
PushClient *push.Client
|
||||
OnboardingService *services.OnboardingEmailService
|
||||
MonitoringHandler *monitoring.Handler
|
||||
}
|
||||
|
||||
// SetupRoutes configures all admin routes
|
||||
@@ -424,6 +426,17 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
onboardingEmails.GET("/:id", onboardingHandler.Get)
|
||||
onboardingEmails.DELETE("/:id", onboardingHandler.Delete)
|
||||
}
|
||||
|
||||
// System monitoring (logs, stats, websocket)
|
||||
if deps != nil && deps.MonitoringHandler != nil {
|
||||
monitoringGroup := protected.Group("/monitoring")
|
||||
{
|
||||
monitoringGroup.GET("/logs", deps.MonitoringHandler.GetLogs)
|
||||
monitoringGroup.GET("/stats", deps.MonitoringHandler.GetStats)
|
||||
monitoringGroup.DELETE("/logs", deps.MonitoringHandler.ClearLogs)
|
||||
monitoringGroup.GET("/ws", deps.MonitoringHandler.WebSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,21 +32,27 @@ type AdminClaims struct {
|
||||
// AdminAuthMiddleware creates a middleware that validates admin JWT tokens
|
||||
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var tokenString string
|
||||
|
||||
// Get token from Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
return
|
||||
}
|
||||
|
||||
if authHeader != "" {
|
||||
// Check Bearer prefix
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
return
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenString = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
// If no header token, check query parameter (for WebSocket connections)
|
||||
if tokenString == "" {
|
||||
tokenString = c.Query("token")
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate token
|
||||
claims := &AdminClaims{}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
type SubscriptionSettings struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"`
|
||||
EnableMonitoring bool `gorm:"column:enable_monitoring;default:true" json:"enable_monitoring"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
|
||||
165
internal/monitoring/buffer.go
Normal file
165
internal/monitoring/buffer.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Redis key constants for monitoring
|
||||
const (
|
||||
LogsKey = "monitoring:logs"
|
||||
LogsChannel = "monitoring:logs:channel"
|
||||
StatsKeyPrefix = "monitoring:stats:"
|
||||
MaxLogEntries = 1000
|
||||
LogsTTL = 24 * time.Hour
|
||||
StatsExpiration = 30 * time.Second // Stats expire if not updated
|
||||
)
|
||||
|
||||
// LogBuffer provides Redis-backed ring buffer for log entries
|
||||
type LogBuffer struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a new log buffer with the given Redis client
|
||||
func NewLogBuffer(client *redis.Client) *LogBuffer {
|
||||
return &LogBuffer{client: client}
|
||||
}
|
||||
|
||||
// Push adds a log entry to the buffer and publishes it for real-time streaming
|
||||
func (b *LogBuffer) Push(entry LogEntry) error {
|
||||
ctx := context.Background()
|
||||
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use pipeline for atomic operations
|
||||
pipe := b.client.Pipeline()
|
||||
|
||||
// Push to list (ring buffer)
|
||||
pipe.LPush(ctx, LogsKey, data)
|
||||
|
||||
// Trim to max entries
|
||||
pipe.LTrim(ctx, LogsKey, 0, MaxLogEntries-1)
|
||||
|
||||
// Publish for real-time subscribers
|
||||
pipe.Publish(ctx, LogsChannel, data)
|
||||
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRecent retrieves the most recent log entries
|
||||
func (b *LogBuffer) GetRecent(count int) ([]LogEntry, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
if count <= 0 {
|
||||
count = 100
|
||||
}
|
||||
if count > MaxLogEntries {
|
||||
count = MaxLogEntries
|
||||
}
|
||||
|
||||
results, err := b.client.LRange(ctx, LogsKey, 0, int64(count-1)).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]LogEntry, 0, len(results))
|
||||
for _, r := range results {
|
||||
var entry LogEntry
|
||||
if json.Unmarshal([]byte(r), &entry) == nil {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Subscribe returns a Redis pubsub channel for real-time log streaming
|
||||
func (b *LogBuffer) Subscribe(ctx context.Context) *redis.PubSub {
|
||||
return b.client.Subscribe(ctx, LogsChannel)
|
||||
}
|
||||
|
||||
// Clear removes all logs from the buffer
|
||||
func (b *LogBuffer) Clear() error {
|
||||
ctx := context.Background()
|
||||
return b.client.Del(ctx, LogsKey).Err()
|
||||
}
|
||||
|
||||
// Count returns the number of logs in the buffer
|
||||
func (b *LogBuffer) Count() (int64, error) {
|
||||
ctx := context.Background()
|
||||
return b.client.LLen(ctx, LogsKey).Result()
|
||||
}
|
||||
|
||||
// StatsStore provides Redis storage for system statistics
|
||||
type StatsStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewStatsStore creates a new stats store with the given Redis client
|
||||
func NewStatsStore(client *redis.Client) *StatsStore {
|
||||
return &StatsStore{client: client}
|
||||
}
|
||||
|
||||
// StoreStats stores system stats for a process
|
||||
func (s *StatsStore) StoreStats(stats SystemStats) error {
|
||||
ctx := context.Background()
|
||||
|
||||
data, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := StatsKeyPrefix + stats.Process
|
||||
return s.client.Set(ctx, key, data, StatsExpiration).Err()
|
||||
}
|
||||
|
||||
// GetStats retrieves stats for a specific process
|
||||
func (s *StatsStore) GetStats(process string) (*SystemStats, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
key := StatsKeyPrefix + process
|
||||
data, err := s.client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, nil // No stats available
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stats SystemStats
|
||||
if err := json.Unmarshal(data, &stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetAllStats retrieves stats for all processes (api and worker)
|
||||
func (s *StatsStore) GetAllStats() (map[string]*SystemStats, error) {
|
||||
result := make(map[string]*SystemStats)
|
||||
|
||||
apiStats, err := s.GetStats("api")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiStats != nil {
|
||||
result["api"] = apiStats
|
||||
}
|
||||
|
||||
workerStats, err := s.GetStats("worker")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if workerStats != nil {
|
||||
result["worker"] = workerStats
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
199
internal/monitoring/collector.go
Normal file
199
internal/monitoring/collector.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
// Collector gathers system and runtime statistics
|
||||
type Collector struct {
|
||||
process string
|
||||
startTime time.Time
|
||||
statsStore *StatsStore
|
||||
httpCollector *HTTPStatsCollector // nil for worker
|
||||
asynqClient *asynq.Inspector // nil for api
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// NewCollector creates a new stats collector
|
||||
func NewCollector(process string, statsStore *StatsStore) *Collector {
|
||||
return &Collector{
|
||||
process: process,
|
||||
startTime: time.Now(),
|
||||
statsStore: statsStore,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPCollector sets the HTTP stats collector (for API server)
|
||||
func (c *Collector) SetHTTPCollector(httpCollector *HTTPStatsCollector) {
|
||||
c.httpCollector = httpCollector
|
||||
}
|
||||
|
||||
// SetAsynqInspector sets the Asynq inspector (for Worker)
|
||||
func (c *Collector) SetAsynqInspector(inspector *asynq.Inspector) {
|
||||
c.asynqClient = inspector
|
||||
}
|
||||
|
||||
// Collect gathers all system statistics
|
||||
func (c *Collector) Collect() SystemStats {
|
||||
stats := SystemStats{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Process: c.process,
|
||||
}
|
||||
|
||||
// CPU stats
|
||||
c.collectCPU(&stats)
|
||||
|
||||
// Memory stats (system + Go runtime)
|
||||
c.collectMemory(&stats)
|
||||
|
||||
// Disk stats
|
||||
c.collectDisk(&stats)
|
||||
|
||||
// Go runtime stats
|
||||
c.collectRuntime(&stats)
|
||||
|
||||
// HTTP stats (API only)
|
||||
if c.httpCollector != nil {
|
||||
httpStats := c.httpCollector.GetStats()
|
||||
stats.HTTP = &httpStats
|
||||
}
|
||||
|
||||
// Asynq stats (Worker only)
|
||||
if c.asynqClient != nil {
|
||||
asynqStats := c.collectAsynq()
|
||||
stats.Asynq = &asynqStats
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Collector) collectCPU(stats *SystemStats) {
|
||||
// Get CPU usage percentage (this blocks for ~100ms to sample)
|
||||
if cpuPercent, err := cpu.Percent(100*time.Millisecond, false); err == nil && len(cpuPercent) > 0 {
|
||||
stats.CPU.UsagePercent = cpuPercent[0]
|
||||
}
|
||||
|
||||
stats.CPU.NumCPU = runtime.NumCPU()
|
||||
|
||||
// Load averages (Unix only, returns 0 on Windows)
|
||||
if avg, err := load.Avg(); err == nil {
|
||||
stats.CPU.LoadAvg1 = avg.Load1
|
||||
stats.CPU.LoadAvg5 = avg.Load5
|
||||
stats.CPU.LoadAvg15 = avg.Load15
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) collectMemory(stats *SystemStats) {
|
||||
// System memory
|
||||
if vmem, err := mem.VirtualMemory(); err == nil {
|
||||
stats.Memory.UsedBytes = vmem.Used
|
||||
stats.Memory.TotalBytes = vmem.Total
|
||||
stats.Memory.UsagePercent = vmem.UsedPercent
|
||||
}
|
||||
|
||||
// Go runtime memory
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
stats.Memory.HeapAlloc = m.HeapAlloc
|
||||
stats.Memory.HeapSys = m.HeapSys
|
||||
stats.Memory.HeapInuse = m.HeapInuse
|
||||
}
|
||||
|
||||
func (c *Collector) collectDisk(stats *SystemStats) {
|
||||
// Root filesystem stats
|
||||
if diskStat, err := disk.Usage("/"); err == nil {
|
||||
stats.Disk.UsedBytes = diskStat.Used
|
||||
stats.Disk.TotalBytes = diskStat.Total
|
||||
stats.Disk.FreeBytes = diskStat.Free
|
||||
stats.Disk.UsagePercent = diskStat.UsedPercent
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) collectRuntime(stats *SystemStats) {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
stats.Runtime.Goroutines = runtime.NumGoroutine()
|
||||
stats.Runtime.NumGC = m.NumGC
|
||||
if m.NumGC > 0 {
|
||||
stats.Runtime.LastGCPause = m.PauseNs[(m.NumGC+255)%256]
|
||||
}
|
||||
stats.Runtime.Uptime = int64(time.Since(c.startTime).Seconds())
|
||||
}
|
||||
|
||||
func (c *Collector) collectAsynq() AsynqStats {
|
||||
stats := AsynqStats{
|
||||
Queues: make(map[string]QueueStats),
|
||||
}
|
||||
|
||||
if c.asynqClient == nil {
|
||||
return stats
|
||||
}
|
||||
|
||||
queues, err := c.asynqClient.Queues()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to get asynq queues")
|
||||
return stats
|
||||
}
|
||||
|
||||
for _, qName := range queues {
|
||||
info, err := c.asynqClient.GetQueueInfo(qName)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("queue", qName).Msg("Failed to get queue info")
|
||||
continue
|
||||
}
|
||||
|
||||
stats.Queues[qName] = QueueStats{
|
||||
Pending: info.Pending,
|
||||
Active: info.Active,
|
||||
Scheduled: info.Scheduled,
|
||||
Retry: info.Retry,
|
||||
Archived: info.Archived,
|
||||
Completed: info.Completed,
|
||||
Failed: info.Failed,
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// StartPublishing begins periodic stats collection and publishing to Redis
|
||||
func (c *Collector) StartPublishing(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Collect immediately on start
|
||||
c.publishStats()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.publishStats()
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Collector) publishStats() {
|
||||
stats := c.Collect()
|
||||
if err := c.statsStore.StoreStats(stats); err != nil {
|
||||
log.Debug().Err(err).Str("process", c.process).Msg("Failed to publish stats to Redis")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the stats publishing
|
||||
func (c *Collector) Stop() {
|
||||
close(c.stopChan)
|
||||
}
|
||||
203
internal/monitoring/handler.go
Normal file
203
internal/monitoring/handler.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Allow connections from admin panel
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// Handler provides HTTP handlers for monitoring endpoints
|
||||
type Handler struct {
|
||||
logBuffer *LogBuffer
|
||||
statsStore *StatsStore
|
||||
}
|
||||
|
||||
// NewHandler creates a new monitoring handler
|
||||
func NewHandler(logBuffer *LogBuffer, statsStore *StatsStore) *Handler {
|
||||
return &Handler{
|
||||
logBuffer: logBuffer,
|
||||
statsStore: statsStore,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogs returns filtered log entries
|
||||
// GET /api/admin/monitoring/logs
|
||||
func (h *Handler) GetLogs(c *gin.Context) {
|
||||
var filters LogFilters
|
||||
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := filters.GetLimit()
|
||||
|
||||
// Get more entries than requested for filtering
|
||||
entries, err := h.logBuffer.GetRecent(limit * 2)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get logs from buffer")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
filtered := make([]LogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
// Level filter
|
||||
if filters.Level != "" && e.Level != filters.Level {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process filter
|
||||
if filters.Process != "" && e.Process != filters.Process {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search filter (case-insensitive)
|
||||
if filters.Search != "" {
|
||||
searchLower := strings.ToLower(filters.Search)
|
||||
messageLower := strings.ToLower(e.Message)
|
||||
if !strings.Contains(messageLower, searchLower) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, e)
|
||||
if len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": filtered,
|
||||
"total": len(filtered),
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returns system statistics for all processes
|
||||
// GET /api/admin/monitoring/stats
|
||||
func (h *Handler) GetStats(c *gin.Context) {
|
||||
allStats, err := h.statsStore.GetAllStats()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get stats from store")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve stats"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, allStats)
|
||||
}
|
||||
|
||||
// ClearLogs clears all logs from the buffer
|
||||
// DELETE /api/admin/monitoring/logs
|
||||
func (h *Handler) ClearLogs(c *gin.Context) {
|
||||
if err := h.logBuffer.Clear(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to clear logs")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logs cleared"})
|
||||
}
|
||||
|
||||
// WebSocket handles real-time log streaming
|
||||
// GET /api/admin/monitoring/ws
|
||||
func (h *Handler) WebSocket(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to upgrade WebSocket connection")
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create context that cancels when connection closes
|
||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||
defer cancel()
|
||||
|
||||
// Subscribe to Redis pubsub for logs
|
||||
pubsub := h.logBuffer.Subscribe(ctx)
|
||||
defer pubsub.Close()
|
||||
|
||||
// Handle incoming messages (for filter changes, ping, etc.)
|
||||
var wsMu sync.Mutex
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Stream logs from pubsub
|
||||
ch := pubsub.Channel()
|
||||
statsTicker := time.NewTicker(5 * time.Second)
|
||||
defer statsTicker.Stop()
|
||||
|
||||
// Send initial stats
|
||||
h.sendStats(conn, &wsMu)
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
// Parse log entry
|
||||
var entry LogEntry
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send log message
|
||||
wsMsg := WSMessage{
|
||||
Type: WSMessageTypeLog,
|
||||
Data: entry,
|
||||
}
|
||||
|
||||
wsMu.Lock()
|
||||
err := conn.WriteJSON(wsMsg)
|
||||
wsMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("WebSocket write error")
|
||||
return
|
||||
}
|
||||
|
||||
case <-statsTicker.C:
|
||||
// Send periodic stats update
|
||||
h.sendStats(conn, &wsMu)
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) sendStats(conn *websocket.Conn, mu *sync.Mutex) {
|
||||
allStats, err := h.statsStore.GetAllStats()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsMsg := WSMessage{
|
||||
Type: WSMessageTypeStats,
|
||||
Data: allStats,
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
conn.WriteJSON(wsMsg)
|
||||
mu.Unlock()
|
||||
}
|
||||
215
internal/monitoring/middleware.go
Normal file
215
internal/monitoring/middleware.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTTPStatsCollector collects HTTP request metrics
|
||||
type HTTPStatsCollector struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]int64 // endpoint -> count
|
||||
totalLatency map[string]time.Duration // endpoint -> total latency
|
||||
errors map[string]int64 // endpoint -> error count
|
||||
byStatus map[int]int64 // status code -> count
|
||||
latencies []latencySample // recent latency samples for P95
|
||||
startTime time.Time
|
||||
lastReset time.Time
|
||||
}
|
||||
|
||||
type latencySample struct {
|
||||
endpoint string
|
||||
latency time.Duration
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxLatencySamples = 1000
|
||||
maxEndpoints = 200 // Cap unique endpoints tracked
|
||||
statsResetPeriod = 1 * time.Hour // Reset stats periodically to prevent unbounded growth
|
||||
)
|
||||
|
||||
// NewHTTPStatsCollector creates a new HTTP stats collector
|
||||
func NewHTTPStatsCollector() *HTTPStatsCollector {
|
||||
now := time.Now()
|
||||
return &HTTPStatsCollector{
|
||||
requests: make(map[string]int64),
|
||||
totalLatency: make(map[string]time.Duration),
|
||||
errors: make(map[string]int64),
|
||||
byStatus: make(map[int]int64),
|
||||
latencies: make([]latencySample, 0, maxLatencySamples),
|
||||
startTime: now,
|
||||
lastReset: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Record records a single HTTP request
|
||||
func (c *HTTPStatsCollector) Record(endpoint string, latency time.Duration, status int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Periodically reset to prevent unbounded memory growth
|
||||
if time.Since(c.lastReset) > statsResetPeriod {
|
||||
c.resetLocked()
|
||||
}
|
||||
|
||||
// Check if we've hit the endpoint limit and this is a new endpoint
|
||||
if _, exists := c.requests[endpoint]; !exists && len(c.requests) >= maxEndpoints {
|
||||
// Use a catch-all bucket for overflow endpoints
|
||||
endpoint = "OTHER"
|
||||
}
|
||||
|
||||
c.requests[endpoint]++
|
||||
c.totalLatency[endpoint] += latency
|
||||
c.byStatus[status]++
|
||||
|
||||
if status >= 400 {
|
||||
c.errors[endpoint]++
|
||||
}
|
||||
|
||||
// Store latency sample
|
||||
c.latencies = append(c.latencies, latencySample{
|
||||
endpoint: endpoint,
|
||||
latency: latency,
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Keep only recent samples
|
||||
if len(c.latencies) > maxLatencySamples {
|
||||
c.latencies = c.latencies[len(c.latencies)-maxLatencySamples:]
|
||||
}
|
||||
}
|
||||
|
||||
// resetLocked resets stats while holding the lock
|
||||
func (c *HTTPStatsCollector) resetLocked() {
|
||||
c.requests = make(map[string]int64)
|
||||
c.totalLatency = make(map[string]time.Duration)
|
||||
c.errors = make(map[string]int64)
|
||||
c.byStatus = make(map[int]int64)
|
||||
c.latencies = make([]latencySample, 0, maxLatencySamples)
|
||||
c.lastReset = time.Now()
|
||||
// Keep startTime for uptime calculation
|
||||
}
|
||||
|
||||
// GetStats returns the current HTTP statistics
|
||||
func (c *HTTPStatsCollector) GetStats() HTTPStats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
stats := HTTPStats{
|
||||
ByEndpoint: make(map[string]EndpointStats),
|
||||
ByStatusCode: make(map[int]int64),
|
||||
}
|
||||
|
||||
var totalRequests int64
|
||||
var totalErrors int64
|
||||
var totalLatency time.Duration
|
||||
|
||||
for endpoint, count := range c.requests {
|
||||
avgLatency := c.totalLatency[endpoint] / time.Duration(count)
|
||||
errCount := c.errors[endpoint]
|
||||
errRate := float64(0)
|
||||
if count > 0 {
|
||||
errRate = float64(errCount) / float64(count)
|
||||
}
|
||||
|
||||
stats.ByEndpoint[endpoint] = EndpointStats{
|
||||
Count: count,
|
||||
AvgLatencyMs: float64(avgLatency.Milliseconds()),
|
||||
ErrorRate: errRate,
|
||||
P95LatencyMs: c.calculateP95(endpoint),
|
||||
}
|
||||
|
||||
totalRequests += count
|
||||
totalErrors += errCount
|
||||
totalLatency += c.totalLatency[endpoint]
|
||||
}
|
||||
|
||||
// Copy status code counts
|
||||
for status, count := range c.byStatus {
|
||||
stats.ByStatusCode[status] = count
|
||||
}
|
||||
|
||||
stats.RequestsTotal = totalRequests
|
||||
if totalRequests > 0 {
|
||||
stats.AvgLatencyMs = float64(totalLatency.Milliseconds()) / float64(totalRequests)
|
||||
stats.ErrorRate = float64(totalErrors) / float64(totalRequests)
|
||||
}
|
||||
|
||||
uptime := time.Since(c.startTime).Minutes()
|
||||
if uptime > 0 {
|
||||
stats.RequestsPerMinute = float64(totalRequests) / uptime
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// calculateP95 calculates the 95th percentile latency for an endpoint
|
||||
// Must be called with read lock held
|
||||
func (c *HTTPStatsCollector) calculateP95(endpoint string) float64 {
|
||||
var endpointLatencies []time.Duration
|
||||
|
||||
for _, sample := range c.latencies {
|
||||
if sample.endpoint == endpoint {
|
||||
endpointLatencies = append(endpointLatencies, sample.latency)
|
||||
}
|
||||
}
|
||||
|
||||
if len(endpointLatencies) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Sort latencies
|
||||
sort.Slice(endpointLatencies, func(i, j int) bool {
|
||||
return endpointLatencies[i] < endpointLatencies[j]
|
||||
})
|
||||
|
||||
// Calculate P95 index
|
||||
p95Index := int(float64(len(endpointLatencies)) * 0.95)
|
||||
if p95Index >= len(endpointLatencies) {
|
||||
p95Index = len(endpointLatencies) - 1
|
||||
}
|
||||
|
||||
return float64(endpointLatencies[p95Index].Milliseconds())
|
||||
}
|
||||
|
||||
// Reset clears all collected stats
|
||||
func (c *HTTPStatsCollector) Reset() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.requests = make(map[string]int64)
|
||||
c.totalLatency = make(map[string]time.Duration)
|
||||
c.errors = make(map[string]int64)
|
||||
c.byStatus = make(map[int]int64)
|
||||
c.latencies = make([]latencySample, 0, maxLatencySamples)
|
||||
c.startTime = time.Now()
|
||||
}
|
||||
|
||||
// MetricsMiddleware returns a Gin middleware that collects request metrics
|
||||
func MetricsMiddleware(collector *HTTPStatsCollector) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
// Process request
|
||||
c.Next()
|
||||
|
||||
// Calculate latency
|
||||
latency := time.Since(start)
|
||||
|
||||
// Get endpoint pattern (use route path, fallback to actual path)
|
||||
endpoint := c.FullPath()
|
||||
if endpoint == "" {
|
||||
endpoint = c.Request.URL.Path
|
||||
}
|
||||
|
||||
// Combine method with path for unique endpoint identification
|
||||
endpoint = c.Request.Method + " " + endpoint
|
||||
|
||||
// Record metrics
|
||||
collector.Record(endpoint, latency, c.Writer.Status())
|
||||
}
|
||||
}
|
||||
128
internal/monitoring/models.go
Normal file
128
internal/monitoring/models.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package monitoring
|
||||
|
||||
import "time"
|
||||
|
||||
// LogEntry represents a single log entry captured from zerolog
|
||||
type LogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"` // debug, info, warn, error, fatal
|
||||
Message string `json:"message"`
|
||||
Caller string `json:"caller"` // file:line
|
||||
Process string `json:"process"` // "api" or "worker"
|
||||
Fields map[string]any `json:"fields"` // Additional structured fields
|
||||
}
|
||||
|
||||
// SystemStats contains all system and runtime statistics
|
||||
type SystemStats struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Process string `json:"process"`
|
||||
CPU CPUStats `json:"cpu"`
|
||||
Memory MemoryStats `json:"memory"`
|
||||
Disk DiskStats `json:"disk"`
|
||||
Runtime RuntimeStats `json:"runtime"`
|
||||
HTTP *HTTPStats `json:"http,omitempty"` // API only
|
||||
Asynq *AsynqStats `json:"asynq,omitempty"` // Worker only
|
||||
}
|
||||
|
||||
// CPUStats contains CPU usage information
|
||||
type CPUStats struct {
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
LoadAvg1 float64 `json:"load_avg_1"`
|
||||
LoadAvg5 float64 `json:"load_avg_5"`
|
||||
LoadAvg15 float64 `json:"load_avg_15"`
|
||||
}
|
||||
|
||||
// MemoryStats contains both system and Go runtime memory info
|
||||
type MemoryStats struct {
|
||||
// System memory
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
// Go heap
|
||||
HeapAlloc uint64 `json:"heap_alloc"`
|
||||
HeapSys uint64 `json:"heap_sys"`
|
||||
HeapInuse uint64 `json:"heap_inuse"`
|
||||
}
|
||||
|
||||
// DiskStats contains disk usage information
|
||||
type DiskStats struct {
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
// RuntimeStats contains Go runtime information
|
||||
type RuntimeStats struct {
|
||||
Goroutines int `json:"goroutines"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
LastGCPause uint64 `json:"last_gc_pause_ns"`
|
||||
Uptime int64 `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
// HTTPStats contains HTTP request metrics (API server only)
|
||||
type HTTPStats struct {
|
||||
RequestsTotal int64 `json:"requests_total"`
|
||||
RequestsPerMinute float64 `json:"requests_per_minute"`
|
||||
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
ByEndpoint map[string]EndpointStats `json:"by_endpoint"`
|
||||
ByStatusCode map[int]int64 `json:"by_status_code"`
|
||||
}
|
||||
|
||||
// EndpointStats contains per-endpoint HTTP metrics
|
||||
type EndpointStats struct {
|
||||
Count int64 `json:"count"`
|
||||
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||
P95LatencyMs float64 `json:"p95_latency_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
}
|
||||
|
||||
// AsynqStats contains Asynq job queue metrics (Worker only)
|
||||
type AsynqStats struct {
|
||||
Queues map[string]QueueStats `json:"queues"`
|
||||
}
|
||||
|
||||
// QueueStats contains stats for a single Asynq queue
|
||||
type QueueStats struct {
|
||||
Pending int `json:"pending"`
|
||||
Active int `json:"active"`
|
||||
Scheduled int `json:"scheduled"`
|
||||
Retry int `json:"retry"`
|
||||
Archived int `json:"archived"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// LogFilters for querying logs
|
||||
type LogFilters struct {
|
||||
Level string `form:"level"`
|
||||
Process string `form:"process"`
|
||||
Search string `form:"search"`
|
||||
Limit int `form:"limit,default=100"`
|
||||
}
|
||||
|
||||
// GetLimit returns the limit with bounds checking
|
||||
func (f *LogFilters) GetLimit() int {
|
||||
if f.Limit <= 0 {
|
||||
return 100
|
||||
}
|
||||
if f.Limit > 1000 {
|
||||
return 1000
|
||||
}
|
||||
return f.Limit
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
const (
|
||||
WSMessageTypeLog = "log"
|
||||
WSMessageTypeStats = "stats"
|
||||
)
|
||||
|
||||
// WSMessage wraps messages sent over WebSocket
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"` // "log" or "stats"
|
||||
Data any `json:"data"`
|
||||
}
|
||||
194
internal/monitoring/service.go
Normal file
194
internal/monitoring/service.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultStatsInterval is the default interval for collecting/publishing stats
|
||||
DefaultStatsInterval = 5 * time.Second
|
||||
// SettingsSyncInterval is how often to check the database for enable_monitoring setting
|
||||
SettingsSyncInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// Service orchestrates all monitoring components
|
||||
type Service struct {
|
||||
process string
|
||||
logBuffer *LogBuffer
|
||||
statsStore *StatsStore
|
||||
collector *Collector
|
||||
httpCollector *HTTPStatsCollector
|
||||
handler *Handler
|
||||
logWriter *RedisLogWriter
|
||||
db *gorm.DB
|
||||
settingsStopCh chan struct{}
|
||||
}
|
||||
|
||||
// Config holds configuration for the monitoring service
|
||||
type Config struct {
|
||||
Process string // "api" or "worker"
|
||||
RedisClient *redis.Client // Redis client for log buffer
|
||||
StatsInterval time.Duration // Interval for stats collection (default 5s)
|
||||
DB *gorm.DB // Database for checking enable_monitoring setting (optional)
|
||||
}
|
||||
|
||||
// NewService creates a new monitoring service
|
||||
func NewService(cfg Config) *Service {
|
||||
if cfg.StatsInterval == 0 {
|
||||
cfg.StatsInterval = DefaultStatsInterval
|
||||
}
|
||||
|
||||
// Create components
|
||||
logBuffer := NewLogBuffer(cfg.RedisClient)
|
||||
statsStore := NewStatsStore(cfg.RedisClient)
|
||||
collector := NewCollector(cfg.Process, statsStore)
|
||||
handler := NewHandler(logBuffer, statsStore)
|
||||
logWriter := NewRedisLogWriter(logBuffer, cfg.Process)
|
||||
|
||||
// For API server, create HTTP stats collector
|
||||
var httpCollector *HTTPStatsCollector
|
||||
if cfg.Process == "api" {
|
||||
httpCollector = NewHTTPStatsCollector()
|
||||
collector.SetHTTPCollector(httpCollector)
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
process: cfg.Process,
|
||||
logBuffer: logBuffer,
|
||||
statsStore: statsStore,
|
||||
collector: collector,
|
||||
httpCollector: httpCollector,
|
||||
handler: handler,
|
||||
logWriter: logWriter,
|
||||
db: cfg.DB,
|
||||
settingsStopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Check initial setting from database
|
||||
if cfg.DB != nil {
|
||||
svc.syncSettingsFromDB()
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// SetAsynqInspector sets the Asynq inspector for worker stats
|
||||
func (s *Service) SetAsynqInspector(inspector *asynq.Inspector) {
|
||||
s.collector.SetAsynqInspector(inspector)
|
||||
}
|
||||
|
||||
// Start begins collecting and publishing stats
|
||||
func (s *Service) Start() {
|
||||
log.Info().
|
||||
Str("process", s.process).
|
||||
Dur("interval", DefaultStatsInterval).
|
||||
Bool("enabled", s.logWriter.IsEnabled()).
|
||||
Msg("Starting monitoring service")
|
||||
|
||||
s.collector.StartPublishing(DefaultStatsInterval)
|
||||
|
||||
// Start settings sync if database is available
|
||||
if s.db != nil {
|
||||
go s.startSettingsSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the monitoring service
|
||||
func (s *Service) Stop() {
|
||||
// Stop settings sync
|
||||
close(s.settingsStopCh)
|
||||
|
||||
s.collector.Stop()
|
||||
log.Info().Str("process", s.process).Msg("Monitoring service stopped")
|
||||
}
|
||||
|
||||
// syncSettingsFromDB checks the database for the enable_monitoring setting
|
||||
func (s *Service) syncSettingsFromDB() {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var settings models.SubscriptionSettings
|
||||
err := s.db.First(&settings, 1).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// No settings record, default to enabled
|
||||
s.logWriter.SetEnabled(true)
|
||||
}
|
||||
// On other errors, keep current state
|
||||
return
|
||||
}
|
||||
|
||||
wasEnabled := s.logWriter.IsEnabled()
|
||||
s.logWriter.SetEnabled(settings.EnableMonitoring)
|
||||
|
||||
if wasEnabled != settings.EnableMonitoring {
|
||||
log.Info().
|
||||
Str("process", s.process).
|
||||
Bool("enabled", settings.EnableMonitoring).
|
||||
Msg("Monitoring log capture setting changed")
|
||||
}
|
||||
}
|
||||
|
||||
// startSettingsSync periodically checks the database for settings changes
|
||||
func (s *Service) startSettingsSync() {
|
||||
ticker := time.NewTicker(SettingsSyncInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.settingsStopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.syncSettingsFromDB()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled manually enables or disables log capture
|
||||
func (s *Service) SetEnabled(enabled bool) {
|
||||
s.logWriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// IsEnabled returns whether log capture is enabled
|
||||
func (s *Service) IsEnabled() bool {
|
||||
return s.logWriter.IsEnabled()
|
||||
}
|
||||
|
||||
// SetDB sets the database connection for settings sync
|
||||
// This can be called after NewService if DB wasn't available during initialization
|
||||
func (s *Service) SetDB(db *gorm.DB) {
|
||||
s.db = db
|
||||
s.syncSettingsFromDB()
|
||||
}
|
||||
|
||||
// LogWriter returns an io.Writer for zerolog that captures logs to Redis
|
||||
func (s *Service) LogWriter() io.Writer {
|
||||
return s.logWriter
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for monitoring endpoints
|
||||
func (s *Service) Handler() *Handler {
|
||||
return s.handler
|
||||
}
|
||||
|
||||
// HTTPCollector returns the HTTP stats collector (nil for worker)
|
||||
func (s *Service) HTTPCollector() *HTTPStatsCollector {
|
||||
return s.httpCollector
|
||||
}
|
||||
|
||||
// MetricsMiddleware returns the Gin middleware for HTTP metrics (API server only)
|
||||
func (s *Service) MetricsMiddleware() interface{} {
|
||||
if s.httpCollector == nil {
|
||||
return nil
|
||||
}
|
||||
return MetricsMiddleware(s.httpCollector)
|
||||
}
|
||||
95
internal/monitoring/writer.go
Normal file
95
internal/monitoring/writer.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RedisLogWriter implements io.Writer to capture zerolog output to Redis
|
||||
type RedisLogWriter struct {
|
||||
buffer *LogBuffer
|
||||
process string
|
||||
enabled atomic.Bool
|
||||
}
|
||||
|
||||
// NewRedisLogWriter creates a new writer that captures logs to Redis
|
||||
func NewRedisLogWriter(buffer *LogBuffer, process string) *RedisLogWriter {
|
||||
w := &RedisLogWriter{
|
||||
buffer: buffer,
|
||||
process: process,
|
||||
}
|
||||
w.enabled.Store(true) // enabled by default
|
||||
return w
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables log capture to Redis
|
||||
func (w *RedisLogWriter) SetEnabled(enabled bool) {
|
||||
w.enabled.Store(enabled)
|
||||
}
|
||||
|
||||
// IsEnabled returns whether log capture is enabled
|
||||
func (w *RedisLogWriter) IsEnabled() bool {
|
||||
return w.enabled.Load()
|
||||
}
|
||||
|
||||
// Write implements io.Writer interface
|
||||
// It parses zerolog JSON output and writes to Redis asynchronously
|
||||
func (w *RedisLogWriter) Write(p []byte) (n int, err error) {
|
||||
// Skip if monitoring is disabled
|
||||
if !w.enabled.Load() {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Parse zerolog JSON output
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(p, &raw); err != nil {
|
||||
// Not valid JSON, skip (could be console writer output)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Build log entry
|
||||
entry := LogEntry{
|
||||
ID: uuid.NewString(),
|
||||
Timestamp: time.Now().UTC(),
|
||||
Process: w.process,
|
||||
Fields: make(map[string]any),
|
||||
}
|
||||
|
||||
// Extract standard zerolog fields
|
||||
if lvl, ok := raw["level"].(string); ok {
|
||||
entry.Level = lvl
|
||||
}
|
||||
if msg, ok := raw["message"].(string); ok {
|
||||
entry.Message = msg
|
||||
}
|
||||
if caller, ok := raw["caller"].(string); ok {
|
||||
entry.Caller = caller
|
||||
}
|
||||
|
||||
// Extract timestamp if present (zerolog may include it)
|
||||
if ts, ok := raw["time"].(string); ok {
|
||||
if parsed, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
entry.Timestamp = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Copy additional fields (excluding standard ones)
|
||||
for k, v := range raw {
|
||||
switch k {
|
||||
case "level", "message", "caller", "time":
|
||||
// Skip standard fields
|
||||
default:
|
||||
entry.Fields[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Write to Redis asynchronously to avoid blocking
|
||||
go func() {
|
||||
_ = w.buffer.Push(entry) // Ignore errors to avoid blocking log output
|
||||
}()
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/handlers"
|
||||
"github.com/treytartt/casera-api/internal/i18n"
|
||||
"github.com/treytartt/casera-api/internal/middleware"
|
||||
"github.com/treytartt/casera-api/internal/monitoring"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
@@ -30,6 +31,7 @@ type Dependencies struct {
|
||||
PDFService *services.PDFService
|
||||
PushClient *push.Client // Direct APNs/FCM client
|
||||
StorageService *services.StorageService
|
||||
MonitoringService *monitoring.Service
|
||||
}
|
||||
|
||||
// SetupRouter creates and configures the Gin router
|
||||
@@ -51,6 +53,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
r.Use(corsMiddleware(cfg))
|
||||
r.Use(i18n.Middleware())
|
||||
|
||||
// Monitoring metrics middleware (if monitoring is enabled)
|
||||
if deps.MonitoringService != nil {
|
||||
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
|
||||
r.Use(metricsMiddleware.(gin.HandlerFunc))
|
||||
}
|
||||
}
|
||||
|
||||
// Serve landing page static files (if static directory is configured)
|
||||
staticDir := cfg.Server.StaticDir
|
||||
if staticDir != "" {
|
||||
@@ -137,11 +146,17 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
||||
}
|
||||
|
||||
// Set up admin routes (separate auth system)
|
||||
// Set up admin routes with monitoring handler (if available)
|
||||
var monitoringHandler *monitoring.Handler
|
||||
if deps.MonitoringService != nil {
|
||||
monitoringHandler = deps.MonitoringService.Handler()
|
||||
}
|
||||
|
||||
adminDeps := &admin.Dependencies{
|
||||
EmailService: deps.EmailService,
|
||||
PushClient: deps.PushClient,
|
||||
OnboardingService: onboardingService,
|
||||
MonitoringHandler: monitoringHandler,
|
||||
}
|
||||
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)
|
||||
|
||||
|
||||
@@ -12,20 +12,46 @@ import (
|
||||
|
||||
// InitLogger initializes the zerolog logger
|
||||
func InitLogger(debug bool) {
|
||||
InitLoggerWithWriter(debug, nil)
|
||||
}
|
||||
|
||||
// InitLoggerWithWriter initializes the zerolog logger with an optional additional writer
|
||||
// The additional writer receives JSON formatted logs (useful for monitoring)
|
||||
func InitLoggerWithWriter(debug bool, additionalWriter io.Writer) {
|
||||
zerolog.TimeFieldFormat = time.RFC3339
|
||||
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
if debug {
|
||||
// Pretty console output for development
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
} else {
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
// Build the output writer(s)
|
||||
var output io.Writer
|
||||
|
||||
if additionalWriter != nil {
|
||||
// Always write JSON to additional writer for monitoring
|
||||
// The additional writer parses JSON to extract log entries
|
||||
if debug {
|
||||
// In debug mode: pretty console to stdout + JSON to additional writer
|
||||
consoleOutput := zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05",
|
||||
}
|
||||
output = io.MultiWriter(consoleOutput, additionalWriter)
|
||||
} else {
|
||||
// In production: JSON to both stdout and additional writer
|
||||
output = io.MultiWriter(os.Stdout, additionalWriter)
|
||||
}
|
||||
} else {
|
||||
if debug {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05",
|
||||
}
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
} else {
|
||||
// JSON output for production
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
output = os.Stdout
|
||||
}
|
||||
}
|
||||
|
||||
log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger()
|
||||
|
||||
Reference in New Issue
Block a user