diff --git a/src/components/admin/MonitoringDashboard.scss b/src/components/admin/MonitoringDashboard.scss new file mode 100644 index 0000000..6df3114 --- /dev/null +++ b/src/components/admin/MonitoringDashboard.scss @@ -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; + } + } + } +} diff --git a/src/components/admin/MonitoringDashboard.tsx b/src/components/admin/MonitoringDashboard.tsx new file mode 100644 index 0000000..c66482c --- /dev/null +++ b/src/components/admin/MonitoringDashboard.tsx @@ -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 +} + +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 = { + debug: '#6b7280', + info: '#3b82f6', + warn: '#f59e0b', + error: '#ef4444', + fatal: '#dc2626', +} + +const STATUS_COLORS: Record = { + 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('health') + const [connected, setConnected] = useState(false) + const eventSourceRef = useRef(null) + + const [healthData, setHealthData] = useState(null) + const [performanceData, setPerformanceData] = useState(null) + const [newAlerts, setNewAlerts] = useState([]) + const [newLogs, setNewLogs] = useState([]) + + 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 ( +
+
+

Monitoring Dashboard

+
+ + {connected ? 'Live' : 'Disconnected'} +
+
+ +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {activeTab === 'health' && } + {activeTab === 'services' && } + {activeTab === 'performance' && } + {activeTab === 'alerts' && } + {activeTab === 'logs' && } +
+
+ ) +} + +// ============================================================================ +// Tab Components +// ============================================================================ + +interface SystemHealthTabProps { + healthData: HealthData | null +} + +function SystemHealthTab({ healthData }: SystemHealthTabProps): React.ReactElement { + const [initialData, setInitialData] = useState(null) + const [snapshots, setSnapshots] = useState([]) + + 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
Loading...
+ } + + const uptimeHours = Math.round(data.uptime / 3600) + + return ( +
+ + + + + +
+ ({ timestamp: s.timestamp, value: s.system?.cpuUsagePercent || 0 }))} + label="CPU (24h)" + unit="%" + height={200} + /> + ({ timestamp: s.timestamp, value: s.system?.memoryUsagePercent || 0 }))} + label="Memory (24h)" + unit="%" + height={200} + /> + ({ timestamp: s.timestamp, value: s.system?.loadAvg1 || 0 }))} + label="Load (24h)" + unit="" + height={200} + /> +
+
+ ) +} + +function ServicesTab(): React.ReactElement { + const [services, setServices] = useState(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
Loading...
+ } + + 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 ( +
+ {coreServices.map((s) => ( +
+
+ + {s.name} +
+
{JSON.stringify(s.data, null, 2)}
+
+ ))} + + {services.oauth && ( + <> + + + + )} +
+ ) +} + +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 ( +
+
+ + {name} +
+
{JSON.stringify(data, null, 2)}
+
+ ) +} + +interface PerformanceTabProps { + performanceData: PerformanceData | null +} + +function PerformanceTab({ performanceData }: PerformanceTabProps): React.ReactElement { + const [period, setPeriod] = useState('24h') + const [metrics, setMetrics] = useState(null) + const [snapshots, setSnapshots] = useState([]) + + 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 ( +
+
+ {PERIOD_OPTIONS.map((p) => ( + + ))} +
+ + {data && ( +
+ + + + + +
+ )} + +
+ ({ timestamp: s.timestamp, value: s.performance?.avgResponseTimeMs || 0 }))} + label="Avg Response Time" + unit="ms" + height={200} + /> + ({ timestamp: s.timestamp, value: (s.performance?.errorRate || 0) * 100 }))} + label="Error Rate" + unit="%" + height={200} + /> + ({ timestamp: s.timestamp, value: s.performance?.requestsPerMinute || 0 }))} + label="Requests/Min" + unit="" + height={200} + /> +
+
+ ) +} + +interface AlertsTabProps { + newAlerts: AlertData[] +} + +function AlertsTab({ newAlerts }: AlertsTabProps): React.ReactElement { + const [alerts, setAlerts] = useState([]) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [filter, setFilter] = useState('') + 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 ( +
+
+ + + Alert-Regeln verwalten + +
+ + + + + + + + + + + + + {allAlerts.map((alert, i) => ( + + + + + + + + ))} + {allAlerts.length === 0 && ( + + + + )} + +
SeverityMetricMessageZeitpunktAktion
+ + {alert.metric}{alert.message}{formatTimestamp(alert.createdAt)} + {!alert.acknowledgedBy && ( + + )} +
+ Keine Alerts vorhanden +
+ + +
+ ) +} + +interface LogsTabProps { + newLogs: LogData[] +} + +function LogsTab({ newLogs }: LogsTabProps): React.ReactElement { + const [logs, setLogs] = useState([]) + 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>(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 ( +
+
+ + + setSearch(e.target.value)} + placeholder="Suche..." + className="monitoring__input" + /> +
+ + + + + + + + + + + + {allLogs.map((log, i) => ( + + log.context && toggleExpand(log.id)} + className={log.context ? 'monitoring__row--clickable' : ''} + > + + + + + + {expanded.has(log.id) && log.context && ( + + + + )} + + ))} + {allLogs.length === 0 && ( + + + + )} + +
LevelSourceMessageZeitpunkt
+ + {log.level?.toUpperCase()} + + + {log.source} + {log.message}{formatTimestamp(log.createdAt)}
+
{JSON.stringify(log.context, null, 2)}
+
+ Keine Logs vorhanden +
+ + +
+ ) +} + +// ============================================================================ +// Shared UI Components +// ============================================================================ + +interface StatusBadgeProps { + status: string +} + +function StatusBadge({ status }: StatusBadgeProps): React.ReactElement { + const backgroundColor = STATUS_COLORS[status] || '#6b7280' + + return ( + + {status} + + ) +} + +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 ( +
+
{label}
+
+
+
+
+ {value} + {unit} +
+
+ ) +} + +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 ( +
+
{label}
+
Keine Daten
+
+ ) + } + + 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 ( +
+
+ {label} + + {lastValue?.toFixed(1)} + {unit} + +
+ + + +
+ ) +} + +interface KpiCardProps { + value: string + label: string +} + +function KpiCard({ value, label }: KpiCardProps): React.ReactElement { + return ( +
+ {value} + {label} +
+ ) +} + +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 ( +
+ + + Seite {page} / {totalPages} + + +
+ ) +} + +// ============================================================================ +// 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') +} diff --git a/src/components/admin/MonitoringDashboardView.tsx b/src/components/admin/MonitoringDashboardView.tsx new file mode 100644 index 0000000..c370fce --- /dev/null +++ b/src/components/admin/MonitoringDashboardView.tsx @@ -0,0 +1,10 @@ +'use client' + +import React from 'react' +import { MonitoringDashboard } from './MonitoringDashboard' + +export const MonitoringDashboardView: React.FC = () => { + return +} + +export default MonitoringDashboardView diff --git a/src/components/admin/MonitoringNavLinks.tsx b/src/components/admin/MonitoringNavLinks.tsx new file mode 100644 index 0000000..5e0dd0a --- /dev/null +++ b/src/components/admin/MonitoringNavLinks.tsx @@ -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 ( +
+ + {open && ( +
+ {links.map((link) => ( + + {link.label} + + ))} +
+ )} +
+ ) +} + +export default MonitoringNavLinks diff --git a/src/payload.config.ts b/src/payload.config.ts index 8f43277..997d7cd 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -137,6 +137,7 @@ export default buildConfig({ afterNavLinks: [ '@/components/admin/CommunityNavLinks#CommunityNavLinks', '@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks', + '@/components/admin/MonitoringNavLinks#MonitoringNavLinks', ], views: { TenantDashboard: { @@ -151,6 +152,10 @@ export default buildConfig({ Component: '@/components/admin/ContentCalendarView#ContentCalendarView', path: '/content-calendar', }, + MonitoringDashboard: { + Component: '@/components/admin/MonitoringDashboardView#MonitoringDashboardView', + path: '/monitoring', + }, }, }, },