feat: punkt 5 (monitoring) — system dashboard backend+frontend, admin-only, auto-refresh 30s, alerting via notifications

This commit is contained in:
Agent Zero
2026-08-20 13:39:50 +02:00
parent fbd0324d6b
commit 9a20ae5528
9 changed files with 848 additions and 2 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* System Dashboard API hooks — admin-only system monitoring.
*/
import { useQuery } from '@tanstack/react-query';
import { apiGet } from './client';
// ── Types matching backend responses ──
export interface SystemDashboardData {
overall_status: string;
timestamp: string;
database: {
status: string;
connections?: number;
table_count?: number;
db_size_bytes?: number;
db_size_human?: string;
error?: string;
};
redis: {
status: string;
connections?: number;
used_memory?: number;
used_memory_human?: string;
peak_memory_human?: string;
uptime_seconds?: number;
error?: string;
};
worker: {
status: string;
queue_length?: number;
active_workers?: number;
error?: string;
};
api: {
total_requests?: number;
error_count?: number;
error_rate?: number;
avg_response_time_ms?: number;
error?: string;
};
plugins: {
total_discovered?: number;
active_plugins?: Array<{
name: string;
version: string;
is_core: boolean;
}>;
error_count?: number;
errors?: string[];
error?: string;
};
storage: {
status: string;
path?: string;
disk_total_bytes?: number;
disk_used_bytes?: number;
disk_free_bytes?: number;
disk_usage_percent?: number;
file_count?: number;
error?: string;
};
llm: {
total_tokens: number;
estimated_cost: number;
last_24h_tokens: number;
last_24h_cost: number;
};
}
export interface SystemAlert {
severity: 'critical' | 'warning';
type: string;
message: string;
}
export interface SystemAlertsData {
alerts: SystemAlert[];
alert_count: number;
timestamp: string;
}
// ── Hooks ──
const REFRESH_INTERVAL = 30 * 1000; // 30 seconds
export function useSystemDashboard() {
return useQuery({
queryKey: ['system-dashboard'],
queryFn: () => apiGet<SystemDashboardData>('/system/dashboard'),
refetchInterval: REFRESH_INTERVAL,
staleTime: REFRESH_INTERVAL,
});
}
export function useSystemAlerts() {
return useQuery({
queryKey: ['system-alerts'],
queryFn: () => apiGet<SystemAlertsData>('/system/alerts'),
refetchInterval: REFRESH_INTERVAL,
staleTime: REFRESH_INTERVAL,
});
}
@@ -55,6 +55,7 @@ const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
{ to: '/wiki', labelKey: 'nav.wiki', icon: <BookOpen className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 30 },
{ to: '/system-dashboard', labelKey: 'nav.systemDashboard', icon: <Activity className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 90 },
];
const bottomItems: NavSingleItem[] = [];
@@ -209,6 +210,8 @@ export function Sidebar() {
for (const item of singles) {
// Skip if user lacks permission
if (item.permission && !canAccess(item.permission)) continue;
// Skip admin-only items for non-admins
if (item.path === '/system-dashboard' && !user?.is_system_admin) continue;
elements.push(
<li key={item.path}>
<NavLink
+43 -1
View File
@@ -20,7 +20,8 @@
"mcpSettings": "MCP Einstellungen",
"reports": "Reports",
"tasks": "Aufgaben",
"wiki": "Wiki"
"wiki": "Wiki",
"systemDashboard": "System Dashboard"
},
"auth": {
"login": "Anmelden",
@@ -1281,6 +1282,47 @@
"version": "Version"
}
},
"systemDashboard": {
"title": "System Dashboard",
"subtitle": "Echtzeit-Systemüberwachung",
"adminOnly": "Administrator-Zugriff erforderlich",
"loading": "Lade System-Metriken...",
"loadError": "Fehler beim Laden der System-Metriken",
"activeAlerts": "Aktive Alerts",
"statusUp": "OK",
"statusDegraded": "Degraded",
"statusDown": "Down",
"statusUnknown": "Unbekannt",
"database": "Datenbank",
"redis": "Redis",
"worker": "Worker",
"api": "API",
"plugins": "Plugins",
"storage": "Storage",
"llm": "LLM Nutzung",
"connections": "Verbindungen",
"tables": "Tabellen",
"dbSize": "DB-Größe",
"memoryUsage": "Speicher",
"peakMemory": "Peak",
"uptime": "Uptime",
"queueLength": "Queue-Länge",
"activeWorkers": "Aktive Worker",
"totalRequests": "Anfragen",
"errorCount": "Fehler",
"errorRate": "Fehlerrate",
"avgResponseTime": "Ø Antwortzeit",
"totalPlugins": "Entdeckt",
"activePlugins": "Aktiv",
"pluginErrors": "Fehler",
"diskUsage": "Festplattennutzung",
"fileCount": "Dateien",
"path": "Pfad",
"totalTokens": "Tokens gesamt",
"estimatedCost": "Geschätzte Kosten",
"tokens24h": "Tokens 24h",
"cost24h": "Kosten 24h"
},
"editor": {
"write": "Schreiben",
"preview": "Vorschau",
+43 -1
View File
@@ -20,7 +20,8 @@
"mcpSettings": "MCP Settings",
"reports": "Reports",
"tasks": "Tasks",
"wiki": "Wiki"
"wiki": "Wiki",
"systemDashboard": "System Dashboard"
},
"auth": {
"login": "Sign In",
@@ -1281,6 +1282,47 @@
"version": "Version"
}
},
"systemDashboard": {
"title": "System Dashboard",
"subtitle": "Real-time system monitoring",
"adminOnly": "Administrator access required",
"loading": "Loading system metrics...",
"loadError": "Failed to load system metrics",
"activeAlerts": "Active Alerts",
"statusUp": "OK",
"statusDegraded": "Degraded",
"statusDown": "Down",
"statusUnknown": "Unknown",
"database": "Database",
"redis": "Redis",
"worker": "Worker",
"api": "API",
"plugins": "Plugins",
"storage": "Storage",
"llm": "LLM Usage",
"connections": "Connections",
"tables": "Tables",
"dbSize": "DB Size",
"memoryUsage": "Memory",
"peakMemory": "Peak",
"uptime": "Uptime",
"queueLength": "Queue Length",
"activeWorkers": "Active Workers",
"totalRequests": "Requests",
"errorCount": "Errors",
"errorRate": "Error Rate",
"avgResponseTime": "Avg Response Time",
"totalPlugins": "Discovered",
"activePlugins": "Active",
"pluginErrors": "Errors",
"diskUsage": "Disk Usage",
"fileCount": "Files",
"path": "Path",
"totalTokens": "Total Tokens",
"estimatedCost": "Estimated Cost",
"tokens24h": "Tokens 24h",
"cost24h": "Cost 24h"
},
"editor": {
"write": "Write",
"preview": "Preview",
+281
View File
@@ -0,0 +1,281 @@
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>
);
}
+2
View File
@@ -89,6 +89,7 @@ const LogsPlaceholderPage = React.lazy(() => import('@/pages/logs/LogsPlaceholde
const HelpApiDocsPage = React.lazy(() => import('@/pages/help/HelpApiDocs').then(m => ({ default: m.HelpApiDocsPage })));
const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
const WikiPage = React.lazy(() => import('@/pages/Wiki').then(m => ({ default: m.WikiPage })));
const SystemDashboardPage = React.lazy(() => import('@/pages/SystemDashboard').then(m => ({ default: m.SystemDashboardPage })));
/** Centered spinner fallback for lazy-loaded routes */
function PageLoader() {
@@ -268,6 +269,7 @@ const router = createBrowserRouter([
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
{ path: '/wiki', element: withSuspense(<WikiPage />) },
{ path: '/system-dashboard', element: withSuspense(<SystemDashboardPage />) },
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
],