Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
src/components/admin/system-monitoring-dashboard.tsx
Normal file
197
src/components/admin/system-monitoring-dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Activity, Wifi, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ServiceHealthCard } from './service-health-card';
|
||||
import { QueueOverview } from './queue-overview';
|
||||
import type {
|
||||
HealthStatus,
|
||||
QueueStatus,
|
||||
ConnectionStatus,
|
||||
RecentError,
|
||||
} from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export function SystemMonitoringDashboard() {
|
||||
const { data: healthData } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: queuesData } = useQuery({
|
||||
queryKey: ['system', 'queues'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const { data: connectionsData } = useQuery({
|
||||
queryKey: ['system', 'connections'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ConnectionStatus }>('/api/v1/admin/connections').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const queues: QueueStatus[] = queuesData ?? [];
|
||||
const health: HealthStatus | undefined = healthData;
|
||||
const connections = connectionsData?.totalConnections ?? 0;
|
||||
|
||||
const totalFailed = queues.reduce((sum, q) => sum + q.failed, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
|
||||
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
|
||||
</div>
|
||||
|
||||
{/* Service health */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Service Health
|
||||
</h2>
|
||||
{health ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{health.overall === 'healthy' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All services checked at {new Date(health.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{health.services.map((service) => (
|
||||
<ServiceHealthCard key={service.name} service={service} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Stats row */}
|
||||
<section className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
Active Connections
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{connections}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Total Failed Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-3xl font-bold ${totalFailed > 0 ? 'text-destructive' : ''}`}>
|
||||
{totalFailed}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Active Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">
|
||||
{queues.reduce((sum, q) => sum + q.active, 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Queue overview */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Queue Overview
|
||||
</h2>
|
||||
{queues.length > 0 ? (
|
||||
<QueueOverview queues={queues} />
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[110px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent errors */}
|
||||
<RecentErrorsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentErrorsPanel() {
|
||||
const { data: errorsData } = useQuery({
|
||||
queryKey: ['system', 'errors'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const errors: RecentError[] = errorsData ?? [];
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Recent Errors
|
||||
</h2>
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent errors.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className="font-medium truncate">{error.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.source === 'queue' ? 'Queue' : 'Audit'} —{' '}
|
||||
{new Date(error.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user