282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Database, Server, Cpu, Activity, Plug, HardDrive, BrainCircuit,
|
|
AlertCircle, CheckCircle, XCircle, AlertTriangle, RefreshCw,
|
|
} from 'lucide-react';
|
|
import { useSystemDashboard, useSystemAlerts } from '@/api/systemDashboard';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
|
|
// ─── Status Badge ────────────────────────────────────────────────────────────
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const { t } = useTranslation();
|
|
const isUp = status === 'up' || status === 'healthy';
|
|
const isDegraded = status === 'degraded';
|
|
const isDown = status === 'down';
|
|
|
|
const config = isUp
|
|
? { icon: CheckCircle, color: 'text-success-600', bg: 'bg-success-50', label: t('systemDashboard.statusUp', 'OK') }
|
|
: isDegraded
|
|
? { icon: AlertTriangle, color: 'text-warning-600', bg: 'bg-warning-50', label: t('systemDashboard.statusDegraded', 'Degraded') }
|
|
: isDown
|
|
? { icon: XCircle, color: 'text-danger-600', bg: 'bg-danger-50', label: t('systemDashboard.statusDown', 'Down') }
|
|
: { icon: AlertCircle, color: 'text-secondary-600', bg: 'bg-secondary-50', label: t('systemDashboard.statusUnknown', 'Unknown') };
|
|
|
|
const Icon = config.icon;
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${config.bg} ${config.color}`}>
|
|
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
|
|
{config.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Metric Card ─────────────────────────────────────────────────────────────
|
|
|
|
interface MetricCardProps {
|
|
title: string;
|
|
icon: React.ReactNode;
|
|
status?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function MetricCard({ title, icon, status, children }: MetricCardProps) {
|
|
return (
|
|
<div className="bg-white rounded-lg border border-secondary-200 p-6" data-testid={`metric-card-${title}`}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center">
|
|
{icon}
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-secondary-900">{title}</h3>
|
|
</div>
|
|
{status && <StatusBadge status={status} />}
|
|
</div>
|
|
<div className="space-y-2 text-sm text-secondary-600">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Stat Row ────────────────────────────────────────────────────────────────
|
|
|
|
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="flex justify-between">
|
|
<span className="text-secondary-500">{label}</span>
|
|
<span className="font-medium text-secondary-900">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Alert Item ──────────────────────────────────────────────────────────────
|
|
|
|
function AlertItem({ alert }: { alert: { severity: string; type: string; message: string } }) {
|
|
const isCritical = alert.severity === 'critical';
|
|
const Icon = isCritical ? XCircle : AlertTriangle;
|
|
const colorClass = isCritical ? 'text-danger-600 bg-danger-50 border-danger-200' : 'text-warning-600 bg-warning-50 border-warning-200';
|
|
|
|
return (
|
|
<div
|
|
className={`flex items-start gap-3 p-4 rounded-lg border ${colorClass}`}
|
|
role="alert"
|
|
aria-label={alert.type}
|
|
>
|
|
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
|
<div>
|
|
<p className="text-sm font-semibold">{alert.type.replace(/_/g, ' ').toUpperCase()}</p>
|
|
<p className="text-sm mt-0.5">{alert.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export function SystemDashboardPage() {
|
|
const { t } = useTranslation();
|
|
const user = useAuthStore((state) => state.user);
|
|
const isAdmin = user?.is_system_admin === true;
|
|
|
|
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useSystemDashboard();
|
|
const { data: alertsData, isLoading: alertsLoading } = useSystemAlerts();
|
|
|
|
// Admin-only guard
|
|
if (!isAdmin) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]" role="alert">
|
|
<div className="text-center">
|
|
<AlertCircle className="w-12 h-12 text-danger-500 mx-auto mb-4" aria-hidden="true" />
|
|
<p className="text-secondary-700 font-medium">{t('systemDashboard.adminOnly', 'Administrator-Zugriff erforderlich')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dashboardLoading || alertsLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label={t('systemDashboard.loading', 'Lade System-Metriken...')}>
|
|
<RefreshCw className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dashboardError || !dashboard) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]" role="alert">
|
|
<div className="text-center">
|
|
<AlertCircle className="w-12 h-12 text-danger-500 mx-auto mb-4" aria-hidden="true" />
|
|
<p className="text-secondary-700 font-medium">{t('systemDashboard.loadError', 'Fehler beim Laden der System-Metriken')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const alerts = alertsData?.alerts ?? [];
|
|
const overallStatus = dashboard.overall_status;
|
|
|
|
return (
|
|
<div className="space-y-6" data-testid="system-dashboard-page">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('systemDashboard.title', 'System Dashboard')}</h1>
|
|
<p className="text-sm text-secondary-500 mt-1">
|
|
{t('systemDashboard.subtitle', 'Echtzeit-Systemüberwachung')} — {new Date(dashboard.timestamp).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<StatusBadge status={overallStatus} />
|
|
</div>
|
|
|
|
{/* Alerts Section */}
|
|
{alerts.length > 0 && (
|
|
<div className="space-y-3" data-testid="system-alerts-section">
|
|
<h2 className="text-lg font-semibold text-secondary-900">
|
|
{t('systemDashboard.activeAlerts', 'Aktive Alerts')} ({alerts.length})
|
|
</h2>
|
|
{alerts.map((alert, idx) => (
|
|
<AlertItem key={`${alert.type}-${idx}`} alert={alert} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Metrics Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{/* Database */}
|
|
<MetricCard
|
|
title={t('systemDashboard.database', 'Datenbank')}
|
|
icon={<Database className="w-5 h-5" aria-hidden="true" />}
|
|
status={dashboard.database.status}
|
|
>
|
|
{dashboard.database.error ? (
|
|
<p className="text-danger-600">{dashboard.database.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.connections', 'Verbindungen')} value={dashboard.database.connections ?? 'N/A'} />
|
|
<StatRow label={t('systemDashboard.tables', 'Tabellen')} value={dashboard.database.table_count ?? 'N/A'} />
|
|
<StatRow label={t('systemDashboard.dbSize', 'DB-Größe')} value={dashboard.database.db_size_human ?? 'N/A'} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* Redis */}
|
|
<MetricCard
|
|
title={t('systemDashboard.redis', 'Redis')}
|
|
icon={<Server className="w-5 h-5" aria-hidden="true" />}
|
|
status={dashboard.redis.status}
|
|
>
|
|
{dashboard.redis.error ? (
|
|
<p className="text-danger-600">{dashboard.redis.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.connections', 'Verbindungen')} value={dashboard.redis.connections ?? 'N/A'} />
|
|
<StatRow label={t('systemDashboard.memoryUsage', 'Speicher')} value={dashboard.redis.used_memory_human ?? 'N/A'} />
|
|
<StatRow label={t('systemDashboard.peakMemory', 'Peak')} value={dashboard.redis.peak_memory_human ?? 'N/A'} />
|
|
<StatRow label={t('systemDashboard.uptime', 'Uptime')} value={`${Math.round((dashboard.redis.uptime_seconds ?? 0) / 3600)}h`} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* Worker */}
|
|
<MetricCard
|
|
title={t('systemDashboard.worker', 'Worker')}
|
|
icon={<Cpu className="w-5 h-5" aria-hidden="true" />}
|
|
status={dashboard.worker.status}
|
|
>
|
|
{dashboard.worker.error ? (
|
|
<p className="text-danger-600">{dashboard.worker.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.queueLength', 'Queue-Länge')} value={dashboard.worker.queue_length ?? 0} />
|
|
<StatRow label={t('systemDashboard.activeWorkers', 'Aktive Worker')} value={dashboard.worker.active_workers ?? 0} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* API Stats */}
|
|
<MetricCard
|
|
title={t('systemDashboard.api', 'API')}
|
|
icon={<Activity className="w-5 h-5" aria-hidden="true" />}
|
|
>
|
|
{dashboard.api.error ? (
|
|
<p className="text-danger-600">{dashboard.api.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.totalRequests', 'Anfragen')} value={dashboard.api.total_requests ?? 0} />
|
|
<StatRow label={t('systemDashboard.errorCount', 'Fehler')} value={dashboard.api.error_count ?? 0} />
|
|
<StatRow label={t('systemDashboard.errorRate', 'Fehlerrate')} value={`${dashboard.api.error_rate ?? 0}%`} />
|
|
<StatRow label={t('systemDashboard.avgResponseTime', 'Ø Antwortzeit')} value={`${dashboard.api.avg_response_time_ms ?? 0}ms`} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* Plugins */}
|
|
<MetricCard
|
|
title={t('systemDashboard.plugins', 'Plugins')}
|
|
icon={<Plug className="w-5 h-5" aria-hidden="true" />}
|
|
>
|
|
{dashboard.plugins.error ? (
|
|
<p className="text-danger-600">{dashboard.plugins.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.totalPlugins', 'Entdeckt')} value={dashboard.plugins.total_discovered ?? 0} />
|
|
<StatRow label={t('systemDashboard.activePlugins', 'Aktiv')} value={dashboard.plugins.active_plugins?.length ?? 0} />
|
|
<StatRow label={t('systemDashboard.pluginErrors', 'Fehler')} value={dashboard.plugins.error_count ?? 0} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* Storage */}
|
|
<MetricCard
|
|
title={t('systemDashboard.storage', 'Storage')}
|
|
icon={<HardDrive className="w-5 h-5" aria-hidden="true" />}
|
|
status={dashboard.storage.status}
|
|
>
|
|
{dashboard.storage.error ? (
|
|
<p className="text-danger-600">{dashboard.storage.error}</p>
|
|
) : (
|
|
<>
|
|
<StatRow label={t('systemDashboard.diskUsage', 'Festplattennutzung')} value={`${dashboard.storage.disk_usage_percent ?? 0}%`} />
|
|
<StatRow label={t('systemDashboard.fileCount', 'Dateien')} value={dashboard.storage.file_count ?? 0} />
|
|
<StatRow label={t('systemDashboard.path', 'Pfad')} value={dashboard.storage.path ?? 'N/A'} />
|
|
</>
|
|
)}
|
|
</MetricCard>
|
|
|
|
{/* LLM Usage */}
|
|
<MetricCard
|
|
title={t('systemDashboard.llm', 'LLM Nutzung')}
|
|
icon={<BrainCircuit className="w-5 h-5" aria-hidden="true" />}
|
|
>
|
|
<StatRow label={t('systemDashboard.totalTokens', 'Tokens gesamt')} value={dashboard.llm.total_tokens.toLocaleString()} />
|
|
<StatRow label={t('systemDashboard.estimatedCost', 'Geschätzte Kosten')} value={`$${dashboard.llm.estimated_cost.toFixed(2)}`} />
|
|
<StatRow label={t('systemDashboard.tokens24h', 'Tokens 24h')} value={dashboard.llm.last_24h_tokens.toLocaleString()} />
|
|
<StatRow label={t('systemDashboard.cost24h', 'Kosten 24h')} value={`$${dashboard.llm.last_24h_cost.toFixed(2)}`} />
|
|
</MetricCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|