feat(monitoring): add monitoring dashboard UI with 5 tabs, SSE connection, and shared components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-15 00:45:41 +00:00
parent 5f38136b95
commit dd73162035
5 changed files with 1280 additions and 0 deletions

View file

@ -0,0 +1,402 @@
.monitoring {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
&__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
&__status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
&--connected {
color: #22c55e;
}
&--disconnected {
color: #ef4444;
}
}
&__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
&__tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--theme-elevation-150, #e5e7eb);
margin-bottom: 20px;
}
&__tab {
padding: 8px 16px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
color: var(--theme-elevation-500, #6b7280);
border-bottom: 2px solid transparent;
transition: all 0.15s;
&:hover {
color: var(--theme-text, #111827);
}
&--active {
color: var(--theme-text, #111827);
border-bottom-color: var(--theme-elevation-800, #3b82f6);
font-weight: 500;
}
}
&__content {
min-height: 400px;
}
&__loading {
padding: 40px;
text-align: center;
color: var(--theme-elevation-500);
}
&__empty {
text-align: center;
color: var(--theme-elevation-500);
padding: 20px;
}
// Grid layout for health gauges
&__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
&__chart-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
grid-column: 1 / -1;
}
// Gauge Widget
&__gauge {
background: var(--theme-elevation-50, #f9fafb);
border: 1px solid var(--theme-elevation-150, #e5e7eb);
border-radius: 8px;
padding: 16px;
text-align: center;
}
&__gauge-label {
font-size: 12px;
color: var(--theme-elevation-500);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
&__gauge-bar {
height: 6px;
background: var(--theme-elevation-100, #f3f4f6);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
&__gauge-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
&__gauge-value {
font-size: 24px;
font-weight: 700;
}
// Chart
&__chart {
background: var(--theme-elevation-50, #f9fafb);
border: 1px solid var(--theme-elevation-150, #e5e7eb);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
}
&__chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__chart-label {
font-size: 12px;
color: var(--theme-elevation-500);
font-weight: 500;
}
&__chart-value {
font-size: 14px;
font-weight: 600;
}
&__chart-svg {
flex: 1;
width: 100%;
}
// Services
&__services {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
&__service-card {
background: var(--theme-elevation-50, #f9fafb);
border: 1px solid var(--theme-elevation-150, #e5e7eb);
border-radius: 8px;
padding: 16px;
}
&__service-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
&__service-name {
font-weight: 500;
font-size: 14px;
}
&__service-details {
font-size: 11px;
color: var(--theme-elevation-500);
overflow: auto;
max-height: 200px;
margin: 0;
}
// Status Badge
&__status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.3px;
}
// Period Selector
&__period-selector {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
&__period-btn {
padding: 6px 12px;
border: 1px solid var(--theme-elevation-150, #e5e7eb);
background: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&--active {
background: var(--theme-elevation-800, #3b82f6);
color: white;
border-color: transparent;
}
}
// KPI Cards
&__kpi-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
&__kpi {
background: var(--theme-elevation-50, #f9fafb);
border: 1px solid var(--theme-elevation-150, #e5e7eb);
border-radius: 8px;
padding: 16px;
text-align: center;
}
&__kpi-value {
display: block;
font-size: 20px;
font-weight: 700;
}
&__kpi-label {
display: block;
font-size: 11px;
color: var(--theme-elevation-500);
margin-top: 4px;
}
// Alerts
&__alerts-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
// Logs
&__logs-toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
// Shared form elements
&__select,
&__input {
padding: 6px 10px;
border: 1px solid var(--theme-elevation-150, #e5e7eb);
border-radius: 4px;
font-size: 13px;
background: var(--theme-input-bg, white);
color: var(--theme-text, #111827);
}
&__input {
min-width: 200px;
}
&__link {
color: var(--theme-elevation-800, #3b82f6);
font-size: 13px;
text-decoration: none;
margin-left: auto;
&:hover {
text-decoration: underline;
}
}
// Table
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--theme-elevation-150, #e5e7eb);
}
th {
font-weight: 600;
color: var(--theme-elevation-500);
font-size: 11px;
text-transform: uppercase;
}
}
&__row--unack {
background: rgba(239, 68, 68, 0.05);
}
&__row--clickable {
cursor: pointer;
&:hover {
background: var(--theme-elevation-50);
}
}
&__json {
font-size: 11px;
background: var(--theme-elevation-50);
padding: 8px;
border-radius: 4px;
overflow: auto;
max-height: 200px;
margin: 0;
}
&__badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
background: var(--theme-elevation-100);
color: var(--theme-elevation-600);
}
// Buttons
&__btn {
padding: 6px 12px;
border: 1px solid var(--theme-elevation-150, #e5e7eb);
background: var(--theme-elevation-50, #f9fafb);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover {
background: var(--theme-elevation-100);
}
&--sm {
padding: 4px 8px;
font-size: 11px;
}
}
// Pagination
&__pagination {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
margin-top: 16px;
font-size: 13px;
button {
padding: 6px 12px;
border: 1px solid var(--theme-elevation-150);
background: none;
border-radius: 4px;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}

View file

@ -0,0 +1,816 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import './MonitoringDashboard.scss'
// ============================================================================
// Types
// ============================================================================
type Tab = 'health' | 'services' | 'performance' | 'alerts' | 'logs'
interface TabDefinition {
key: Tab
label: string
}
interface HealthData {
cpuUsagePercent: number
memoryUsagePercent: number
diskUsagePercent: number
uptime: number
loadAvg1?: number
}
interface Snapshot {
timestamp: string
system?: {
cpuUsagePercent?: number
memoryUsagePercent?: number
loadAvg1?: number
}
performance?: {
avgResponseTimeMs?: number
errorRate?: number
requestsPerMinute?: number
}
}
interface ServiceData {
status: string
[key: string]: unknown
}
interface ServicesResponse {
postgresql?: ServiceData
pgbouncer?: ServiceData
redis?: ServiceData
smtp?: ServiceData
queues?: ServiceData
oauth?: {
metaOAuth?: { status: string; [key: string]: unknown }
youtubeOAuth?: { status: string; [key: string]: unknown }
}
}
interface PerformanceData {
avgResponseTimeMs: number
p95ResponseTimeMs: number
p99ResponseTimeMs: number
errorRate: number
requestsPerMinute: number
}
interface AlertData {
id: string
severity: string
metric: string
message: string
createdAt?: string
acknowledgedBy?: string
}
interface LogData {
id: string
level: string
source: string
message: string
createdAt?: string
context?: Record<string, unknown>
}
interface TrendPoint {
timestamp: string
value: number
}
// ============================================================================
// Constants
// ============================================================================
const TABS: TabDefinition[] = [
{ key: 'health', label: 'System Health' },
{ key: 'services', label: 'Services' },
{ key: 'performance', label: 'Performance' },
{ key: 'alerts', label: 'Alerts' },
{ key: 'logs', label: 'Logs' },
]
const PERIOD_OPTIONS = ['1h', '6h', '24h', '7d'] as const
const LEVEL_COLORS: Record<string, string> = {
debug: '#6b7280',
info: '#3b82f6',
warn: '#f59e0b',
error: '#ef4444',
fatal: '#dc2626',
}
const STATUS_COLORS: Record<string, string> = {
online: '#22c55e',
ok: '#22c55e',
warning: '#f59e0b',
expiring_soon: '#f59e0b',
offline: '#ef4444',
error: '#ef4444',
expired: '#ef4444',
unknown: '#6b7280',
}
const MAX_ALERTS_BUFFER = 50
const MAX_LOGS_BUFFER = 100
// ============================================================================
// Main Dashboard Component
// ============================================================================
export function MonitoringDashboard(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tab>('health')
const [connected, setConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
const [healthData, setHealthData] = useState<HealthData | null>(null)
const [performanceData, setPerformanceData] = useState<PerformanceData | null>(null)
const [newAlerts, setNewAlerts] = useState<AlertData[]>([])
const [newLogs, setNewLogs] = useState<LogData[]>([])
useEffect(() => {
const es = new EventSource('/api/monitoring/stream', { withCredentials: true })
eventSourceRef.current = es
es.addEventListener('connected', () => setConnected(true))
es.addEventListener('health', (e) => {
try {
setHealthData(JSON.parse(e.data))
} catch (err) {
console.error('[Monitoring] Failed to parse health event:', err)
}
})
es.addEventListener('performance', (e) => {
try {
setPerformanceData(JSON.parse(e.data))
} catch (err) {
console.error('[Monitoring] Failed to parse performance event:', err)
}
})
es.addEventListener('alert', (e) => {
try {
const alert: AlertData = JSON.parse(e.data)
setNewAlerts((prev) => [alert, ...prev].slice(0, MAX_ALERTS_BUFFER))
} catch (err) {
console.error('[Monitoring] Failed to parse alert event:', err)
}
})
es.addEventListener('log', (e) => {
try {
const log: LogData = JSON.parse(e.data)
setNewLogs((prev) => [log, ...prev].slice(0, MAX_LOGS_BUFFER))
} catch (err) {
console.error('[Monitoring] Failed to parse log event:', err)
}
})
es.addEventListener('reconnect', () => {
es.close()
})
es.onerror = () => setConnected(false)
return () => {
es.close()
eventSourceRef.current = null
}
}, [])
const statusClass = connected ? 'monitoring__status--connected' : 'monitoring__status--disconnected'
return (
<div className="monitoring">
<div className="monitoring__header">
<h1 className="monitoring__title">Monitoring Dashboard</h1>
<div className={`monitoring__status ${statusClass}`}>
<span className="monitoring__status-dot" />
{connected ? 'Live' : 'Disconnected'}
</div>
</div>
<div className="monitoring__tabs">
{TABS.map((tab) => (
<button
key={tab.key}
className={`monitoring__tab ${activeTab === tab.key ? 'monitoring__tab--active' : ''}`}
onClick={() => setActiveTab(tab.key)}
type="button"
>
{tab.label}
</button>
))}
</div>
<div className="monitoring__content">
{activeTab === 'health' && <SystemHealthTab healthData={healthData} />}
{activeTab === 'services' && <ServicesTab />}
{activeTab === 'performance' && <PerformanceTab performanceData={performanceData} />}
{activeTab === 'alerts' && <AlertsTab newAlerts={newAlerts} />}
{activeTab === 'logs' && <LogsTab newLogs={newLogs} />}
</div>
</div>
)
}
// ============================================================================
// Tab Components
// ============================================================================
interface SystemHealthTabProps {
healthData: HealthData | null
}
function SystemHealthTab({ healthData }: SystemHealthTabProps): React.ReactElement {
const [initialData, setInitialData] = useState<HealthData | null>(null)
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
useEffect(() => {
fetch('/api/monitoring/health', { credentials: 'include' })
.then((r) => r.json())
.then((d) => setInitialData(d.data))
.catch((err) => console.error('[Monitoring] Failed to fetch health data:', err))
fetch('/api/monitoring/snapshots?period=24h', { credentials: 'include' })
.then((r) => r.json())
.then((d) => setSnapshots(d.data || []))
.catch((err) => console.error('[Monitoring] Failed to fetch snapshots:', err))
}, [])
const data = healthData || initialData
if (!data) {
return <div className="monitoring__loading">Loading...</div>
}
const uptimeHours = Math.round(data.uptime / 3600)
return (
<div className="monitoring__grid">
<GaugeWidget label="CPU" value={data.cpuUsagePercent} max={100} unit="%" thresholds={{ warning: 70, critical: 90 }} />
<GaugeWidget label="RAM" value={data.memoryUsagePercent} max={100} unit="%" thresholds={{ warning: 80, critical: 95 }} />
<GaugeWidget label="Disk" value={data.diskUsagePercent} max={100} unit="%" thresholds={{ warning: 80, critical: 95 }} />
<GaugeWidget label="Uptime" value={uptimeHours} max={720} unit="h" thresholds={{ warning: 0, critical: 0 }} />
<div className="monitoring__chart-row">
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: s.system?.cpuUsagePercent || 0 }))}
label="CPU (24h)"
unit="%"
height={200}
/>
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: s.system?.memoryUsagePercent || 0 }))}
label="Memory (24h)"
unit="%"
height={200}
/>
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: s.system?.loadAvg1 || 0 }))}
label="Load (24h)"
unit=""
height={200}
/>
</div>
</div>
)
}
function ServicesTab(): React.ReactElement {
const [services, setServices] = useState<ServicesResponse | null>(null)
useEffect(() => {
fetch('/api/monitoring/services', { credentials: 'include' })
.then((r) => r.json())
.then((d) => setServices(d.data))
.catch((err) => console.error('[Monitoring] Failed to fetch services:', err))
}, [])
if (!services) {
return <div className="monitoring__loading">Loading...</div>
}
const coreServices = [
{ name: 'PostgreSQL', data: services.postgresql },
{ name: 'PgBouncer', data: services.pgbouncer },
{ name: 'Redis', data: services.redis },
{ name: 'SMTP', data: services.smtp },
{ name: 'Queues', data: services.queues },
]
return (
<div className="monitoring__services">
{coreServices.map((s) => (
<div key={s.name} className="monitoring__service-card">
<div className="monitoring__service-header">
<StatusBadge status={s.data?.status || 'offline'} />
<span className="monitoring__service-name">{s.name}</span>
</div>
<pre className="monitoring__service-details">{JSON.stringify(s.data, null, 2)}</pre>
</div>
))}
{services.oauth && (
<>
<ServiceOAuthCard name="Meta OAuth" data={services.oauth.metaOAuth} />
<ServiceOAuthCard name="YouTube OAuth" data={services.oauth.youtubeOAuth} />
</>
)}
</div>
)
}
interface ServiceOAuthCardProps {
name: string
data?: { status: string; [key: string]: unknown }
}
function ServiceOAuthCard({ name, data }: ServiceOAuthCardProps): React.ReactElement {
const status = data?.status === 'ok' ? 'online' : 'warning'
return (
<div className="monitoring__service-card">
<div className="monitoring__service-header">
<StatusBadge status={status} />
<span className="monitoring__service-name">{name}</span>
</div>
<pre className="monitoring__service-details">{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
interface PerformanceTabProps {
performanceData: PerformanceData | null
}
function PerformanceTab({ performanceData }: PerformanceTabProps): React.ReactElement {
const [period, setPeriod] = useState<string>('24h')
const [metrics, setMetrics] = useState<PerformanceData | null>(null)
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
useEffect(() => {
fetch(`/api/monitoring/performance?period=${period}`, { credentials: 'include' })
.then((r) => r.json())
.then((d) => setMetrics(d.data))
.catch((err) => console.error('[Monitoring] Failed to fetch performance data:', err))
fetch(`/api/monitoring/snapshots?period=${period}`, { credentials: 'include' })
.then((r) => r.json())
.then((d) => setSnapshots(d.data || []))
.catch((err) => console.error('[Monitoring] Failed to fetch snapshots:', err))
}, [period])
const data = performanceData || metrics
return (
<div className="monitoring__performance">
<div className="monitoring__period-selector">
{PERIOD_OPTIONS.map((p) => (
<button
key={p}
className={`monitoring__period-btn ${period === p ? 'monitoring__period-btn--active' : ''}`}
onClick={() => setPeriod(p)}
type="button"
>
{p}
</button>
))}
</div>
{data && (
<div className="monitoring__kpi-row">
<KpiCard value={`${data.avgResponseTimeMs}ms`} label="Avg Response" />
<KpiCard value={`${data.p95ResponseTimeMs}ms`} label="P95" />
<KpiCard value={`${data.p99ResponseTimeMs}ms`} label="P99" />
<KpiCard value={`${(data.errorRate * 100).toFixed(1)}%`} label="Error Rate" />
<KpiCard value={String(data.requestsPerMinute)} label="RPM" />
</div>
)}
<div className="monitoring__chart-row">
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: s.performance?.avgResponseTimeMs || 0 }))}
label="Avg Response Time"
unit="ms"
height={200}
/>
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: (s.performance?.errorRate || 0) * 100 }))}
label="Error Rate"
unit="%"
height={200}
/>
<TrendChart
data={snapshots.map((s) => ({ timestamp: s.timestamp, value: s.performance?.requestsPerMinute || 0 }))}
label="Requests/Min"
unit=""
height={200}
/>
</div>
</div>
)
}
interface AlertsTabProps {
newAlerts: AlertData[]
}
function AlertsTab({ newAlerts }: AlertsTabProps): React.ReactElement {
const [alerts, setAlerts] = useState<AlertData[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [filter, setFilter] = useState<string>('')
const [refreshCounter, setRefreshCounter] = useState(0)
useEffect(() => {
const params = new URLSearchParams({ page: String(page), limit: '20' })
if (filter) {
params.set('severity', filter)
}
fetch(`/api/monitoring/alerts?${params}`, { credentials: 'include' })
.then((r) => r.json())
.then((d) => {
setAlerts(d.data || [])
setTotalPages(d.totalPages || 1)
})
.catch((err) => console.error('[Monitoring] Failed to fetch alerts:', err))
}, [page, filter, refreshCounter])
const handleAcknowledge = useCallback(async (alertId: string) => {
try {
await fetch('/api/monitoring/alerts/acknowledge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alertId }),
})
setRefreshCounter((c) => c + 1)
} catch (err) {
console.error('[Monitoring] Failed to acknowledge alert:', err)
}
}, [])
const newAlertIds = new Set(newAlerts.map((a) => a.id))
const allAlerts = [...newAlerts, ...alerts.filter((a) => !newAlertIds.has(a.id))]
return (
<div className="monitoring__alerts">
<div className="monitoring__alerts-toolbar">
<select value={filter} onChange={(e) => setFilter(e.target.value)} className="monitoring__select">
<option value="">Alle Severity</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
<a href="/admin/collections/monitoring-alert-rules" className="monitoring__link">
Alert-Regeln verwalten
</a>
</div>
<table className="monitoring__table">
<thead>
<tr>
<th>Severity</th>
<th>Metric</th>
<th>Message</th>
<th>Zeitpunkt</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{allAlerts.map((alert, i) => (
<tr key={alert.id || i} className={!alert.acknowledgedBy ? 'monitoring__row--unack' : ''}>
<td>
<StatusBadge status={getAlertStatusColor(alert.severity)} />
</td>
<td>{alert.metric}</td>
<td>{alert.message}</td>
<td>{formatTimestamp(alert.createdAt)}</td>
<td>
{!alert.acknowledgedBy && (
<button
onClick={() => handleAcknowledge(alert.id)}
className="monitoring__btn monitoring__btn--sm"
type="button"
>
Bestätigen
</button>
)}
</td>
</tr>
))}
{allAlerts.length === 0 && (
<tr>
<td colSpan={5} className="monitoring__empty">
Keine Alerts vorhanden
</td>
</tr>
)}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)
}
interface LogsTabProps {
newLogs: LogData[]
}
function LogsTab({ newLogs }: LogsTabProps): React.ReactElement {
const [logs, setLogs] = useState<LogData[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [level, setLevel] = useState('')
const [source, setSource] = useState('')
const [search, setSearch] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
useEffect(() => {
const params = new URLSearchParams({ page: String(page), limit: '50' })
if (level) params.set('level', level)
if (source) params.set('source', source)
if (search) params.set('search', search)
fetch(`/api/monitoring/logs?${params}`, { credentials: 'include' })
.then((r) => r.json())
.then((d) => {
setLogs(d.data || [])
setTotalPages(d.totalPages || 1)
})
.catch((err) => console.error('[Monitoring] Failed to fetch logs:', err))
}, [page, level, source, search])
const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const newLogIds = new Set(newLogs.map((l) => l.id))
const allLogs = [...newLogs, ...logs.filter((l) => !newLogIds.has(l.id))]
return (
<div className="monitoring__logs">
<div className="monitoring__logs-toolbar">
<select value={level} onChange={(e) => setLevel(e.target.value)} className="monitoring__select">
<option value="">Alle Level</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
<select value={source} onChange={(e) => setSource(e.target.value)} className="monitoring__select">
<option value="">Alle Sources</option>
<option value="payload">Payload</option>
<option value="queue-worker">Queue Worker</option>
<option value="cron">Cron</option>
<option value="email">Email</option>
<option value="oauth">OAuth</option>
<option value="sync">Sync</option>
</select>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche..."
className="monitoring__input"
/>
</div>
<table className="monitoring__table">
<thead>
<tr>
<th>Level</th>
<th>Source</th>
<th>Message</th>
<th>Zeitpunkt</th>
</tr>
</thead>
<tbody>
{allLogs.map((log, i) => (
<React.Fragment key={log.id || i}>
<tr
onClick={() => log.context && toggleExpand(log.id)}
className={log.context ? 'monitoring__row--clickable' : ''}
>
<td>
<span style={{ color: LEVEL_COLORS[log.level] || '#6b7280', fontWeight: 'bold' }}>
{log.level?.toUpperCase()}
</span>
</td>
<td>
<span className="monitoring__badge">{log.source}</span>
</td>
<td>{log.message}</td>
<td>{formatTimestamp(log.createdAt)}</td>
</tr>
{expanded.has(log.id) && log.context && (
<tr>
<td colSpan={4}>
<pre className="monitoring__json">{JSON.stringify(log.context, null, 2)}</pre>
</td>
</tr>
)}
</React.Fragment>
))}
{allLogs.length === 0 && (
<tr>
<td colSpan={4} className="monitoring__empty">
Keine Logs vorhanden
</td>
</tr>
)}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)
}
// ============================================================================
// Shared UI Components
// ============================================================================
interface StatusBadgeProps {
status: string
}
function StatusBadge({ status }: StatusBadgeProps): React.ReactElement {
const backgroundColor = STATUS_COLORS[status] || '#6b7280'
return (
<span className="monitoring__status-badge" style={{ backgroundColor }}>
{status}
</span>
)
}
interface GaugeWidgetProps {
label: string
value: number
max: number
unit: string
thresholds: { warning: number; critical: number }
}
function GaugeWidget({ label, value, max, unit, thresholds }: GaugeWidgetProps): React.ReactElement {
const percent = Math.min((value / max) * 100, 100)
const color = getThresholdColor(value, thresholds)
return (
<div className="monitoring__gauge">
<div className="monitoring__gauge-label">{label}</div>
<div className="monitoring__gauge-bar">
<div className="monitoring__gauge-fill" style={{ width: `${percent}%`, backgroundColor: color }} />
</div>
<div className="monitoring__gauge-value">
{value}
{unit}
</div>
</div>
)
}
interface TrendChartProps {
data: TrendPoint[]
label: string
unit: string
height: number
}
function TrendChart({ data, label, unit, height }: TrendChartProps): React.ReactElement {
if (!data || data.length === 0) {
return (
<div className="monitoring__chart" style={{ height }}>
<div className="monitoring__chart-label">{label}</div>
<div className="monitoring__empty">Keine Daten</div>
</div>
)
}
const values = data.map((d) => d.value)
const minVal = Math.min(...values)
const maxVal = Math.max(...values)
const range = maxVal - minVal || 1
const width = 400
const padding = 10
const chartWidth = width - padding * 2
const chartHeight = height - 40
const points = data
.map((d, i) => {
const x = padding + (i / (data.length - 1 || 1)) * chartWidth
const y = padding + chartHeight - ((d.value - minVal) / range) * chartHeight
return `${x},${y}`
})
.join(' ')
const lastValue = values[values.length - 1]
return (
<div className="monitoring__chart" style={{ height }}>
<div className="monitoring__chart-header">
<span className="monitoring__chart-label">{label}</span>
<span className="monitoring__chart-value">
{lastValue?.toFixed(1)}
{unit}
</span>
</div>
<svg viewBox={`0 0 ${width} ${height - 30}`} className="monitoring__chart-svg">
<polyline fill="none" stroke="#3b82f6" strokeWidth="2" points={points} />
</svg>
</div>
)
}
interface KpiCardProps {
value: string
label: string
}
function KpiCard({ value, label }: KpiCardProps): React.ReactElement {
return (
<div className="monitoring__kpi">
<span className="monitoring__kpi-value">{value}</span>
<span className="monitoring__kpi-label">{label}</span>
</div>
)
}
interface PaginationProps {
page: number
totalPages: number
onPageChange: (page: number) => void
}
function Pagination({ page, totalPages, onPageChange }: PaginationProps): React.ReactElement | null {
if (totalPages <= 1) {
return null
}
return (
<div className="monitoring__pagination">
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} type="button">
Zurück
</button>
<span>
Seite {page} / {totalPages}
</span>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} type="button">
Weiter
</button>
</div>
)
}
// ============================================================================
// Utility Functions
// ============================================================================
function getThresholdColor(value: number, thresholds: { warning: number; critical: number }): string {
if (thresholds.critical > 0 && value >= thresholds.critical) {
return '#ef4444'
}
if (thresholds.warning > 0 && value >= thresholds.warning) {
return '#f59e0b'
}
return '#22c55e'
}
function getAlertStatusColor(severity: string): string {
if (severity === 'critical') {
return 'offline'
}
if (severity === 'error') {
return 'warning'
}
return 'online'
}
function formatTimestamp(timestamp?: string): string {
if (!timestamp) {
return ''
}
return new Date(timestamp).toLocaleString('de-DE')
}

View file

@ -0,0 +1,10 @@
'use client'
import React from 'react'
import { MonitoringDashboard } from './MonitoringDashboard'
export const MonitoringDashboardView: React.FC = () => {
return <MonitoringDashboard />
}
export default MonitoringDashboardView

View file

@ -0,0 +1,47 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
export const MonitoringNavLinks: React.FC = () => {
const [open, setOpen] = useState(true)
const links = [
{ href: '/admin/monitoring', label: 'Monitoring Dashboard' },
]
return (
<div className="nav-group">
<button
className={`nav-group__toggle ${open ? 'nav-group__toggle--open' : ''}`}
type="button"
onClick={() => setOpen(!open)}
>
<div className="nav-group__label">Monitoring</div>
<div className="nav-group__indicator">
<svg
className="icon icon--chevron nav-group__indicator"
height="100%"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
viewBox="0 0 20 20"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<path className="stroke" d="M14 8L10 12L6 8" strokeLinecap="square" />
</svg>
</div>
</button>
{open && (
<div className="nav-group__content">
{links.map((link) => (
<Link key={link.href} href={link.href} className="nav__link">
<span className="nav__link-label">{link.label}</span>
</Link>
))}
</div>
)}
</div>
)
}
export default MonitoringNavLinks

View file

@ -137,6 +137,7 @@ export default buildConfig({
afterNavLinks: [ afterNavLinks: [
'@/components/admin/CommunityNavLinks#CommunityNavLinks', '@/components/admin/CommunityNavLinks#CommunityNavLinks',
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks', '@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
'@/components/admin/MonitoringNavLinks#MonitoringNavLinks',
], ],
views: { views: {
TenantDashboard: { TenantDashboard: {
@ -151,6 +152,10 @@ export default buildConfig({
Component: '@/components/admin/ContentCalendarView#ContentCalendarView', Component: '@/components/admin/ContentCalendarView#ContentCalendarView',
path: '/content-calendar', path: '/content-calendar',
}, },
MonitoringDashboard: {
Component: '@/components/admin/MonitoringDashboardView#MonitoringDashboardView',
path: '/monitoring',
},
}, },
}, },
}, },