mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
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:
parent
5f38136b95
commit
dd73162035
5 changed files with 1280 additions and 0 deletions
402
src/components/admin/MonitoringDashboard.scss
Normal file
402
src/components/admin/MonitoringDashboard.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
816
src/components/admin/MonitoringDashboard.tsx
Normal file
816
src/components/admin/MonitoringDashboard.tsx
Normal 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')
|
||||||
|
}
|
||||||
10
src/components/admin/MonitoringDashboardView.tsx
Normal file
10
src/components/admin/MonitoringDashboardView.tsx
Normal 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
|
||||||
47
src/components/admin/MonitoringNavLinks.tsx
Normal file
47
src/components/admin/MonitoringNavLinks.tsx
Normal 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
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue