feat: punkt 5 (monitoring) — system dashboard backend+frontend, admin-only, auto-refresh 30s, alerting via notifications
This commit is contained in:
@@ -65,6 +65,7 @@ from app.routes import ( # noqa: E402
|
|||||||
saved_filters,
|
saved_filters,
|
||||||
saved_views,
|
saved_views,
|
||||||
sequences,
|
sequences,
|
||||||
|
system_dashboard,
|
||||||
system_settings,
|
system_settings,
|
||||||
taxes,
|
taxes,
|
||||||
tenants,
|
tenants,
|
||||||
@@ -559,6 +560,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(currencies.router)
|
app.include_router(currencies.router)
|
||||||
app.include_router(taxes.router)
|
app.include_router(taxes.router)
|
||||||
app.include_router(sequences.router)
|
app.include_router(sequences.router)
|
||||||
|
app.include_router(system_dashboard.router)
|
||||||
app.include_router(system_settings.router)
|
app.include_router(system_settings.router)
|
||||||
app.include_router(attachments.router)
|
app.include_router(attachments.router)
|
||||||
app.include_router(addresses.router)
|
app.include_router(addresses.router)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.routes import (
|
|||||||
plugins, # noqa: F401
|
plugins, # noqa: F401
|
||||||
roles, # noqa: F401
|
roles, # noqa: F401
|
||||||
sequences, # noqa: F401
|
sequences, # noqa: F401
|
||||||
|
system_dashboard, # noqa: F401
|
||||||
system_settings, # noqa: F401
|
system_settings, # noqa: F401
|
||||||
taxes, # noqa: F401
|
taxes, # noqa: F401
|
||||||
tenants, # noqa: F401
|
tenants, # noqa: F401
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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: '/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: '/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: '/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[] = [];
|
const bottomItems: NavSingleItem[] = [];
|
||||||
@@ -209,6 +210,8 @@ export function Sidebar() {
|
|||||||
for (const item of singles) {
|
for (const item of singles) {
|
||||||
// Skip if user lacks permission
|
// Skip if user lacks permission
|
||||||
if (item.permission && !canAccess(item.permission)) continue;
|
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(
|
elements.push(
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"mcpSettings": "MCP Einstellungen",
|
"mcpSettings": "MCP Einstellungen",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
"wiki": "Wiki"
|
"wiki": "Wiki",
|
||||||
|
"systemDashboard": "System Dashboard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -1281,6 +1282,47 @@
|
|||||||
"version": "Version"
|
"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": {
|
"editor": {
|
||||||
"write": "Schreiben",
|
"write": "Schreiben",
|
||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"mcpSettings": "MCP Settings",
|
"mcpSettings": "MCP Settings",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"wiki": "Wiki"
|
"wiki": "Wiki",
|
||||||
|
"systemDashboard": "System Dashboard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
@@ -1281,6 +1282,47 @@
|
|||||||
"version": "Version"
|
"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": {
|
"editor": {
|
||||||
"write": "Write",
|
"write": "Write",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 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 ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage })));
|
||||||
const WikiPage = React.lazy(() => import('@/pages/Wiki').then(m => ({ default: m.WikiPage })));
|
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 */
|
/** Centered spinner fallback for lazy-loaded routes */
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
@@ -268,6 +269,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
|
||||||
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
|
||||||
{ path: '/wiki', element: withSuspense(<WikiPage />) },
|
{ path: '/wiki', element: withSuspense(<WikiPage />) },
|
||||||
|
{ path: '/system-dashboard', element: withSuspense(<SystemDashboardPage />) },
|
||||||
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
{ path: '/profile', element: withSuspense(<SettingsProfilePage />) },
|
||||||
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
{ path: '*', element: <ErrorBoundary>{<PluginRouteRenderer />}</ErrorBoundary> },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user