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}
+
+ ))
+ )}
+
+
+
+
+
+
+ >
+ );
+}
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)}
+
+
+
+
{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()
}