diff --git a/admin/package-lock.json b/admin/package-lock.json index 2065324..8339fc3 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -25,6 +26,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "next": "16.0.5", "next-themes": "^0.4.6", @@ -1925,6 +1927,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -3921,6 +3954,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/admin/package.json b/admin/package.json index 8b31605..ae4fe57 100644 --- a/admin/package.json +++ b/admin/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -26,6 +27,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "next": "16.0.5", "next-themes": "^0.4.6", diff --git a/admin/src/app/(dashboard)/monitoring/page.tsx b/admin/src/app/(dashboard)/monitoring/page.tsx new file mode 100644 index 0000000..9e2c8b1 --- /dev/null +++ b/admin/src/app/(dashboard)/monitoring/page.tsx @@ -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 ( +
+
+
+

System Monitoring

+

+ Real-time logs and system statistics +

+
+
+
+ + +
+ +
+
+ + {hasError && ( + + + Connection Error + + Unable to connect to the monitoring service. Make sure the API server is running with Redis connected. + + + )} + + + + Overview + Logs + API Stats + Worker Stats + + + + + + + + + refetchLogs()} + isClearingLogs={isClearingLogs} + /> + + + + + + + + + + + + +
+ ); +} diff --git a/admin/src/components/app-sidebar.tsx b/admin/src/components/app-sidebar.tsx index 02532aa..93a3e73 100644 --- a/admin/src/components/app-sidebar.tsx +++ b/admin/src/components/app-sidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { + Activity, Home, Users, Building2, @@ -75,6 +76,7 @@ const limitationsItems = [ ]; const settingsItems = [ + { title: 'Monitoring', url: '/admin/monitoring', icon: Activity }, { title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen }, { title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate }, { title: 'Admin Users', url: '/admin/admin-users', icon: UserCog }, diff --git a/admin/src/components/monitoring/http-stats.tsx b/admin/src/components/monitoring/http-stats.tsx new file mode 100644 index 0000000..c5cbd73 --- /dev/null +++ b/admin/src/components/monitoring/http-stats.tsx @@ -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 ( + + + HTTP Statistics + + +
+ No HTTP statistics available +
+
+
+ ); + } + + return ( +
+ + + Total Requests + + + +
{(stats.total_requests ?? 0).toLocaleString()}
+

+ {(stats.requests_per_second ?? 0).toFixed(2)} req/s +

+
+
+ + + + Avg Latency + + + +
{formatLatency(stats.avg_latency_ms)}
+

+ P95: {formatLatency(stats.p95_latency_ms)} +

+
+
+ + + + Error Rate + + + +
{formatPercent(stats.error_rate)}
+

+ {(stats.errors_total ?? 0).toLocaleString()} total errors +

+
+
+ + + + Throughput + + + +
{(stats.requests_per_second ?? 0).toFixed(2)}
+

requests per second

+
+
+
+ ); +} + +export function HTTPEndpointStats({ stats }: HTTPStatsProps) { + if (!stats?.by_endpoint || Object.keys(stats.by_endpoint).length === 0) { + return ( + + + Endpoint Statistics + + +
+ No endpoint statistics available +
+
+
+ ); + } + + // 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 ( + + + Top Endpoints by Traffic + + + + + + Endpoint + Requests + Avg Latency + Errors + Error Rate + + + + {sortedEndpoints.map(([endpoint, data]) => { + const errorRate = data.count > 0 ? (data.errors / data.count) * 100 : 0; + return ( + + {endpoint} + {data.count.toLocaleString()} + {formatLatency(data.avg_latency_ms)} + {data.errors} + + 5 ? 'text-red-500' : ''}> + {formatPercent(errorRate)} + + + + ); + })} + +
+
+
+ ); +} diff --git a/admin/src/components/monitoring/index.ts b/admin/src/components/monitoring/index.ts new file mode 100644 index 0000000..b955712 --- /dev/null +++ b/admin/src/components/monitoring/index.ts @@ -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'; diff --git a/admin/src/components/monitoring/log-viewer.tsx b/admin/src/components/monitoring/log-viewer.tsx new file mode 100644 index 0000000..bbd86e1 --- /dev/null +++ b/admin/src/components/monitoring/log-viewer.tsx @@ -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('all'); + const [processFilter, setProcessFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedLog, setSelectedLog] = useState(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 ( + + + Live + + ); + case 'connecting': + return ( + + + Connecting + + ); + case 'error': + return ( + + + Error + + ); + default: + return ( + + + Disconnected + + ); + } + }; + + return ( + <> + + +
+ + Logs + {wsStatusBadge()} + {filteredLogs.length} entries + +
+ {onRefresh && ( + + )} + {onClearLogs && ( + + )} +
+
+
+ + + + +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> + {searchQuery && ( + + )} +
+
+
+ + +
+ {filteredLogs.length === 0 ? ( +
+ No logs to display +
+ ) : ( + filteredLogs.map((log) => ( +
setSelectedLog(log)} + > + + {formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })} + + + {log.level.toUpperCase()} + + + {log.process} + + {log.message} +
+ )) + )} +
+
+
+
+ + setSelectedLog(null)}> + + + + Log Details + {selectedLog && ( + + {selectedLog.level.toUpperCase()} + + )} + + + {selectedLog && ( +
+
+
+ Timestamp: +

{new Date(selectedLog.timestamp).toISOString()}

+
+
+ Process: +

{selectedLog.process}

+
+
+ Caller: +

{selectedLog.caller || 'N/A'}

+
+
+
+ Message: +

{selectedLog.message}

+
+ {selectedLog.fields && Object.keys(selectedLog.fields).length > 0 && ( +
+ Fields: +
+                    {JSON.stringify(selectedLog.fields, null, 2)}
+                  
+
+ )} +
+ )} +
+
+ + ); +} diff --git a/admin/src/components/monitoring/queue-stats.tsx b/admin/src/components/monitoring/queue-stats.tsx new file mode 100644 index 0000000..edb0cd8 --- /dev/null +++ b/admin/src/components/monitoring/queue-stats.tsx @@ -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 ( + + + Queue Statistics + + +
+ No queue statistics available +
+
+
+ ); + } + + return ( +
+ + + Pending + + + +
{stats.total_pending}
+

tasks waiting

+
+
+ + + + Active + + + +
{stats.total_active}
+

currently running

+
+
+ + + + Scheduled + + + +
{stats.total_scheduled}
+

future tasks

+
+
+ + + + Retry + + + +
{stats.total_retry}
+

awaiting retry

+
+
+ + + + Failed + + + +
{stats.total_failed}
+

permanently failed

+
+
+
+ ); +} + +export function QueueDetails({ stats }: QueueStatsProps) { + if (!stats?.queues || Object.keys(stats.queues).length === 0) { + return ( + + + Queue Details + + +
+ No queue details available +
+
+
+ ); + } + + // 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 Critical; + case 'default': + return Default; + case 'low': + return Low; + default: + return {queueName}; + } + }; + + return ( + + + Queue Details + + + + + + Queue + Size + Pending + Active + Scheduled + Retry + Archived + Completed + Failed + + + + {sortedQueues.map(([queueName, queue]) => ( + + {getPriorityBadge(queueName)} + {queue.size} + {queue.pending} + + 0 ? 'text-blue-500 font-medium' : ''}> + {queue.active} + + + {queue.scheduled} + + 0 ? 'text-yellow-500' : ''}> + {queue.retry} + + + {queue.archived} + + {queue.completed} + + + 0 ? 'text-red-500 font-medium' : ''}> + {queue.failed} + + + + ))} + +
+
+
+ ); +} diff --git a/admin/src/components/monitoring/stats-cards.tsx b/admin/src/components/monitoring/stats-cards.tsx new file mode 100644 index 0000000..44b8a02 --- /dev/null +++ b/admin/src/components/monitoring/stats-cards.tsx @@ -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 ( + + + {title} + Online + + +
+
+
+ + CPU +
+

{formatPercent(stats.cpu?.usage_percent)}

+
+
+
+ + Memory +
+

{formatBytes(stats.memory?.go_alloc)}

+
+
+
+ + Goroutines +
+

{stats.runtime?.goroutines ?? 0}

+
+
+
+ + Uptime +
+

{formatUptime(stats.runtime?.uptime_seconds)}

+
+
+
+
+ ); +} + +function OfflineCard({ title }: { title: string }) { + return ( + + + {title} + Offline + + +
+ No data available +
+
+
+ ); +} + +export function StatsCards({ apiStats, workerStats }: StatsCardsProps) { + return ( +
+ {apiStats ? ( + + ) : ( + + )} + {workerStats ? ( + + ) : ( + + )} +
+ ); +} + +interface SystemOverviewProps { + apiStats?: SystemStats; + workerStats?: SystemStats; +} + +export function SystemOverview({ apiStats, workerStats }: SystemOverviewProps) { + const stats = apiStats || workerStats; + if (!stats) return null; + + return ( +
+ + + System CPU + + + +
{formatPercent(stats.cpu?.usage_percent)}
+

+ Load: {safeToFixed(stats.cpu?.load_avg_1)} / {safeToFixed(stats.cpu?.load_avg_5)} / {safeToFixed(stats.cpu?.load_avg_15)} +

+
+
+ + + + System Memory + + + +
{formatPercent(stats.memory?.system_percent)}
+

+ {formatBytes(stats.memory?.system_used)} / {formatBytes(stats.memory?.system_total)} +

+
+
+ + + + Disk Usage + + + +
{formatPercent(stats.disk?.percent)}
+

+ {formatBytes(stats.disk?.used)} / {formatBytes(stats.disk?.total)} +

+
+
+ + + + GC Runs + + + +
{(apiStats?.runtime.gc_runs || 0) + (workerStats?.runtime.gc_runs || 0)}
+

+ Total pause: {((apiStats?.runtime.gc_pause_total_ns || 0) + (workerStats?.runtime.gc_pause_total_ns || 0)) / 1000000}ms +

+
+
+
+ ); +} diff --git a/admin/src/components/ui/alert.tsx b/admin/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/admin/src/components/ui/alert.tsx @@ -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) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/admin/src/components/ui/scroll-area.tsx b/admin/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..8e4fa13 --- /dev/null +++ b/admin/src/components/ui/scroll-area.tsx @@ -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) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/admin/src/hooks/use-monitoring.ts b/admin/src/hooks/use-monitoring.ts new file mode 100644 index 0000000..17cdf84 --- /dev/null +++ b/admin/src/hooks/use-monitoring.ts @@ -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([]); + const [status, setStatus] = useState('disconnected'); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(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); +} diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 35076c5..41e0f94 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -665,10 +665,12 @@ export const notificationPrefsApi = { // Settings types export interface SystemSettings { enable_limitations: boolean; + enable_monitoring: boolean; } export interface UpdateSettingsRequest { enable_limitations?: boolean; + enable_monitoring?: boolean; } // Settings API @@ -1598,4 +1600,41 @@ export const onboardingEmailsApi = { }, }; +// Monitoring Types +import type { LogEntry, LogFilters, LogsResponse, StatsResponse } from '@/types/monitoring'; + +// Monitoring API +export const monitoringApi = { + getLogs: async (filters?: LogFilters): Promise => { + const response = await api.get('/monitoring/logs', { params: filters }); + return response.data; + }, + + getStats: async (): Promise => { + const response = await api.get('/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; diff --git a/admin/src/types/monitoring.ts b/admin/src/types/monitoring.ts new file mode 100644 index 0000000..0d612f4 --- /dev/null +++ b/admin/src/types/monitoring.ts @@ -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; +} + +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; +} + +export interface QueueStats { + size: number; + pending: number; + active: number; + scheduled: number; + retry: number; + archived: number; + completed: number; + failed: number; +} + +export interface AsynqStats { + queues: Record; + 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 = { + debug: "text-gray-500", + info: "text-blue-500", + warn: "text-yellow-500", + error: "text-red-500", +}; + +export const LOG_LEVEL_BG_COLORS: Record = { + 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", +}; diff --git a/cmd/api/main.go b/cmd/api/main.go index f59f862..98e9f5d 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/database" "github.com/treytartt/casera-api/internal/i18n" + "github.com/treytartt/casera-api/internal/monitoring" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/router" "github.com/treytartt/casera-api/internal/services" @@ -29,7 +30,7 @@ func main() { os.Exit(1) } - // Initialize logger + // Initialize basic logger first (will be enhanced after Redis connects) utils.InitLogger(cfg.Server.Debug) // Initialize i18n @@ -80,6 +81,27 @@ func main() { defer cache.Close() } + // Initialize monitoring service (if Redis is available) + var monitoringService *monitoring.Service + if cache != nil { + monitoringService = monitoring.NewService(monitoring.Config{ + Process: "api", + RedisClient: cache.Client(), + DB: db, // Pass database for enable_monitoring setting sync + }) + + // Reinitialize logger with monitoring writer + utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter()) + + // Start stats collection + monitoringService.Start() + defer monitoringService.Stop() + + log.Info(). + Bool("log_capture_enabled", monitoringService.IsEnabled()). + Msg("Monitoring service initialized") + } + // Initialize email service var emailService *services.EmailService log.Info(). @@ -133,13 +155,14 @@ func main() { // Setup router with dependencies (includes admin panel at /admin) deps := &router.Dependencies{ - DB: db, - Cache: cache, - Config: cfg, - EmailService: emailService, - PDFService: pdfService, - PushClient: pushClient, - StorageService: storageService, + DB: db, + Cache: cache, + Config: cfg, + EmailService: emailService, + PDFService: pdfService, + PushClient: pushClient, + StorageService: storageService, + MonitoringService: monitoringService, } r := router.SetupRouter(deps) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 7c5ec35..f910117 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -8,10 +8,12 @@ import ( "syscall" "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/database" + "github.com/treytartt/casera-api/internal/monitoring" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" @@ -70,6 +72,43 @@ func main() { log.Fatal().Err(err).Msg("Failed to parse Redis URL") } + // Initialize monitoring service (if Redis is available) + var monitoringService *monitoring.Service + redisClientOpt, ok := redisOpt.(asynq.RedisClientOpt) + if ok { + redisClient := redis.NewClient(&redis.Options{ + Addr: redisClientOpt.Addr, + Password: redisClientOpt.Password, + DB: redisClientOpt.DB, + }) + + // Verify Redis connection + if err := redisClient.Ping(context.Background()).Err(); err != nil { + log.Warn().Err(err).Msg("Failed to connect to Redis for monitoring - monitoring disabled") + } else { + monitoringService = monitoring.NewService(monitoring.Config{ + Process: "worker", + RedisClient: redisClient, + DB: db, // Pass database for enable_monitoring setting sync + }) + + // Reinitialize logger with monitoring writer + utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter()) + + // Create Asynq inspector for queue statistics + inspector := asynq.NewInspector(redisOpt) + monitoringService.SetAsynqInspector(inspector) + + // Start stats collection + monitoringService.Start() + defer monitoringService.Stop() + + log.Info(). + Bool("log_capture_enabled", monitoringService.IsEnabled()). + Msg("Monitoring service initialized") + } + } + // Create Asynq server srv := asynq.NewServer( redisOpt, diff --git a/go.mod b/go.mod index 45f646a..9222ea8 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,19 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/hibiken/asynq v0.25.1 github.com/jung-kurt/gofpdf v1.16.2 + github.com/nicksnyder/go-i18n/v2 v2.6.0 github.com/redis/go-redis/v9 v9.17.1 github.com/rs/zerolog v1.34.0 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/shopspring/decimal v1.4.0 + github.com/sideshow/apns2 v0.25.0 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.40.0 + golang.org/x/text v0.27.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -24,47 +29,22 @@ require ( ) require ( - cel.dev/expr v0.23.1 // indirect - cloud.google.com/go v0.121.0 // indirect - cloud.google.com/go/auth v0.16.1 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - cloud.google.com/go/firestore v1.18.0 // indirect - cloud.google.com/go/iam v1.5.2 // indirect - cloud.google.com/go/longrunning v0.6.7 // indirect - cloud.google.com/go/monitoring v1.24.2 // indirect - cloud.google.com/go/storage v1.53.0 // indirect - firebase.google.com/go/v4 v4.18.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect - github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -74,50 +54,34 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect - github.com/sideshow/apns2 v0.25.0 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/zeebo/errs v1.4.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect golang.org/x/time v0.11.0 // indirect - google.golang.org/api v0.231.0 // indirect - google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/grpc v1.72.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2475f72..00ae0ee 100644 --- a/go.sum +++ b/go.sum @@ -1,33 +1,5 @@ -cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= -cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= -cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= -cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= -cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= -cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= -cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= -cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= -cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= -cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= -cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw= -cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA= -firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= -firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= -github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= -github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -43,20 +15,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -69,13 +33,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -89,28 +48,19 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -138,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -158,16 +110,15 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs= github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= @@ -175,6 +126,12 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6 github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= @@ -189,8 +146,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -205,106 +160,49 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= -google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= -google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= -google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= -google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= diff --git a/internal/admin/handlers/settings_handler.go b/internal/admin/handlers/settings_handler.go index 2f3f23e..41919a5 100644 --- a/internal/admin/handlers/settings_handler.go +++ b/internal/admin/handlers/settings_handler.go @@ -29,6 +29,7 @@ func NewAdminSettingsHandler(db *gorm.DB) *AdminSettingsHandler { // SettingsResponse represents the settings response type SettingsResponse struct { EnableLimitations bool `json:"enable_limitations"` + EnableMonitoring bool `json:"enable_monitoring"` } // GetSettings handles GET /api/admin/settings @@ -37,7 +38,7 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) { if err := h.db.First(&settings, 1).Error; err != nil { if err == gorm.ErrRecordNotFound { // Create default settings - settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false} + settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false, EnableMonitoring: true} h.db.Create(&settings) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"}) @@ -47,12 +48,14 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) { c.JSON(http.StatusOK, SettingsResponse{ EnableLimitations: settings.EnableLimitations, + EnableMonitoring: settings.EnableMonitoring, }) } // UpdateSettingsRequest represents the update request type UpdateSettingsRequest struct { EnableLimitations *bool `json:"enable_limitations"` + EnableMonitoring *bool `json:"enable_monitoring"` } // UpdateSettings handles PUT /api/admin/settings @@ -66,7 +69,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) { var settings models.SubscriptionSettings if err := h.db.First(&settings, 1).Error; err != nil { if err == gorm.ErrRecordNotFound { - settings = models.SubscriptionSettings{ID: 1} + settings = models.SubscriptionSettings{ID: 1, EnableMonitoring: true} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"}) return @@ -77,6 +80,10 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) { settings.EnableLimitations = *req.EnableLimitations } + if req.EnableMonitoring != nil { + settings.EnableMonitoring = *req.EnableMonitoring + } + if err := h.db.Save(&settings).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"}) return @@ -84,6 +91,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) { c.JSON(http.StatusOK, SettingsResponse{ EnableLimitations: settings.EnableLimitations, + EnableMonitoring: settings.EnableMonitoring, }) } diff --git a/internal/admin/routes.go b/internal/admin/routes.go index b43956e..eff16b6 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -12,6 +12,7 @@ import ( "github.com/treytartt/casera-api/internal/admin/handlers" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/middleware" + "github.com/treytartt/casera-api/internal/monitoring" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" @@ -22,6 +23,7 @@ type Dependencies struct { EmailService *services.EmailService PushClient *push.Client OnboardingService *services.OnboardingEmailService + MonitoringHandler *monitoring.Handler } // SetupRoutes configures all admin routes @@ -424,6 +426,17 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe onboardingEmails.GET("/:id", onboardingHandler.Get) onboardingEmails.DELETE("/:id", onboardingHandler.Delete) } + + // System monitoring (logs, stats, websocket) + if deps != nil && deps.MonitoringHandler != nil { + monitoringGroup := protected.Group("/monitoring") + { + monitoringGroup.GET("/logs", deps.MonitoringHandler.GetLogs) + monitoringGroup.GET("/stats", deps.MonitoringHandler.GetStats) + monitoringGroup.DELETE("/logs", deps.MonitoringHandler.ClearLogs) + monitoringGroup.GET("/ws", deps.MonitoringHandler.WebSocket) + } + } } } diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 21bffb9..c0b49ff 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -32,21 +32,27 @@ type AdminClaims struct { // AdminAuthMiddleware creates a middleware that validates admin JWT tokens func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc { return func(c *gin.Context) { + var tokenString string + // Get token from Authorization header authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) - return + if authHeader != "" { + // Check Bearer prefix + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + tokenString = parts[1] + } } - // Check Bearer prefix - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) - return + // If no header token, check query parameter (for WebSocket connections) + if tokenString == "" { + tokenString = c.Query("token") } - tokenString := parts[1] + if tokenString == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"}) + return + } // Parse and validate token claims := &AdminClaims{} diff --git a/internal/models/subscription.go b/internal/models/subscription.go index 9204434..92af876 100644 --- a/internal/models/subscription.go +++ b/internal/models/subscription.go @@ -16,6 +16,7 @@ const ( type SubscriptionSettings struct { ID uint `gorm:"primaryKey" json:"id"` EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"` + EnableMonitoring bool `gorm:"column:enable_monitoring;default:true" json:"enable_monitoring"` } // TableName returns the table name for GORM diff --git a/internal/monitoring/buffer.go b/internal/monitoring/buffer.go new file mode 100644 index 0000000..8a3ed34 --- /dev/null +++ b/internal/monitoring/buffer.go @@ -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 +} diff --git a/internal/monitoring/collector.go b/internal/monitoring/collector.go new file mode 100644 index 0000000..9b7ef9e --- /dev/null +++ b/internal/monitoring/collector.go @@ -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) +} diff --git a/internal/monitoring/handler.go b/internal/monitoring/handler.go new file mode 100644 index 0000000..7c3c772 --- /dev/null +++ b/internal/monitoring/handler.go @@ -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() +} diff --git a/internal/monitoring/middleware.go b/internal/monitoring/middleware.go new file mode 100644 index 0000000..91440f7 --- /dev/null +++ b/internal/monitoring/middleware.go @@ -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()) + } +} diff --git a/internal/monitoring/models.go b/internal/monitoring/models.go new file mode 100644 index 0000000..893a50c --- /dev/null +++ b/internal/monitoring/models.go @@ -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"` +} diff --git a/internal/monitoring/service.go b/internal/monitoring/service.go new file mode 100644 index 0000000..169e03a --- /dev/null +++ b/internal/monitoring/service.go @@ -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) +} diff --git a/internal/monitoring/writer.go b/internal/monitoring/writer.go new file mode 100644 index 0000000..b61fb7a --- /dev/null +++ b/internal/monitoring/writer.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index a698651..b520fb0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,7 @@ import ( "github.com/treytartt/casera-api/internal/handlers" "github.com/treytartt/casera-api/internal/i18n" "github.com/treytartt/casera-api/internal/middleware" + "github.com/treytartt/casera-api/internal/monitoring" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" @@ -23,13 +24,14 @@ const Version = "2.0.0" // Dependencies holds all dependencies needed by the router type Dependencies struct { - DB *gorm.DB - Cache *services.CacheService - Config *config.Config - EmailService *services.EmailService - PDFService *services.PDFService - PushClient *push.Client // Direct APNs/FCM client - StorageService *services.StorageService + DB *gorm.DB + Cache *services.CacheService + Config *config.Config + EmailService *services.EmailService + PDFService *services.PDFService + PushClient *push.Client // Direct APNs/FCM client + StorageService *services.StorageService + MonitoringService *monitoring.Service } // SetupRouter creates and configures the Gin router @@ -51,6 +53,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine { r.Use(corsMiddleware(cfg)) r.Use(i18n.Middleware()) + // Monitoring metrics middleware (if monitoring is enabled) + if deps.MonitoringService != nil { + if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil { + r.Use(metricsMiddleware.(gin.HandlerFunc)) + } + } + // Serve landing page static files (if static directory is configured) staticDir := cfg.Server.StaticDir if staticDir != "" { @@ -137,11 +146,17 @@ func SetupRouter(deps *Dependencies) *gin.Engine { mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService) } - // Set up admin routes (separate auth system) + // Set up admin routes with monitoring handler (if available) + var monitoringHandler *monitoring.Handler + if deps.MonitoringService != nil { + monitoringHandler = deps.MonitoringService.Handler() + } + adminDeps := &admin.Dependencies{ EmailService: deps.EmailService, PushClient: deps.PushClient, OnboardingService: onboardingService, + MonitoringHandler: monitoringHandler, } admin.SetupRoutes(r, deps.DB, cfg, adminDeps) diff --git a/pkg/utils/logger.go b/pkg/utils/logger.go index de76715..f76e2ab 100644 --- a/pkg/utils/logger.go +++ b/pkg/utils/logger.go @@ -12,22 +12,48 @@ import ( // InitLogger initializes the zerolog logger func InitLogger(debug bool) { + InitLoggerWithWriter(debug, nil) +} + +// InitLoggerWithWriter initializes the zerolog logger with an optional additional writer +// The additional writer receives JSON formatted logs (useful for monitoring) +func InitLoggerWithWriter(debug bool, additionalWriter io.Writer) { zerolog.TimeFieldFormat = time.RFC3339 - var output io.Writer = os.Stdout - if debug { - // Pretty console output for development - output = zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: "15:04:05", - } zerolog.SetGlobalLevel(zerolog.DebugLevel) } else { - // JSON output for production zerolog.SetGlobalLevel(zerolog.InfoLevel) } + // Build the output writer(s) + var output io.Writer + + if additionalWriter != nil { + // Always write JSON to additional writer for monitoring + // The additional writer parses JSON to extract log entries + if debug { + // In debug mode: pretty console to stdout + JSON to additional writer + consoleOutput := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "15:04:05", + } + output = io.MultiWriter(consoleOutput, additionalWriter) + } else { + // In production: JSON to both stdout and additional writer + output = io.MultiWriter(os.Stdout, additionalWriter) + } + } else { + if debug { + output = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "15:04:05", + } + } else { + output = os.Stdout + } + } + log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger() }