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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.5",
|
"next": "16.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"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": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"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"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@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-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.5",
|
"next": "16.0.5",
|
||||||
"next-themes": "^0.4.6",
|
"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';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Home,
|
Home,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -75,6 +76,7 @@ const limitationsItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
|
{ title: 'Monitoring', url: '/admin/monitoring', icon: Activity },
|
||||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
||||||
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
|
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
|
||||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
{ 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
|
// Settings types
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
enable_limitations: boolean;
|
enable_limitations: boolean;
|
||||||
|
enable_monitoring: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsRequest {
|
export interface UpdateSettingsRequest {
|
||||||
enable_limitations?: boolean;
|
enable_limitations?: boolean;
|
||||||
|
enable_monitoring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings API
|
// 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;
|
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/config"
|
||||||
"github.com/treytartt/casera-api/internal/database"
|
"github.com/treytartt/casera-api/internal/database"
|
||||||
"github.com/treytartt/casera-api/internal/i18n"
|
"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/push"
|
||||||
"github.com/treytartt/casera-api/internal/router"
|
"github.com/treytartt/casera-api/internal/router"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
@@ -29,7 +30,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize logger
|
// Initialize basic logger first (will be enhanced after Redis connects)
|
||||||
utils.InitLogger(cfg.Server.Debug)
|
utils.InitLogger(cfg.Server.Debug)
|
||||||
|
|
||||||
// Initialize i18n
|
// Initialize i18n
|
||||||
@@ -80,6 +81,27 @@ func main() {
|
|||||||
defer cache.Close()
|
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
|
// Initialize email service
|
||||||
var emailService *services.EmailService
|
var emailService *services.EmailService
|
||||||
log.Info().
|
log.Info().
|
||||||
@@ -140,6 +162,7 @@ func main() {
|
|||||||
PDFService: pdfService,
|
PDFService: pdfService,
|
||||||
PushClient: pushClient,
|
PushClient: pushClient,
|
||||||
StorageService: storageService,
|
StorageService: storageService,
|
||||||
|
MonitoringService: monitoringService,
|
||||||
}
|
}
|
||||||
r := router.SetupRouter(deps)
|
r := router.SetupRouter(deps)
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/config"
|
"github.com/treytartt/casera-api/internal/config"
|
||||||
"github.com/treytartt/casera-api/internal/database"
|
"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/push"
|
||||||
"github.com/treytartt/casera-api/internal/repositories"
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
@@ -70,6 +72,43 @@ func main() {
|
|||||||
log.Fatal().Err(err).Msg("Failed to parse Redis URL")
|
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
|
// Create Asynq server
|
||||||
srv := asynq.NewServer(
|
srv := asynq.NewServer(
|
||||||
redisOpt,
|
redisOpt,
|
||||||
|
|||||||
60
go.mod
60
go.mod
@@ -9,14 +9,19 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hibiken/asynq v0.25.1
|
github.com/hibiken/asynq v0.25.1
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
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/redis/go-redis/v9 v9.17.1
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
github.com/sideshow/apns2 v0.25.0
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
|
golang.org/x/text v0.27.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
@@ -24,47 +29,22 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.23.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/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/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/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.9.0 // 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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.14.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
github.com/zeebo/errs v1.4.0 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // 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
|
|
||||||
golang.org/x/arch v0.12.0 // indirect
|
golang.org/x/arch v0.12.0 // indirect
|
||||||
golang.org/x/net v0.42.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/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.34.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
|
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
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
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/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
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/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
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-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 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
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/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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/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.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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
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/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
||||||
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
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=
|
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/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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
|
||||||
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
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/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 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
|
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/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 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
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=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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-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 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
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/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-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 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-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-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.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.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.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 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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/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.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.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 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
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 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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-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=
|
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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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=
|
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
|
// SettingsResponse represents the settings response
|
||||||
type SettingsResponse struct {
|
type SettingsResponse struct {
|
||||||
EnableLimitations bool `json:"enable_limitations"`
|
EnableLimitations bool `json:"enable_limitations"`
|
||||||
|
EnableMonitoring bool `json:"enable_monitoring"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings handles GET /api/admin/settings
|
// 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 := h.db.First(&settings, 1).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// Create default settings
|
// Create default settings
|
||||||
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
|
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false, EnableMonitoring: true}
|
||||||
h.db.Create(&settings)
|
h.db.Create(&settings)
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
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{
|
c.JSON(http.StatusOK, SettingsResponse{
|
||||||
EnableLimitations: settings.EnableLimitations,
|
EnableLimitations: settings.EnableLimitations,
|
||||||
|
EnableMonitoring: settings.EnableMonitoring,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettingsRequest represents the update request
|
// UpdateSettingsRequest represents the update request
|
||||||
type UpdateSettingsRequest struct {
|
type UpdateSettingsRequest struct {
|
||||||
EnableLimitations *bool `json:"enable_limitations"`
|
EnableLimitations *bool `json:"enable_limitations"`
|
||||||
|
EnableMonitoring *bool `json:"enable_monitoring"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings handles PUT /api/admin/settings
|
// UpdateSettings handles PUT /api/admin/settings
|
||||||
@@ -66,7 +69,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
var settings models.SubscriptionSettings
|
var settings models.SubscriptionSettings
|
||||||
if err := h.db.First(&settings, 1).Error; err != nil {
|
if err := h.db.First(&settings, 1).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
settings = models.SubscriptionSettings{ID: 1}
|
settings = models.SubscriptionSettings{ID: 1, EnableMonitoring: true}
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||||
return
|
return
|
||||||
@@ -77,6 +80,10 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
settings.EnableLimitations = *req.EnableLimitations
|
settings.EnableLimitations = *req.EnableLimitations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.EnableMonitoring != nil {
|
||||||
|
settings.EnableMonitoring = *req.EnableMonitoring
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Save(&settings).Error; err != nil {
|
if err := h.db.Save(&settings).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||||
return
|
return
|
||||||
@@ -84,6 +91,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, SettingsResponse{
|
c.JSON(http.StatusOK, SettingsResponse{
|
||||||
EnableLimitations: settings.EnableLimitations,
|
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/admin/handlers"
|
||||||
"github.com/treytartt/casera-api/internal/config"
|
"github.com/treytartt/casera-api/internal/config"
|
||||||
"github.com/treytartt/casera-api/internal/middleware"
|
"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/push"
|
||||||
"github.com/treytartt/casera-api/internal/repositories"
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
@@ -22,6 +23,7 @@ type Dependencies struct {
|
|||||||
EmailService *services.EmailService
|
EmailService *services.EmailService
|
||||||
PushClient *push.Client
|
PushClient *push.Client
|
||||||
OnboardingService *services.OnboardingEmailService
|
OnboardingService *services.OnboardingEmailService
|
||||||
|
MonitoringHandler *monitoring.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupRoutes configures all admin routes
|
// 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.GET("/:id", onboardingHandler.Get)
|
||||||
onboardingEmails.DELETE("/:id", onboardingHandler.Delete)
|
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
|
// AdminAuthMiddleware creates a middleware that validates admin JWT tokens
|
||||||
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc {
|
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
var tokenString string
|
||||||
|
|
||||||
// Get token from Authorization header
|
// Get token from Authorization header
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader != "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Bearer prefix
|
// Check Bearer prefix
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
tokenString = parts[1]
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Parse and validate token
|
||||||
claims := &AdminClaims{}
|
claims := &AdminClaims{}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
type SubscriptionSettings struct {
|
type SubscriptionSettings struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"`
|
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
|
// 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/handlers"
|
||||||
"github.com/treytartt/casera-api/internal/i18n"
|
"github.com/treytartt/casera-api/internal/i18n"
|
||||||
"github.com/treytartt/casera-api/internal/middleware"
|
"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/push"
|
||||||
"github.com/treytartt/casera-api/internal/repositories"
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
@@ -30,6 +31,7 @@ type Dependencies struct {
|
|||||||
PDFService *services.PDFService
|
PDFService *services.PDFService
|
||||||
PushClient *push.Client // Direct APNs/FCM client
|
PushClient *push.Client // Direct APNs/FCM client
|
||||||
StorageService *services.StorageService
|
StorageService *services.StorageService
|
||||||
|
MonitoringService *monitoring.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupRouter creates and configures the Gin router
|
// SetupRouter creates and configures the Gin router
|
||||||
@@ -51,6 +53,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
r.Use(corsMiddleware(cfg))
|
r.Use(corsMiddleware(cfg))
|
||||||
r.Use(i18n.Middleware())
|
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)
|
// Serve landing page static files (if static directory is configured)
|
||||||
staticDir := cfg.Server.StaticDir
|
staticDir := cfg.Server.StaticDir
|
||||||
if staticDir != "" {
|
if staticDir != "" {
|
||||||
@@ -137,11 +146,17 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
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{
|
adminDeps := &admin.Dependencies{
|
||||||
EmailService: deps.EmailService,
|
EmailService: deps.EmailService,
|
||||||
PushClient: deps.PushClient,
|
PushClient: deps.PushClient,
|
||||||
OnboardingService: onboardingService,
|
OnboardingService: onboardingService,
|
||||||
|
MonitoringHandler: monitoringHandler,
|
||||||
}
|
}
|
||||||
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)
|
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,46 @@ import (
|
|||||||
|
|
||||||
// InitLogger initializes the zerolog logger
|
// InitLogger initializes the zerolog logger
|
||||||
func InitLogger(debug bool) {
|
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
|
zerolog.TimeFieldFormat = time.RFC3339
|
||||||
|
|
||||||
var output io.Writer = os.Stdout
|
|
||||||
|
|
||||||
if debug {
|
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{
|
output = zerolog.ConsoleWriter{
|
||||||
Out: os.Stdout,
|
Out: os.Stdout,
|
||||||
TimeFormat: "15:04:05",
|
TimeFormat: "15:04:05",
|
||||||
}
|
}
|
||||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
||||||
} else {
|
} else {
|
||||||
// JSON output for production
|
output = os.Stdout
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger()
|
log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger()
|
||||||
|
|||||||
Reference in New Issue
Block a user