feat(04-01): create AlertBanner and AdminMonitoringDashboard components
- AlertBanner filters to active service_down/service_degraded alerts only - AlertBanner renders red banner with AlertTriangle icon and X Acknowledge button - AdminMonitoringDashboard fetches health+analytics concurrently via Promise.all - Health panel shows 1x4 grid of service status cards with colored dots - Analytics panel shows 1x5 stat cards with range selector and Refresh button - Both components follow existing Tailwind/lucide-react/cn() patterns
This commit is contained in:
178
frontend/src/components/AdminMonitoringDashboard.tsx
Normal file
178
frontend/src/components/AdminMonitoringDashboard.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Activity, Clock, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
adminService,
|
||||||
|
ServiceHealthEntry,
|
||||||
|
AnalyticsSummary,
|
||||||
|
} from '../services/adminService';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
const statusStyles: Record<
|
||||||
|
ServiceHealthEntry['status'],
|
||||||
|
{ dot: string; label: string; text: string }
|
||||||
|
> = {
|
||||||
|
healthy: { dot: 'bg-green-500', label: 'Healthy', text: 'text-green-700' },
|
||||||
|
degraded: { dot: 'bg-yellow-500', label: 'Degraded', text: 'text-yellow-700' },
|
||||||
|
down: { dot: 'bg-red-500', label: 'Down', text: 'text-red-700' },
|
||||||
|
unknown: { dot: 'bg-gray-400', label: 'Unknown', text: 'text-gray-600' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const serviceDisplayName: Record<string, string> = {
|
||||||
|
document_ai: 'Document AI',
|
||||||
|
llm_api: 'LLM API',
|
||||||
|
supabase: 'Supabase',
|
||||||
|
firebase_auth: 'Firebase Auth',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminMonitoringDashboard: React.FC = () => {
|
||||||
|
const [health, setHealth] = useState<ServiceHealthEntry[]>([]);
|
||||||
|
const [analytics, setAnalytics] = useState<AnalyticsSummary | null>(null);
|
||||||
|
const [range, setRange] = useState('24h');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [healthData, analyticsData] = await Promise.all([
|
||||||
|
adminService.getHealth(),
|
||||||
|
adminService.getAnalytics(range),
|
||||||
|
]);
|
||||||
|
setHealth(healthData);
|
||||||
|
setAnalytics(analyticsData);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load monitoring data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="mt-2 text-sm text-red-600 underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Service Health Panel */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center space-x-2">
|
||||||
|
<Activity className="h-5 w-5 text-gray-500" />
|
||||||
|
<span>Service Health</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{health.map((entry) => {
|
||||||
|
const styles = statusStyles[entry.status] ?? statusStyles.unknown;
|
||||||
|
const displayName = serviceDisplayName[entry.service] ?? entry.service;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.service}
|
||||||
|
className="bg-white rounded-lg shadow-soft border border-gray-100 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<div className={cn('w-3 h-3 rounded-full flex-shrink-0', styles.dot)} />
|
||||||
|
<span className="text-sm font-medium text-gray-900">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
<p className={cn('text-sm font-semibold', styles.text)}>{styles.label}</p>
|
||||||
|
{entry.latencyMs !== null && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{entry.latencyMs}ms</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center space-x-1 mt-1 text-xs text-gray-400">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
{entry.checkedAt
|
||||||
|
? new Date(entry.checkedAt).toLocaleString()
|
||||||
|
: 'Never checked'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing Analytics Panel */}
|
||||||
|
{analytics && (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Processing Analytics</h2>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Range:</label>
|
||||||
|
<select
|
||||||
|
value={range}
|
||||||
|
onChange={(e) => setRange(e.target.value)}
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="24h">Last 24h</option>
|
||||||
|
<option value="7d">Last 7d</option>
|
||||||
|
<option value="30d">Last 30d</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="flex items-center space-x-1 bg-blue-600 text-white px-3 py-1 rounded-md text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Total Uploads</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">{analytics.totalUploads}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Succeeded</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600 mt-1">{analytics.succeeded}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Failed</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600 mt-1">{analytics.failed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Success Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||||
|
{(analytics.successRate * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Avg Processing Time</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||||
|
{analytics.avgProcessingMs !== null
|
||||||
|
? `${(analytics.avgProcessingMs / 1000).toFixed(1)}s`
|
||||||
|
: 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AdminMonitoringDashboard };
|
||||||
|
export default AdminMonitoringDashboard;
|
||||||
44
frontend/src/components/AlertBanner.tsx
Normal file
44
frontend/src/components/AlertBanner.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
import { AlertEvent } from '../services/adminService';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
interface AlertBannerProps {
|
||||||
|
alerts: AlertEvent[];
|
||||||
|
onAcknowledge: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertBanner: React.FC<AlertBannerProps> = ({ alerts, onAcknowledge }) => {
|
||||||
|
const criticalAlerts = alerts.filter(
|
||||||
|
(a) =>
|
||||||
|
a.status === 'active' &&
|
||||||
|
(a.alert_type === 'service_down' || a.alert_type === 'service_degraded')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (criticalAlerts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-red-600 px-4 py-3')}>
|
||||||
|
{criticalAlerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="flex items-center justify-between text-white">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{alert.service_name}: {alert.message ?? alert.alert_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAcknowledge(alert.id)}
|
||||||
|
className="flex items-center space-x-1 text-sm underline hover:no-underline ml-4"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span>Acknowledge</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AlertBanner };
|
||||||
|
export default AlertBanner;
|
||||||
Reference in New Issue
Block a user