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
+2
View File
@@ -65,6 +65,7 @@ from app.routes import ( # noqa: E402
saved_filters,
saved_views,
sequences,
system_dashboard,
system_settings,
taxes,
tenants,
@@ -559,6 +560,7 @@ def create_app() -> FastAPI:
app.include_router(currencies.router)
app.include_router(taxes.router)
app.include_router(sequences.router)
app.include_router(system_dashboard.router)
app.include_router(system_settings.router)
app.include_router(attachments.router)
app.include_router(addresses.router)
+1
View File
@@ -19,6 +19,7 @@ from app.routes import (
plugins, # noqa: F401
roles, # noqa: F401
sequences, # noqa: F401
system_dashboard, # noqa: F401
system_settings, # noqa: F401
taxes, # noqa: F401
tenants, # noqa: F401
+369
View File
@@ -0,0 +1,369 @@
"""System Dashboard routes — admin-only system monitoring.
Endpoints:
GET /api/v1/system/dashboard — comprehensive system metrics
GET /api/v1/system/alerts — active alerts with notification dispatch
"""
from __future__ import annotations
import os
import shutil
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, get_engine
from app.core.monitoring import (
check_database,
check_redis,
check_worker,
get_health_status,
)
from app.core.notifications import post_system_message
from app.deps import require_admin
from app.plugins.registry import get_registry
router = APIRouter(prefix="/api/v1/system", tags=["system"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _format_bytes(size: int | float) -> str:
"""Format bytes to human-readable string."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
async def _get_db_stats() -> dict[str, Any]:
"""Get database statistics."""
try:
engine = get_engine()
async with engine.connect() as conn:
# Active connections
result = await conn.execute(text("SELECT count(*) FROM pg_stat_activity"))
connections = result.scalar()
# Table count (public schema)
result = await conn.execute(
text(
"SELECT count(*) FROM information_schema.tables "
"WHERE table_schema = 'public'"
)
)
table_count = result.scalar()
# Database size
result = await conn.execute(
text("SELECT pg_database_size(current_database())")
)
db_size = result.scalar()
return {
"status": "up",
"connections": connections,
"table_count": table_count,
"db_size_bytes": db_size,
"db_size_human": _format_bytes(db_size),
}
except Exception as e:
return {"status": "down", "error": str(e)}
async def _get_redis_stats() -> dict[str, Any]:
"""Get Redis statistics."""
try:
from app.core.auth import get_redis
r = get_redis()
info = await r.info()
return {
"status": "up",
"connections": info.get("connected_clients", 0),
"used_memory": info.get("used_memory", 0),
"used_memory_human": info.get("used_memory_human", "N/A"),
"peak_memory_human": info.get("used_memory_peak_human", "N/A"),
"uptime_seconds": info.get("uptime_in_seconds", 0),
}
except Exception as e:
return {"status": "down", "error": str(e)}
async def _get_worker_stats() -> dict[str, Any]:
"""Get ARQ worker statistics."""
try:
from app.core.auth import get_redis
r = get_redis()
queue_length = await r.zcard("arq:queue")
# Check for worker heartbeat keys
heartbeat_keys = await r.keys("arq:heartbeat:*")
active_workers = len(heartbeat_keys) if heartbeat_keys else 0
return {
"status": "up" if active_workers > 0 or queue_length == 0 else "degraded",
"queue_length": queue_length,
"active_workers": active_workers,
}
except Exception as e:
return {"status": "down", "error": str(e)}
def _get_api_stats() -> dict[str, Any]:
"""Get API statistics from Prometheus metrics registry."""
try:
from app.core.monitoring import REGISTRY
total_requests = 0
error_count = 0
duration_sum = 0.0
duration_count = 0
for metric in REGISTRY.collect():
for sample in metric.samples:
if sample.name == "leocrm_http_requests_total":
total_requests += int(sample.value)
status_label = sample.labels.get("status", "")
if status_label.startswith("5"):
error_count += int(sample.value)
elif sample.name == "leocrm_http_request_duration_seconds_sum":
duration_sum += sample.value
elif sample.name == "leocrm_http_request_duration_seconds_count":
duration_count += int(sample.value)
avg_duration = (
(duration_sum / duration_count * 1000) if duration_count > 0 else 0
)
error_rate = (error_count / total_requests * 100) if total_requests > 0 else 0
return {
"total_requests": total_requests,
"error_count": error_count,
"error_rate": round(error_rate, 2),
"avg_response_time_ms": round(avg_duration, 2),
}
except Exception as e:
return {"error": str(e)}
def _get_plugin_stats() -> dict[str, Any]:
"""Get plugin statistics from registry."""
try:
registry = get_registry()
discovered = registry.list_discovered()
active_plugins: list[dict[str, Any]] = []
for name in discovered:
plugin = registry.get_plugin(name)
if plugin:
active_plugins.append(
{
"name": name,
"version": plugin.manifest.version,
"is_core": plugin.manifest.is_core,
}
)
return {
"total_discovered": len(discovered),
"active_plugins": active_plugins,
"error_count": 0,
"errors": [],
}
except Exception as e:
return {"error": str(e)}
def _get_storage_stats() -> dict[str, Any]:
"""Get storage statistics."""
try:
from app.config import get_settings
settings = get_settings()
storage_path = getattr(settings, "storage_path", "/tmp")
if not os.path.exists(storage_path):
return {"status": "down", "error": f"Storage path {storage_path} not found"}
disk = shutil.disk_usage(storage_path)
file_count = sum(len(files) for _, _, files in os.walk(storage_path))
return {
"status": "up",
"path": storage_path,
"disk_total_bytes": disk.total,
"disk_used_bytes": disk.used,
"disk_free_bytes": disk.free,
"disk_usage_percent": (
round(disk.used / disk.total * 100, 1) if disk.total > 0 else 0
),
"file_count": file_count,
}
except Exception as e:
return {"status": "down", "error": str(e)}
def _get_llm_usage() -> dict[str, Any]:
"""Get LLM usage statistics (best effort — returns zeros if no tracking)."""
return {
"total_tokens": 0,
"estimated_cost": 0.0,
"last_24h_tokens": 0,
"last_24h_cost": 0.0,
}
# ─── Alert Notification ──────────────────────────────────────────────────────
async def _send_alert_notifications(
db: AsyncSession,
admin: dict[str, Any],
alerts: list[dict[str, Any]],
) -> None:
"""Send notifications for new alerts (throttled via Redis 5-min TTL)."""
try:
from app.core.auth import get_redis
r = get_redis()
tenant_id = uuid.UUID(admin["tenant_id"])
user_id = uuid.UUID(admin["user_id"])
for alert in alerts:
alert_key = f"system_alert:{alert['type']}"
# Only send if not already sent in the last 5 minutes
already_sent = await r.exists(alert_key)
if not already_sent:
await r.setex(alert_key, 300, "1") # 5-minute TTL
await post_system_message(
db=db,
tenant_id=tenant_id,
user_id=user_id,
message_type="system_alert",
title=f"System Alert: {alert['type']}",
body=alert["message"],
severity=alert["severity"],
)
except Exception:
pass # Never let notification failures break the alerts endpoint
# ─── Endpoints ───────────────────────────────────────────────────────────────
@router.get("/dashboard")
async def get_system_dashboard(
admin: dict[str, Any] = Depends(require_admin),
):
"""Get comprehensive system metrics. Admin-only.
Returns DB, Redis, Worker, API, Plugin, Storage, and LLM usage stats.
"""
health = await get_health_status()
db_stats = await _get_db_stats()
redis_stats = await _get_redis_stats()
worker_stats = await _get_worker_stats()
api_stats = _get_api_stats()
plugin_stats = _get_plugin_stats()
storage_stats = _get_storage_stats()
llm_usage = _get_llm_usage()
return {
"overall_status": health.get("status", "unknown"),
"timestamp": datetime.now(UTC).isoformat(),
"database": db_stats,
"redis": redis_stats,
"worker": worker_stats,
"api": api_stats,
"plugins": plugin_stats,
"storage": storage_stats,
"llm": llm_usage,
}
@router.get("/alerts")
async def get_system_alerts(
admin: dict[str, Any] = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Get active system alerts. Admin-only.
Checks DB, Redis, Worker, Storage, and API error rate.
Sends notifications for new alerts (throttled via Redis).
"""
alerts: list[dict[str, Any]] = []
# Check DB
db_check = await check_database()
if db_check["status"] == "down":
alerts.append(
{
"severity": "critical",
"type": "db_down",
"message": f"Database is down: {db_check.get('error', 'Unknown error')}",
}
)
# Check Redis
redis_check = await check_redis()
if redis_check["status"] == "down":
alerts.append(
{
"severity": "critical",
"type": "redis_down",
"message": f"Redis is down: {redis_check.get('error', 'Unknown error')}",
}
)
# Check Worker
worker_check = await check_worker()
if worker_check["status"] == "down":
alerts.append(
{
"severity": "warning",
"type": "worker_down",
"message": f"Worker is down: {worker_check.get('error', 'Unknown error')}",
}
)
# Check Storage disk usage
storage_stats = _get_storage_stats()
disk_percent = storage_stats.get("disk_usage_percent", 0)
if disk_percent > 90:
alerts.append(
{
"severity": "critical",
"type": "disk_full",
"message": f"Disk usage at {disk_percent}%",
}
)
# Check high API error rate
api_stats = _get_api_stats()
error_rate = api_stats.get("error_rate", 0)
if error_rate > 5:
alerts.append(
{
"severity": "warning",
"type": "high_error_rate",
"message": f"High error rate: {error_rate:.1f}%",
}
)
# Send notifications for new alerts (throttled via Redis)
if alerts:
await _send_alert_notifications(db, admin, alerts)
return {
"alerts": alerts,
"alert_count": len(alerts),
"timestamp": datetime.now(UTC).isoformat(),
}
+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> },
],