mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:31:13 -05:00
Compare commits
2 Commits
5d35fdc326
...
99c917deaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99c917deaf | ||
|
|
d94062a937 |
@@ -75,6 +75,7 @@ const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
|||||||
const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup"));
|
||||||
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
const TraceViewer = lazy(() => import("./pages/admin/TraceViewer"));
|
||||||
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics"));
|
||||||
|
const MonitoringOverview = lazy(() => import("./pages/admin/MonitoringOverview"));
|
||||||
|
|
||||||
// User routes (lazy-loaded)
|
// User routes (lazy-loaded)
|
||||||
const Profile = lazy(() => import("./pages/Profile"));
|
const Profile = lazy(() => import("./pages/Profile"));
|
||||||
@@ -405,6 +406,14 @@ function AppContent(): React.JSX.Element {
|
|||||||
</AdminErrorBoundary>
|
</AdminErrorBoundary>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/monitoring-overview"
|
||||||
|
element={
|
||||||
|
<AdminErrorBoundary section="Monitoring Overview">
|
||||||
|
<MonitoringOverview />
|
||||||
|
</AdminErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Utility routes - lazy loaded */}
|
{/* Utility routes - lazy loaded */}
|
||||||
<Route path="/force-logout" element={<ForceLogout />} />
|
<Route path="/force-logout" element={<ForceLogout />} />
|
||||||
|
|||||||
170
src/components/admin/CriticalAlertsPanel.tsx
Normal file
170
src/components/admin/CriticalAlertsPanel.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { AlertTriangle, CheckCircle2, Clock, ShieldAlert, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { CombinedAlert } from '@/hooks/admin/useCombinedAlerts';
|
||||||
|
|
||||||
|
interface CriticalAlertsPanelProps {
|
||||||
|
alerts?: CombinedAlert[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_CONFIG = {
|
||||||
|
critical: { color: 'destructive' as const, icon: XCircle, label: 'Critical' },
|
||||||
|
high: { color: 'destructive' as const, icon: AlertTriangle, label: 'High' },
|
||||||
|
medium: { color: 'secondary' as const, icon: Clock, label: 'Medium' },
|
||||||
|
low: { color: 'secondary' as const, icon: Clock, label: 'Low' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CriticalAlertsPanel({ alerts, isLoading }: CriticalAlertsPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const resolveSystemAlert = useMutation({
|
||||||
|
mutationFn: async (alertId: string) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('system_alerts')
|
||||||
|
.update({ resolved_at: new Date().toISOString() })
|
||||||
|
.eq('id', alertId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['system-alerts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['monitoring'] });
|
||||||
|
toast.success('Alert resolved');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to resolve alert');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveRateLimitAlert = useMutation({
|
||||||
|
mutationFn: async (alertId: string) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('rate_limit_alerts')
|
||||||
|
.update({ resolved_at: new Date().toISOString() })
|
||||||
|
.eq('id', alertId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rate-limit-alerts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['monitoring'] });
|
||||||
|
toast.success('Alert resolved');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to resolve alert');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResolve = (alert: CombinedAlert) => {
|
||||||
|
if (alert.source === 'system') {
|
||||||
|
resolveSystemAlert.mutate(alert.id);
|
||||||
|
} else {
|
||||||
|
resolveRateLimitAlert.mutate(alert.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5" />
|
||||||
|
Critical Alerts
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading alerts...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alerts || alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-green-500/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5" />
|
||||||
|
Critical Alerts
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">All Systems Operational</div>
|
||||||
|
<div className="text-sm text-muted-foreground">No active alerts detected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5" />
|
||||||
|
Critical Alerts
|
||||||
|
<Badge variant="destructive">{alerts.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button asChild size="sm" variant="ghost">
|
||||||
|
<Link to="/admin/error-monitoring">View All</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{alerts.map((alert) => {
|
||||||
|
const config = SEVERITY_CONFIG[alert.severity];
|
||||||
|
const SeverityIcon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<SeverityIcon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${alert.severity === 'critical' || alert.severity === 'high' ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
<Badge variant={config.color} className="flex-shrink-0">
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="flex-shrink-0">
|
||||||
|
{alert.source === 'system' ? 'System' : 'Rate Limit'}
|
||||||
|
</Badge>
|
||||||
|
{alert.alert_type && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{alert.alert_type.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 break-words">{alert.message}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatDistanceToNow(new Date(alert.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleResolve(alert)}
|
||||||
|
loading={resolveSystemAlert.isPending || resolveRateLimitAlert.isPending}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/admin/MonitoringNavCards.tsx
Normal file
83
src/components/admin/MonitoringNavCards.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { AlertTriangle, ArrowRight, ScrollText, Shield } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface NavCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
to: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
stat?: string;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavCard({ title, description, to, icon: Icon, stat, badge }: NavCardProps) {
|
||||||
|
return (
|
||||||
|
<Link to={to}>
|
||||||
|
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{stat && (
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{stat}</p>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitoringNavCardsProps {
|
||||||
|
errorCount?: number;
|
||||||
|
rateLimitCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitoringNavCards({ errorCount, rateLimitCount }: MonitoringNavCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<NavCard
|
||||||
|
title="Error Monitoring"
|
||||||
|
description="View detailed error logs, analytics, and traces"
|
||||||
|
to="/admin/error-monitoring"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
stat={errorCount !== undefined ? `${errorCount} errors in last 24h` : undefined}
|
||||||
|
badge={errorCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavCard
|
||||||
|
title="Rate Limit Metrics"
|
||||||
|
description="Monitor rate limiting, alerts, and configurations"
|
||||||
|
to="/admin/rate-limit-metrics"
|
||||||
|
icon={Shield}
|
||||||
|
stat={rateLimitCount !== undefined ? `${rateLimitCount} blocks today` : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavCard
|
||||||
|
title="System Log"
|
||||||
|
description="View system events, audit trails, and history"
|
||||||
|
to="/admin/system-log"
|
||||||
|
icon={ScrollText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/admin/MonitoringQuickStats.tsx
Normal file
116
src/components/admin/MonitoringQuickStats.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Activity, AlertTriangle, Clock, Database, FileText, Shield, TrendingUp, Users } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { SystemHealthData } from '@/hooks/useSystemHealth';
|
||||||
|
import type { ModerationHealth } from '@/hooks/admin/useModerationHealth';
|
||||||
|
|
||||||
|
interface MonitoringQuickStatsProps {
|
||||||
|
systemHealth?: SystemHealthData;
|
||||||
|
rateLimitStats?: { total_requests: number; blocked_requests: number; unique_ips: number };
|
||||||
|
moderationHealth?: ModerationHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
status?: 'healthy' | 'warning' | 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, status = 'healthy' }: StatCardProps) {
|
||||||
|
const statusColors = {
|
||||||
|
healthy: 'text-green-500',
|
||||||
|
warning: 'text-yellow-500',
|
||||||
|
critical: 'text-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-muted ${statusColors[status]}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{label}</p>
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitoringQuickStats({ systemHealth, rateLimitStats, moderationHealth }: MonitoringQuickStatsProps) {
|
||||||
|
const criticalAlerts = systemHealth?.critical_alerts_count || 0;
|
||||||
|
const highAlerts = systemHealth?.high_alerts_count || 0;
|
||||||
|
const totalAlerts = criticalAlerts + highAlerts;
|
||||||
|
|
||||||
|
const blockRate = rateLimitStats?.total_requests
|
||||||
|
? ((rateLimitStats.blocked_requests / rateLimitStats.total_requests) * 100).toFixed(1)
|
||||||
|
: '0.0';
|
||||||
|
|
||||||
|
const queueStatus =
|
||||||
|
(moderationHealth?.queueLength || 0) > 50 ? 'critical' :
|
||||||
|
(moderationHealth?.queueLength || 0) > 20 ? 'warning' : 'healthy';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Active Alerts"
|
||||||
|
value={totalAlerts}
|
||||||
|
status={criticalAlerts > 0 ? 'critical' : highAlerts > 0 ? 'warning' : 'healthy'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={Shield}
|
||||||
|
label="Rate Limit Block Rate"
|
||||||
|
value={`${blockRate}%`}
|
||||||
|
status={parseFloat(blockRate) > 5 ? 'warning' : 'healthy'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={FileText}
|
||||||
|
label="Moderation Queue"
|
||||||
|
value={moderationHealth?.queueLength || 0}
|
||||||
|
status={queueStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Active Locks"
|
||||||
|
value={moderationHealth?.activeLocks || 0}
|
||||||
|
status={(moderationHealth?.activeLocks || 0) > 5 ? 'warning' : 'healthy'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={Database}
|
||||||
|
label="Orphaned Images"
|
||||||
|
value={systemHealth?.orphaned_images_count || 0}
|
||||||
|
status={(systemHealth?.orphaned_images_count || 0) > 0 ? 'warning' : 'healthy'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Failed Webhooks"
|
||||||
|
value={systemHealth?.failed_webhook_count || 0}
|
||||||
|
status={(systemHealth?.failed_webhook_count || 0) > 0 ? 'warning' : 'healthy'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Unique IPs"
|
||||||
|
value={rateLimitStats?.unique_ips || 0}
|
||||||
|
status="healthy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Total Requests"
|
||||||
|
value={rateLimitStats?.total_requests || 0}
|
||||||
|
status="healthy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/admin/RecentActivityTimeline.tsx
Normal file
138
src/components/admin/RecentActivityTimeline.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { AlertTriangle, Database, ShieldAlert, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { ActivityEvent } from '@/hooks/admin/useRecentActivity';
|
||||||
|
|
||||||
|
interface RecentActivityTimelineProps {
|
||||||
|
activity?: ActivityEvent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivityTimeline({ activity, isLoading }: RecentActivityTimelineProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading activity...</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activity || activity.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity (Last Hour)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center text-muted-foreground py-8">No recent activity</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventIcon = (event: ActivityEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'error':
|
||||||
|
return XCircle;
|
||||||
|
case 'approval':
|
||||||
|
return Database;
|
||||||
|
case 'alert':
|
||||||
|
return AlertTriangle;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventColor = (event: ActivityEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'approval':
|
||||||
|
return 'text-orange-500';
|
||||||
|
case 'alert':
|
||||||
|
return 'text-yellow-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventDescription = (event: ActivityEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'error':
|
||||||
|
return `${event.error_type} in ${event.endpoint}`;
|
||||||
|
case 'approval':
|
||||||
|
return `Approval failed: ${event.error_message}`;
|
||||||
|
case 'alert':
|
||||||
|
return event.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventLink = (event: ActivityEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'error':
|
||||||
|
return `/admin/error-monitoring`;
|
||||||
|
case 'approval':
|
||||||
|
return `/admin/error-monitoring?tab=approvals`;
|
||||||
|
case 'alert':
|
||||||
|
return `/admin/error-monitoring`;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Recent Activity (Last Hour)</CardTitle>
|
||||||
|
<Badge variant="outline">{activity.length} events</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[400px] pr-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.map((event) => {
|
||||||
|
const Icon = getEventIcon(event);
|
||||||
|
const color = getEventColor(event);
|
||||||
|
const description = getEventDescription(event);
|
||||||
|
const link = getEventLink(event);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-3 p-3 rounded-lg border border-border transition-colors ${
|
||||||
|
link ? 'hover:bg-accent/50 cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{event.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(event.created_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 break-words">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return link ? (
|
||||||
|
<Link key={event.id} to={link}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={event.id}>{content}</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/admin/SystemHealthStatus.tsx
Normal file
141
src/components/admin/SystemHealthStatus.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Activity, AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useRunSystemMaintenance, type SystemHealthData } from '@/hooks/useSystemHealth';
|
||||||
|
import type { DatabaseHealth } from '@/hooks/admin/useDatabaseHealth';
|
||||||
|
|
||||||
|
interface SystemHealthStatusProps {
|
||||||
|
systemHealth?: SystemHealthData;
|
||||||
|
dbHealth?: DatabaseHealth;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemHealthStatus({ systemHealth, dbHealth, isLoading }: SystemHealthStatusProps) {
|
||||||
|
const runMaintenance = useRunSystemMaintenance();
|
||||||
|
|
||||||
|
const getOverallStatus = () => {
|
||||||
|
if (isLoading) return 'checking';
|
||||||
|
if (!systemHealth) return 'unknown';
|
||||||
|
|
||||||
|
const hasCriticalIssues =
|
||||||
|
(systemHealth.orphaned_images_count || 0) > 0 ||
|
||||||
|
(systemHealth.failed_webhook_count || 0) > 0 ||
|
||||||
|
(systemHealth.critical_alerts_count || 0) > 0 ||
|
||||||
|
dbHealth?.status === 'unhealthy';
|
||||||
|
|
||||||
|
if (hasCriticalIssues) return 'unhealthy';
|
||||||
|
|
||||||
|
const hasWarnings =
|
||||||
|
dbHealth?.status === 'warning' ||
|
||||||
|
(systemHealth.high_alerts_count || 0) > 0;
|
||||||
|
|
||||||
|
if (hasWarnings) return 'warning';
|
||||||
|
|
||||||
|
return 'healthy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getOverallStatus();
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
healthy: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'All Systems Operational',
|
||||||
|
color: 'text-green-500',
|
||||||
|
bgColor: 'bg-green-500/10',
|
||||||
|
borderColor: 'border-green-500/20',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: 'System Warning',
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
bgColor: 'bg-yellow-500/10',
|
||||||
|
borderColor: 'border-yellow-500/20',
|
||||||
|
},
|
||||||
|
unhealthy: {
|
||||||
|
icon: XCircle,
|
||||||
|
label: 'Critical Issues Detected',
|
||||||
|
color: 'text-red-500',
|
||||||
|
bgColor: 'bg-red-500/10',
|
||||||
|
borderColor: 'border-red-500/20',
|
||||||
|
},
|
||||||
|
checking: {
|
||||||
|
icon: Activity,
|
||||||
|
label: 'Checking System Health...',
|
||||||
|
color: 'text-muted-foreground',
|
||||||
|
bgColor: 'bg-muted',
|
||||||
|
borderColor: 'border-border',
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
label: 'Unable to Determine Status',
|
||||||
|
color: 'text-muted-foreground',
|
||||||
|
bgColor: 'bg-muted',
|
||||||
|
borderColor: 'border-border',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
|
const handleRunMaintenance = () => {
|
||||||
|
runMaintenance.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`${config.borderColor} border-2`}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
System Health
|
||||||
|
</CardTitle>
|
||||||
|
{(status === 'unhealthy' || status === 'warning') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRunMaintenance}
|
||||||
|
loading={runMaintenance.isPending}
|
||||||
|
loadingText="Running..."
|
||||||
|
>
|
||||||
|
Run Maintenance
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`flex items-center gap-3 p-4 rounded-lg ${config.bgColor}`}>
|
||||||
|
<StatusIcon className={`w-8 h-8 ${config.color}`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">{config.label}</span>
|
||||||
|
<Badge variant={status === 'healthy' ? 'default' : status === 'warning' ? 'secondary' : 'destructive'}>
|
||||||
|
{status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{systemHealth && (
|
||||||
|
<div className="mt-2 grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Orphaned Images:</span>
|
||||||
|
<span className="ml-1 font-medium">{systemHealth.orphaned_images_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Failed Webhooks:</span>
|
||||||
|
<span className="ml-1 font-medium">{systemHealth.failed_webhook_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Critical Alerts:</span>
|
||||||
|
<span className="ml-1 font-medium">{systemHealth.critical_alerts_count || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">DB Errors (1h):</span>
|
||||||
|
<span className="ml-1 font-medium">{dbHealth?.recentErrors || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield } from 'lucide-react';
|
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity } from 'lucide-react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { useCombinedAlerts } from '@/hooks/admin/useCombinedAlerts';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -21,6 +23,8 @@ export function AdminSidebar() {
|
|||||||
const isSuperuser = permissions?.role_level === 'superuser';
|
const isSuperuser = permissions?.role_level === 'superuser';
|
||||||
const isAdmin = permissions?.role_level === 'admin' || isSuperuser;
|
const isAdmin = permissions?.role_level === 'admin' || isSuperuser;
|
||||||
const collapsed = state === 'collapsed';
|
const collapsed = state === 'collapsed';
|
||||||
|
const { data: combinedAlerts } = useCombinedAlerts();
|
||||||
|
const alertCount = combinedAlerts?.length || 0;
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@@ -28,6 +32,12 @@ export function AdminSidebar() {
|
|||||||
url: '/admin',
|
url: '/admin',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Monitoring Overview',
|
||||||
|
url: '/admin/monitoring-overview',
|
||||||
|
icon: Activity,
|
||||||
|
badge: alertCount > 0 ? alertCount : undefined,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Moderation',
|
title: 'Moderation',
|
||||||
url: '/admin/moderation',
|
url: '/admin/moderation',
|
||||||
@@ -132,7 +142,21 @@ export function AdminSidebar() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4" />
|
<item.icon className="w-4 h-4" />
|
||||||
{!collapsed && <span>{item.title}</span>}
|
{!collapsed && (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{item.title}
|
||||||
|
{item.badge !== undefined && (
|
||||||
|
<Badge variant="destructive" className="text-xs h-5 px-1.5">
|
||||||
|
{item.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{collapsed && item.badge !== undefined && item.badge > 0 && (
|
||||||
|
<Badge variant="destructive" className="text-xs h-5 w-5 p-0 flex items-center justify-center absolute -top-1 -right-1">
|
||||||
|
{item.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
49
src/hooks/admin/useCombinedAlerts.ts
Normal file
49
src/hooks/admin/useCombinedAlerts.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSystemAlerts } from '@/hooks/useSystemHealth';
|
||||||
|
import { useUnresolvedAlerts } from '@/hooks/useRateLimitAlerts';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export interface CombinedAlert {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
message: string;
|
||||||
|
alert_type?: string;
|
||||||
|
source: 'system' | 'rate_limit';
|
||||||
|
resolved_at?: string | null;
|
||||||
|
metric_type?: string;
|
||||||
|
function_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCombinedAlerts() {
|
||||||
|
const systemCritical = useSystemAlerts('critical');
|
||||||
|
const systemHigh = useSystemAlerts('high');
|
||||||
|
const rateLimitAlerts = useUnresolvedAlerts();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.combinedAlerts(),
|
||||||
|
queryFn: () => {
|
||||||
|
const combined: CombinedAlert[] = [
|
||||||
|
...(systemCritical.data || []).map(a => ({ ...a, source: 'system' as const })),
|
||||||
|
...(systemHigh.data || []).map(a => ({ ...a, source: 'system' as const })),
|
||||||
|
...(rateLimitAlerts.data || []).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
created_at: a.created_at,
|
||||||
|
severity: 'high' as const, // Rate limit alerts are considered high severity
|
||||||
|
message: a.alert_message,
|
||||||
|
alert_type: a.metric_type,
|
||||||
|
source: 'rate_limit' as const,
|
||||||
|
resolved_at: a.resolved_at,
|
||||||
|
metric_type: a.metric_type,
|
||||||
|
function_name: a.function_name,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return combined
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
.slice(0, 10);
|
||||||
|
},
|
||||||
|
enabled: !systemCritical.isLoading && !systemHigh.isLoading && !rateLimitAlerts.isLoading,
|
||||||
|
staleTime: 15000,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
43
src/hooks/admin/useDatabaseHealth.ts
Normal file
43
src/hooks/admin/useDatabaseHealth.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export interface DatabaseHealth {
|
||||||
|
status: 'healthy' | 'warning' | 'unhealthy';
|
||||||
|
recentErrors: number;
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDatabaseHealth() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.databaseHealth(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const threshold = new Date(Date.now() - 3600000); // 1 hour
|
||||||
|
|
||||||
|
// Check for recent database errors
|
||||||
|
const { count, error } = await supabase
|
||||||
|
.from('request_metadata')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('error_type', 'database_error')
|
||||||
|
.gte('created_at', threshold.toISOString());
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
status: 'warning' as const,
|
||||||
|
recentErrors: 0,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCount = count || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: errorCount > 10 ? 'unhealthy' : errorCount > 5 ? 'warning' : 'healthy',
|
||||||
|
recentErrors: errorCount,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
} as DatabaseHealth;
|
||||||
|
},
|
||||||
|
staleTime: 60000,
|
||||||
|
refetchInterval: 120000,
|
||||||
|
});
|
||||||
|
}
|
||||||
36
src/hooks/admin/useModerationHealth.ts
Normal file
36
src/hooks/admin/useModerationHealth.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export interface ModerationHealth {
|
||||||
|
queueLength: number;
|
||||||
|
activeLocks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModerationHealth() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.moderationHealth(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const [queue, oldestSubmission] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('status', 'pending_review'),
|
||||||
|
supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('created_at')
|
||||||
|
.eq('status', 'pending_review')
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.single(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queueLength: queue.count || 0,
|
||||||
|
activeLocks: 0, // Not tracking locks for now
|
||||||
|
} as ModerationHealth;
|
||||||
|
},
|
||||||
|
staleTime: 30000,
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
77
src/hooks/admin/useRecentActivity.ts
Normal file
77
src/hooks/admin/useRecentActivity.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { queryKeys } from '@/lib/queryKeys';
|
||||||
|
|
||||||
|
export type ActivityEvent =
|
||||||
|
| { id: string; created_at: string; type: 'error'; error_type: string | null; error_message: string | null; endpoint: string }
|
||||||
|
| { id: string; created_at: string; type: 'approval'; success: false; error_message: string | null; moderator_id: string }
|
||||||
|
| { id: string; created_at: string; type: 'alert'; alert_type: string; severity: string; message: string };
|
||||||
|
|
||||||
|
export function useRecentActivity(timeWindow = 3600000) { // 1 hour default
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.monitoring.recentActivity(timeWindow),
|
||||||
|
queryFn: async () => {
|
||||||
|
const threshold = new Date(Date.now() - timeWindow);
|
||||||
|
|
||||||
|
const [errors, approvals, alerts] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('request_metadata')
|
||||||
|
.select('id, created_at, error_type, error_message, endpoint')
|
||||||
|
.not('error_type', 'is', null)
|
||||||
|
.gte('created_at', threshold.toISOString())
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10),
|
||||||
|
supabase
|
||||||
|
.from('approval_transaction_metrics')
|
||||||
|
.select('id, created_at, success, error_message, moderator_id')
|
||||||
|
.eq('success', false)
|
||||||
|
.gte('created_at', threshold.toISOString())
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10),
|
||||||
|
supabase
|
||||||
|
.from('system_alerts')
|
||||||
|
.select('id, created_at, alert_type, severity, message')
|
||||||
|
.gte('created_at', threshold.toISOString())
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const combined: ActivityEvent[] = [
|
||||||
|
...(errors.data || [])
|
||||||
|
.filter(e => e.error_type && e.error_message)
|
||||||
|
.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
created_at: e.created_at,
|
||||||
|
type: 'error' as const,
|
||||||
|
error_type: e.error_type,
|
||||||
|
error_message: e.error_message,
|
||||||
|
endpoint: e.endpoint,
|
||||||
|
})),
|
||||||
|
...(approvals.data || [])
|
||||||
|
.filter(a => a.created_at && a.error_message)
|
||||||
|
.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
created_at: a.created_at || new Date().toISOString(),
|
||||||
|
type: 'approval' as const,
|
||||||
|
success: false as const,
|
||||||
|
error_message: a.error_message,
|
||||||
|
moderator_id: a.moderator_id,
|
||||||
|
})),
|
||||||
|
...(alerts.data || []).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
created_at: a.created_at,
|
||||||
|
type: 'alert' as const,
|
||||||
|
alert_type: a.alert_type,
|
||||||
|
severity: a.severity,
|
||||||
|
message: a.message,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return combined
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
.slice(0, 30);
|
||||||
|
},
|
||||||
|
staleTime: 30000,
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/lib/supabaseClient';
|
import { supabase } from '@/lib/supabaseClient';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface SystemHealthData {
|
export interface SystemHealthData {
|
||||||
orphaned_images_count: number;
|
orphaned_images_count: number;
|
||||||
critical_alerts_count: number;
|
critical_alerts_count: number;
|
||||||
|
high_alerts_count?: number;
|
||||||
|
failed_webhook_count?: number;
|
||||||
alerts_last_24h: number;
|
alerts_last_24h: number;
|
||||||
checked_at: string;
|
checked_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SystemAlert {
|
export interface SystemAlert {
|
||||||
id: string;
|
id: string;
|
||||||
alert_type: 'orphaned_images' | 'stale_submissions' | 'circular_dependency' | 'validation_error' | 'ban_attempt' | 'upload_timeout' | 'high_error_rate';
|
alert_type: 'orphaned_images' | 'stale_submissions' | 'circular_dependency' | 'validation_error' | 'ban_attempt' | 'upload_timeout' | 'high_error_rate';
|
||||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
@@ -101,8 +104,10 @@ export function useSystemAlerts(severity?: 'low' | 'medium' | 'high' | 'critical
|
|||||||
* Only accessible to admins
|
* Only accessible to admins
|
||||||
*/
|
*/
|
||||||
export function useRunSystemMaintenance() {
|
export function useRunSystemMaintenance() {
|
||||||
return async () => {
|
const queryClient = useQueryClient();
|
||||||
try {
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
const { data, error } = await supabase.rpc('run_system_maintenance');
|
const { data, error } = await supabase.rpc('run_system_maintenance');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -118,12 +123,18 @@ export function useRunSystemMaintenance() {
|
|||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
details: Record<string, any>;
|
details: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
} catch (error) {
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['system-health'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['system-alerts'] });
|
||||||
|
toast.success('System maintenance completed successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: 'Run System Maintenance',
|
action: 'Run System Maintenance',
|
||||||
metadata: { error: String(error) }
|
metadata: { error: String(error) }
|
||||||
});
|
});
|
||||||
throw error;
|
toast.error('Failed to run system maintenance');
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,4 +77,17 @@ export const queryKeys = {
|
|||||||
lists: {
|
lists: {
|
||||||
items: (listId: string) => ['list-items', listId] as const,
|
items: (listId: string) => ['list-items', listId] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Monitoring queries
|
||||||
|
monitoring: {
|
||||||
|
overview: () => ['monitoring', 'overview'] as const,
|
||||||
|
systemHealth: () => ['system-health'] as const,
|
||||||
|
systemAlerts: (severity?: string) => ['system-alerts', severity] as const,
|
||||||
|
rateLimitStats: (timeWindow: number) => ['rate-limit-stats', timeWindow] as const,
|
||||||
|
recentErrors: (timeWindow: number) => ['recent-errors', timeWindow] as const,
|
||||||
|
recentActivity: (timeWindow: number) => ['recent-activity', timeWindow] as const,
|
||||||
|
combinedAlerts: () => ['monitoring', 'combined-alerts'] as const,
|
||||||
|
databaseHealth: () => ['monitoring', 'database-health'] as const,
|
||||||
|
moderationHealth: () => ['monitoring', 'moderation-health'] as const,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
124
src/pages/admin/MonitoringOverview.tsx
Normal file
124
src/pages/admin/MonitoringOverview.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||||
|
import { RefreshButton } from '@/components/ui/refresh-button';
|
||||||
|
import { SystemHealthStatus } from '@/components/admin/SystemHealthStatus';
|
||||||
|
import { CriticalAlertsPanel } from '@/components/admin/CriticalAlertsPanel';
|
||||||
|
import { MonitoringQuickStats } from '@/components/admin/MonitoringQuickStats';
|
||||||
|
import { RecentActivityTimeline } from '@/components/admin/RecentActivityTimeline';
|
||||||
|
import { MonitoringNavCards } from '@/components/admin/MonitoringNavCards';
|
||||||
|
import { useSystemHealth } from '@/hooks/useSystemHealth';
|
||||||
|
import { useCombinedAlerts } from '@/hooks/admin/useCombinedAlerts';
|
||||||
|
import { useRecentActivity } from '@/hooks/admin/useRecentActivity';
|
||||||
|
import { useDatabaseHealth } from '@/hooks/admin/useDatabaseHealth';
|
||||||
|
import { useModerationHealth } from '@/hooks/admin/useModerationHealth';
|
||||||
|
import { useRateLimitStats } from '@/hooks/useRateLimitMetrics';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export default function MonitoringOverview() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
|
||||||
|
// Fetch all monitoring data
|
||||||
|
const systemHealth = useSystemHealth();
|
||||||
|
const combinedAlerts = useCombinedAlerts();
|
||||||
|
const recentActivity = useRecentActivity(3600000); // 1 hour
|
||||||
|
const dbHealth = useDatabaseHealth();
|
||||||
|
const moderationHealth = useModerationHealth();
|
||||||
|
const rateLimitStats = useRateLimitStats(3600000); // 1 hour
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
systemHealth.isLoading ||
|
||||||
|
combinedAlerts.isLoading ||
|
||||||
|
recentActivity.isLoading ||
|
||||||
|
dbHealth.isLoading ||
|
||||||
|
moderationHealth.isLoading ||
|
||||||
|
rateLimitStats.isLoading;
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['monitoring'],
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['system-health'],
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['system-alerts'],
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['rate-limit'],
|
||||||
|
refetchType: 'active'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate error count for nav card (from recent activity)
|
||||||
|
const errorCount = recentActivity.data?.filter(e => e.type === 'error').length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Monitoring Overview</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">Real-time system health, alerts, and activity monitoring</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="auto-refresh"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-refresh" className="text-sm cursor-pointer">
|
||||||
|
Auto-refresh
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<RefreshButton
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Health Status */}
|
||||||
|
<SystemHealthStatus
|
||||||
|
systemHealth={systemHealth.data ?? undefined}
|
||||||
|
dbHealth={dbHealth.data}
|
||||||
|
isLoading={systemHealth.isLoading || dbHealth.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Critical Alerts */}
|
||||||
|
<CriticalAlertsPanel
|
||||||
|
alerts={combinedAlerts.data}
|
||||||
|
isLoading={combinedAlerts.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Stats Grid */}
|
||||||
|
<MonitoringQuickStats
|
||||||
|
systemHealth={systemHealth.data ?? undefined}
|
||||||
|
rateLimitStats={rateLimitStats.data}
|
||||||
|
moderationHealth={moderationHealth.data}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Recent Activity Timeline */}
|
||||||
|
<RecentActivityTimeline
|
||||||
|
activity={recentActivity.data}
|
||||||
|
isLoading={recentActivity.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Navigation Cards */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Detailed Dashboards</h2>
|
||||||
|
<MonitoringNavCards
|
||||||
|
errorCount={errorCount}
|
||||||
|
rateLimitCount={rateLimitStats.data?.blocked_requests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Add RLS policies for system_alerts table
|
||||||
|
|
||||||
|
-- SELECT policy: Moderators can view system alerts
|
||||||
|
CREATE POLICY "Moderators can view system alerts"
|
||||||
|
ON public.system_alerts
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('admin', 'moderator', 'superuser')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT policy: System can create alerts
|
||||||
|
CREATE POLICY "System can create alerts"
|
||||||
|
ON public.system_alerts
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- UPDATE policy: Moderators can resolve system alerts
|
||||||
|
CREATE POLICY "Moderators can resolve system alerts"
|
||||||
|
ON public.system_alerts
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('admin', 'moderator', 'superuser')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND role IN ('admin', 'moderator', 'superuser')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grant permissions to authenticated users
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.system_alerts TO authenticated;
|
||||||
Reference in New Issue
Block a user