feat(monitoring): complete monitoring pipeline and fix presentation flag persistence
add superadmin monitoring dashboard with protected route, menu entry, and monitoring data client add monitoring overview API endpoint and improve log serialization/aggregation for dashboard use extend listener health/log handling with robust status/event/timestamp normalization and screenshot payload extraction improve screenshot persistence and retrieval (timestamp-aware uploads, latest screenshot endpoint fallback) fix page_progress and auto_progress persistence/serialization across create, update, and detached occurrence flows align technical and project docs to reflect implemented monitoring and no-version-bump backend changes add documentation sync log entry and include minor compose env indentation cleanup
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { SidebarComponent } from '@syncfusion/ej2-react-navigations';
|
||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import { DropDownButtonComponent } from '@syncfusion/ej2-react-splitbuttons';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Settings,
|
||||
Monitor,
|
||||
MonitorDotIcon,
|
||||
Activity,
|
||||
LogOut,
|
||||
Wrench,
|
||||
Info,
|
||||
@@ -31,6 +32,7 @@ const sidebarItems = [
|
||||
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes, minRole: 'editor' },
|
||||
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon, minRole: 'admin' },
|
||||
{ name: 'Infoscreen-Clients', path: '/clients', icon: Monitor, minRole: 'admin' },
|
||||
{ name: 'Monitor-Dashboard', path: '/monitoring', icon: Activity, minRole: 'superadmin' },
|
||||
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench, minRole: 'admin' },
|
||||
{ name: 'Medien', path: '/medien', icon: Image, minRole: 'editor' },
|
||||
{ name: 'Benutzer', path: '/benutzer', icon: User, minRole: 'admin' },
|
||||
@@ -49,6 +51,7 @@ import Benutzer from './users';
|
||||
import Einstellungen from './settings';
|
||||
import SetupMode from './SetupMode';
|
||||
import Programminfo from './programminfo';
|
||||
import MonitoringDashboard from './monitoring';
|
||||
import Logout from './logout';
|
||||
import Login from './login';
|
||||
import { useAuth } from './useAuth';
|
||||
@@ -480,6 +483,14 @@ const App: React.FC = () => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const RequireSuperadmin: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, loading, user } = useAuth();
|
||||
if (loading) return <div style={{ padding: 24 }}>Lade ...</div>;
|
||||
if (!isAuthenticated) return <Login />;
|
||||
if (user?.role !== 'superadmin') return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
@@ -499,6 +510,14 @@ const App: React.FC = () => {
|
||||
<Route path="benutzer" element={<Benutzer />} />
|
||||
<Route path="einstellungen" element={<Einstellungen />} />
|
||||
<Route path="clients" element={<Infoscreens />} />
|
||||
<Route
|
||||
path="monitoring"
|
||||
element={
|
||||
<RequireSuperadmin>
|
||||
<MonitoringDashboard />
|
||||
</RequireSuperadmin>
|
||||
}
|
||||
/>
|
||||
<Route path="setup" element={<SetupMode />} />
|
||||
<Route path="programminfo" element={<Programminfo />} />
|
||||
</Route>
|
||||
|
||||
106
dashboard/src/apiClientMonitoring.ts
Normal file
106
dashboard/src/apiClientMonitoring.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export interface MonitoringLogEntry {
|
||||
id: number;
|
||||
timestamp: string | null;
|
||||
level: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | null;
|
||||
message: string;
|
||||
context: Record<string, unknown>;
|
||||
client_uuid?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringClient {
|
||||
uuid: string;
|
||||
hostname?: string | null;
|
||||
description?: string | null;
|
||||
ip?: string | null;
|
||||
model?: string | null;
|
||||
groupId?: number | null;
|
||||
groupName?: string | null;
|
||||
registrationTime?: string | null;
|
||||
lastAlive?: string | null;
|
||||
isAlive: boolean;
|
||||
status: 'healthy' | 'warning' | 'critical' | 'offline';
|
||||
currentEventId?: number | null;
|
||||
currentProcess?: string | null;
|
||||
processStatus?: string | null;
|
||||
processPid?: number | null;
|
||||
screenHealthStatus?: string | null;
|
||||
lastScreenshotAnalyzed?: string | null;
|
||||
lastScreenshotHash?: string | null;
|
||||
screenshotUrl: string;
|
||||
logCounts24h: {
|
||||
error: number;
|
||||
warn: number;
|
||||
info: number;
|
||||
debug: number;
|
||||
};
|
||||
latestLog?: MonitoringLogEntry | null;
|
||||
latestError?: MonitoringLogEntry | null;
|
||||
}
|
||||
|
||||
export interface MonitoringOverview {
|
||||
summary: {
|
||||
totalClients: number;
|
||||
onlineClients: number;
|
||||
offlineClients: number;
|
||||
healthyClients: number;
|
||||
warningClients: number;
|
||||
criticalClients: number;
|
||||
errorLogs: number;
|
||||
warnLogs: number;
|
||||
};
|
||||
periodHours: number;
|
||||
gracePeriodSeconds: number;
|
||||
since: string;
|
||||
timestamp: string;
|
||||
clients: MonitoringClient[];
|
||||
}
|
||||
|
||||
export interface ClientLogsResponse {
|
||||
client_uuid: string;
|
||||
logs: MonitoringLogEntry[];
|
||||
count: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || fallbackMessage);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function fetchMonitoringOverview(hours = 24): Promise<MonitoringOverview> {
|
||||
const response = await fetch(`/api/client-logs/monitoring-overview?hours=${hours}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return parseJsonResponse<MonitoringOverview>(response, 'Fehler beim Laden der Monitoring-Übersicht');
|
||||
}
|
||||
|
||||
export async function fetchRecentClientErrors(limit = 20): Promise<MonitoringLogEntry[]> {
|
||||
const response = await fetch(`/api/client-logs/recent-errors?limit=${limit}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await parseJsonResponse<{ errors: MonitoringLogEntry[] }>(
|
||||
response,
|
||||
'Fehler beim Laden der letzten Fehler'
|
||||
);
|
||||
return data.errors;
|
||||
}
|
||||
|
||||
export async function fetchClientMonitoringLogs(
|
||||
uuid: string,
|
||||
options: { level?: string; limit?: number } = {}
|
||||
): Promise<MonitoringLogEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.level && options.level !== 'ALL') {
|
||||
params.set('level', options.level);
|
||||
}
|
||||
params.set('limit', String(options.limit ?? 100));
|
||||
|
||||
const response = await fetch(`/api/client-logs/${uuid}/logs?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await parseJsonResponse<ClientLogsResponse>(response, 'Fehler beim Laden der Client-Logs');
|
||||
return data.logs;
|
||||
}
|
||||
347
dashboard/src/monitoring.css
Normal file
347
dashboard/src/monitoring.css
Normal file
@@ -0,0 +1,347 @@
|
||||
.monitoring-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.monitoring-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.monitoring-title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #5c4318;
|
||||
}
|
||||
|
||||
.monitoring-subtitle {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #6b7280;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.monitoring-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.monitoring-toolbar-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.monitoring-toolbar-field-compact {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.monitoring-toolbar-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #5b4b32;
|
||||
}
|
||||
|
||||
.monitoring-meta-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
color: #6b7280;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.monitoring-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.monitoring-metric-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.monitoring-metric-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.monitoring-metric-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.monitoring-metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.monitoring-metric-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.monitoring-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.monitoring-sidebar-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.monitoring-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: 0 12px 40px rgb(120 89 28 / 8%);
|
||||
}
|
||||
|
||||
.monitoring-clients-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.monitoring-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.monitoring-panel-header-stacked {
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.monitoring-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.monitoring-panel-header span {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monitoring-detail-card .e-card-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.monitoring-detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.monitoring-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.monitoring-detail-row span {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monitoring-detail-row strong {
|
||||
text-align: right;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.monitoring-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.22rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.monitoring-screenshot {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
|
||||
min-height: 180px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.monitoring-screenshot-meta {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.88rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.monitoring-error-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff1f2, #fee2e2);
|
||||
border: 1px solid #fecdd3;
|
||||
}
|
||||
|
||||
.monitoring-error-time {
|
||||
color: #9f1239;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.monitoring-error-message {
|
||||
color: #4c0519;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.monitoring-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.monitoring-log-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.monitoring-log-detail-row span {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monitoring-log-detail-row strong {
|
||||
text-align: right;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.monitoring-log-context {
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.84rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem 0.55rem;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-body {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-actions {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 1rem 0.9rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.monitoring-log-context-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-content .monitoring-log-detail-row {
|
||||
padding: 0.1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-content .monitoring-log-context {
|
||||
padding: 0.95rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.monitoring-lower-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.monitoring-main-grid,
|
||||
.monitoring-lower-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
.monitoring-page {
|
||||
padding: 0.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.monitoring-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.monitoring-header-row,
|
||||
.monitoring-panel-header,
|
||||
.monitoring-detail-row,
|
||||
.monitoring-log-detail-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.monitoring-detail-row strong,
|
||||
.monitoring-log-detail-row strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.monitoring-toolbar,
|
||||
.monitoring-toolbar-field,
|
||||
.monitoring-toolbar-field-compact {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-content {
|
||||
padding: 0.4rem 0.2rem 0.1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-body {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.monitoring-log-dialog-actions {
|
||||
padding: 0 0.2rem 0.4rem;
|
||||
}
|
||||
}
|
||||
534
dashboard/src/monitoring.tsx
Normal file
534
dashboard/src/monitoring.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fetchClientMonitoringLogs,
|
||||
fetchMonitoringOverview,
|
||||
fetchRecentClientErrors,
|
||||
type MonitoringClient,
|
||||
type MonitoringLogEntry,
|
||||
type MonitoringOverview,
|
||||
} from './apiClientMonitoring';
|
||||
import { useAuth } from './useAuth';
|
||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
||||
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
|
||||
import {
|
||||
GridComponent,
|
||||
ColumnsDirective,
|
||||
ColumnDirective,
|
||||
Inject,
|
||||
Page,
|
||||
Search,
|
||||
Sort,
|
||||
Toolbar,
|
||||
} from '@syncfusion/ej2-react-grids';
|
||||
import { MessageComponent } from '@syncfusion/ej2-react-notifications';
|
||||
import { DialogComponent } from '@syncfusion/ej2-react-popups';
|
||||
import './monitoring.css';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
const hourOptions = [
|
||||
{ text: 'Letzte 6 Stunden', value: 6 },
|
||||
{ text: 'Letzte 24 Stunden', value: 24 },
|
||||
{ text: 'Letzte 72 Stunden', value: 72 },
|
||||
{ text: 'Letzte 168 Stunden', value: 168 },
|
||||
];
|
||||
|
||||
const logLevelOptions = [
|
||||
{ text: 'Alle Logs', value: 'ALL' },
|
||||
{ text: 'ERROR', value: 'ERROR' },
|
||||
{ text: 'WARN', value: 'WARN' },
|
||||
{ text: 'INFO', value: 'INFO' },
|
||||
{ text: 'DEBUG', value: 'DEBUG' },
|
||||
];
|
||||
|
||||
const statusPalette: Record<string, { label: string; color: string; background: string }> = {
|
||||
healthy: { label: 'Stabil', color: '#166534', background: '#dcfce7' },
|
||||
warning: { label: 'Warnung', color: '#92400e', background: '#fef3c7' },
|
||||
critical: { label: 'Kritisch', color: '#991b1b', background: '#fee2e2' },
|
||||
offline: { label: 'Offline', color: '#334155', background: '#e2e8f0' },
|
||||
};
|
||||
|
||||
function parseUtcDate(value?: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const hasTimezone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(trimmed);
|
||||
const utcValue = hasTimezone ? trimmed : `${trimmed}Z`;
|
||||
const parsed = new Date(utcValue);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: string | null): string {
|
||||
if (!value) return 'Keine Daten';
|
||||
const date = parseUtcDate(value);
|
||||
if (!date) return value;
|
||||
return date.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function formatRelative(value?: string | null): string {
|
||||
if (!value) return 'Keine Daten';
|
||||
const date = parseUtcDate(value);
|
||||
if (!date) return 'Unbekannt';
|
||||
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) return 'gerade eben';
|
||||
if (diffMinutes < 60) return `vor ${diffMinutes} Min.`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std.`;
|
||||
return `vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const palette = statusPalette[status] || statusPalette.offline;
|
||||
return (
|
||||
<span
|
||||
className="monitoring-status-badge"
|
||||
style={{ color: palette.color, backgroundColor: palette.background }}
|
||||
>
|
||||
{palette.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMetricCard(title: string, value: number, subtitle: string, accent: string) {
|
||||
return (
|
||||
<div className="e-card monitoring-metric-card" style={{ borderTop: `4px solid ${accent}` }}>
|
||||
<div className="e-card-content monitoring-metric-content">
|
||||
<div className="monitoring-metric-title">{title}</div>
|
||||
<div className="monitoring-metric-value">{value}</div>
|
||||
<div className="monitoring-metric-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderContext(context?: Record<string, unknown>): string {
|
||||
if (!context || Object.keys(context).length === 0) {
|
||||
return 'Kein Kontext vorhanden';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(context, null, 2);
|
||||
} catch {
|
||||
return 'Kontext konnte nicht formatiert werden';
|
||||
}
|
||||
}
|
||||
|
||||
function buildScreenshotUrl(client: MonitoringClient, overviewTimestamp?: string | null): string {
|
||||
const refreshKey = client.lastScreenshotHash || client.lastScreenshotAnalyzed || overviewTimestamp;
|
||||
if (!refreshKey) {
|
||||
return client.screenshotUrl;
|
||||
}
|
||||
|
||||
const separator = client.screenshotUrl.includes('?') ? '&' : '?';
|
||||
return `${client.screenshotUrl}${separator}v=${encodeURIComponent(refreshKey)}`;
|
||||
}
|
||||
|
||||
const MonitoringDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [hours, setHours] = React.useState<number>(24);
|
||||
const [logLevel, setLogLevel] = React.useState<string>('ALL');
|
||||
const [overview, setOverview] = React.useState<MonitoringOverview | null>(null);
|
||||
const [recentErrors, setRecentErrors] = React.useState<MonitoringLogEntry[]>([]);
|
||||
const [clientLogs, setClientLogs] = React.useState<MonitoringLogEntry[]>([]);
|
||||
const [selectedClientUuid, setSelectedClientUuid] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [logsLoading, setLogsLoading] = React.useState<boolean>(false);
|
||||
const [screenshotErrored, setScreenshotErrored] = React.useState<boolean>(false);
|
||||
const selectedClientUuidRef = React.useRef<string | null>(null);
|
||||
const [selectedLogEntry, setSelectedLogEntry] = React.useState<MonitoringLogEntry | null>(null);
|
||||
|
||||
const selectedClient = React.useMemo<MonitoringClient | null>(() => {
|
||||
if (!overview || !selectedClientUuid) return null;
|
||||
return overview.clients.find(client => client.uuid === selectedClientUuid) || null;
|
||||
}, [overview, selectedClientUuid]);
|
||||
|
||||
const selectedClientScreenshotUrl = React.useMemo<string | null>(() => {
|
||||
if (!selectedClient) return null;
|
||||
return buildScreenshotUrl(selectedClient, overview?.timestamp || null);
|
||||
}, [selectedClient, overview?.timestamp]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedClientUuidRef.current = selectedClientUuid;
|
||||
}, [selectedClientUuid]);
|
||||
|
||||
const loadOverview = React.useCallback(async (requestedHours: number, preserveSelection = true) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [overviewData, errorsData] = await Promise.all([
|
||||
fetchMonitoringOverview(requestedHours),
|
||||
fetchRecentClientErrors(25),
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setRecentErrors(errorsData);
|
||||
|
||||
const currentSelection = selectedClientUuidRef.current;
|
||||
const nextSelectedUuid =
|
||||
preserveSelection && currentSelection && overviewData.clients.some(client => client.uuid === currentSelection)
|
||||
? currentSelection
|
||||
: overviewData.clients[0]?.uuid || null;
|
||||
|
||||
setSelectedClientUuid(nextSelectedUuid);
|
||||
setScreenshotErrored(false);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : 'Monitoring-Daten konnten nicht geladen werden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadOverview(hours, false);
|
||||
}, [hours, loadOverview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
loadOverview(hours);
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hours, loadOverview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedClientUuid) {
|
||||
setClientLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const loadLogs = async () => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const logs = await fetchClientMonitoringLogs(selectedClientUuid, { level: logLevel, limit: 100 });
|
||||
if (active) {
|
||||
setClientLogs(logs);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setClientLogs([]);
|
||||
setError(loadError instanceof Error ? loadError.message : 'Client-Logs konnten nicht geladen werden');
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadLogs();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedClientUuid, logLevel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setScreenshotErrored(false);
|
||||
}, [selectedClientUuid]);
|
||||
|
||||
if (!user || user.role !== 'superadmin') {
|
||||
return (
|
||||
<MessageComponent severity="Error" content="Dieses Monitoring-Dashboard ist nur für Superadministratoren sichtbar." />
|
||||
);
|
||||
}
|
||||
|
||||
const clientGridData = (overview?.clients || []).map(client => ({
|
||||
...client,
|
||||
displayName: client.description || client.hostname || client.uuid,
|
||||
lastAliveDisplay: formatTimestamp(client.lastAlive),
|
||||
currentProcessDisplay: client.currentProcess || 'kein Prozess',
|
||||
processStatusDisplay: client.processStatus || 'unbekannt',
|
||||
errorCount: client.logCounts24h.error,
|
||||
warnCount: client.logCounts24h.warn,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="monitoring-page">
|
||||
<div className="monitoring-header-row">
|
||||
<div>
|
||||
<h2 className="monitoring-title">Monitor-Dashboard</h2>
|
||||
<p className="monitoring-subtitle">
|
||||
Live-Zustand der Infoscreen-Clients, Prozessstatus und zentrale Fehlerprotokolle.
|
||||
</p>
|
||||
</div>
|
||||
<div className="monitoring-toolbar">
|
||||
<div className="monitoring-toolbar-field">
|
||||
<label>Zeitraum</label>
|
||||
<DropDownListComponent
|
||||
dataSource={hourOptions}
|
||||
fields={{ text: 'text', value: 'value' }}
|
||||
value={hours}
|
||||
change={(args: { value: number }) => setHours(Number(args.value))}
|
||||
/>
|
||||
</div>
|
||||
<ButtonComponent cssClass="e-primary" onClick={() => loadOverview(hours)} disabled={loading}>
|
||||
Aktualisieren
|
||||
</ButtonComponent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <MessageComponent severity="Error" content={error} />}
|
||||
|
||||
{overview && (
|
||||
<div className="monitoring-meta-row">
|
||||
<span>Stand: {formatTimestamp(overview.timestamp)}</span>
|
||||
<span>Alive-Fenster: {overview.gracePeriodSeconds} Sekunden</span>
|
||||
<span>Betrachtungszeitraum: {overview.periodHours} Stunden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="monitoring-summary-grid">
|
||||
{renderMetricCard('Clients gesamt', overview?.summary.totalClients || 0, 'Registrierte Displays', '#7c3aed')}
|
||||
{renderMetricCard('Online', overview?.summary.onlineClients || 0, 'Heartbeat innerhalb der Grace-Periode', '#15803d')}
|
||||
{renderMetricCard('Warnungen', overview?.summary.warningClients || 0, 'Warn-Logs oder Übergangszustände', '#d97706')}
|
||||
{renderMetricCard('Kritisch', overview?.summary.criticalClients || 0, 'Crashs oder Fehler-Logs', '#dc2626')}
|
||||
{renderMetricCard('Offline', overview?.summary.offlineClients || 0, 'Keine frischen Signale', '#475569')}
|
||||
{renderMetricCard('Fehler-Logs', overview?.summary.errorLogs || 0, 'Im gewählten Zeitraum', '#b91c1c')}
|
||||
</div>
|
||||
|
||||
{loading && !overview ? (
|
||||
<MessageComponent severity="Info" content="Monitoring-Daten werden geladen ..." />
|
||||
) : (
|
||||
<div className="monitoring-main-grid">
|
||||
<div className="monitoring-panel monitoring-clients-panel">
|
||||
<div className="monitoring-panel-header">
|
||||
<h3>Client-Zustand</h3>
|
||||
<span>{overview?.clients.length || 0} Einträge</span>
|
||||
</div>
|
||||
<GridComponent
|
||||
dataSource={clientGridData}
|
||||
allowPaging={true}
|
||||
pageSettings={{ pageSize: 10 }}
|
||||
allowSorting={true}
|
||||
toolbar={['Search']}
|
||||
height={460}
|
||||
rowSelected={(args: { data: MonitoringClient }) => {
|
||||
setSelectedClientUuid(args.data.uuid);
|
||||
}}
|
||||
>
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective
|
||||
field="status"
|
||||
headerText="Status"
|
||||
width="120"
|
||||
template={(props: MonitoringClient) => statusBadge(props.status)}
|
||||
/>
|
||||
<ColumnDirective field="displayName" headerText="Client" width="190" />
|
||||
<ColumnDirective field="groupName" headerText="Gruppe" width="150" />
|
||||
<ColumnDirective field="currentProcessDisplay" headerText="Prozess" width="130" />
|
||||
<ColumnDirective field="processStatusDisplay" headerText="Prozessstatus" width="130" />
|
||||
<ColumnDirective field="errorCount" headerText="ERROR" textAlign="Right" width="90" />
|
||||
<ColumnDirective field="warnCount" headerText="WARN" textAlign="Right" width="90" />
|
||||
<ColumnDirective field="lastAliveDisplay" headerText="Letztes Signal" width="170" />
|
||||
</ColumnsDirective>
|
||||
<Inject services={[Page, Search, Sort, Toolbar]} />
|
||||
</GridComponent>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-sidebar-column">
|
||||
<div className="e-card monitoring-detail-card">
|
||||
<div className="e-card-header">
|
||||
<div className="e-card-header-caption">
|
||||
<div className="e-card-title">Aktiver Client</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e-card-content">
|
||||
{selectedClient ? (
|
||||
<div className="monitoring-detail-list">
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Name</span>
|
||||
<strong>{selectedClient.description || selectedClient.hostname || selectedClient.uuid}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Status</span>
|
||||
<strong>{statusBadge(selectedClient.status)}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>UUID</span>
|
||||
<strong className="monitoring-mono">{selectedClient.uuid}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Raumgruppe</span>
|
||||
<strong>{selectedClient.groupName || 'Nicht zugeordnet'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Prozess</span>
|
||||
<strong>{selectedClient.currentProcess || 'kein Prozess'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>PID</span>
|
||||
<strong>{selectedClient.processPid || 'keine PID'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Event-ID</span>
|
||||
<strong>{selectedClient.currentEventId || 'keine Zuordnung'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Letztes Signal</span>
|
||||
<strong>{formatRelative(selectedClient.lastAlive)}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Bildschirmstatus</span>
|
||||
<strong>{selectedClient.screenHealthStatus || 'UNKNOWN'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-detail-row">
|
||||
<span>Letzte Analyse</span>
|
||||
<strong>{formatTimestamp(selectedClient.lastScreenshotAnalyzed)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MessageComponent severity="Info" content="Wählen Sie links einen Client aus." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="e-card monitoring-detail-card">
|
||||
<div className="e-card-header">
|
||||
<div className="e-card-header-caption">
|
||||
<div className="e-card-title">Der letzte Screenshot</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e-card-content">
|
||||
{selectedClient ? (
|
||||
<>
|
||||
{screenshotErrored ? (
|
||||
<MessageComponent severity="Warning" content="Für diesen Client liegt noch kein Screenshot vor." />
|
||||
) : (
|
||||
<img
|
||||
src={selectedClientScreenshotUrl || selectedClient.screenshotUrl}
|
||||
alt={`Screenshot ${selectedClient.uuid}`}
|
||||
className="monitoring-screenshot"
|
||||
onError={() => setScreenshotErrored(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="monitoring-screenshot-meta">
|
||||
Empfangen: {formatTimestamp(selectedClient.lastScreenshotAnalyzed)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<MessageComponent severity="Info" content="Kein Client ausgewählt." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="e-card monitoring-detail-card">
|
||||
<div className="e-card-header">
|
||||
<div className="e-card-header-caption">
|
||||
<div className="e-card-title">Letzter Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e-card-content">
|
||||
{selectedClient?.latestError ? (
|
||||
<div className="monitoring-error-box">
|
||||
<div className="monitoring-error-time">{formatTimestamp(selectedClient.latestError.timestamp)}</div>
|
||||
<div className="monitoring-error-message">{selectedClient.latestError.message}</div>
|
||||
</div>
|
||||
) : (
|
||||
<MessageComponent severity="Success" content="Kein ERROR-Log für den ausgewählten Client gefunden." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="monitoring-lower-grid">
|
||||
<div className="monitoring-panel">
|
||||
<div className="monitoring-panel-header monitoring-panel-header-stacked">
|
||||
<div>
|
||||
<h3>Client-Logs</h3>
|
||||
<span>{selectedClient ? `Client ${selectedClient.uuid}` : 'Kein Client ausgewählt'}</span>
|
||||
</div>
|
||||
<div className="monitoring-toolbar-field monitoring-toolbar-field-compact">
|
||||
<label>Level</label>
|
||||
<DropDownListComponent
|
||||
dataSource={logLevelOptions}
|
||||
fields={{ text: 'text', value: 'value' }}
|
||||
value={logLevel}
|
||||
change={(args: { value: string }) => setLogLevel(String(args.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{logsLoading && <MessageComponent severity="Info" content="Client-Logs werden geladen ..." />}
|
||||
<GridComponent
|
||||
dataSource={clientLogs}
|
||||
allowPaging={true}
|
||||
pageSettings={{ pageSize: 8 }}
|
||||
allowSorting={true}
|
||||
height={320}
|
||||
rowSelected={(args: { data: MonitoringLogEntry }) => {
|
||||
setSelectedLogEntry(args.data);
|
||||
}}
|
||||
>
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective field="timestamp" headerText="Zeit" width="180" template={(props: MonitoringLogEntry) => formatTimestamp(props.timestamp)} />
|
||||
<ColumnDirective field="level" headerText="Level" width="90" />
|
||||
<ColumnDirective field="message" headerText="Nachricht" width="360" />
|
||||
</ColumnsDirective>
|
||||
<Inject services={[Page, Sort]} />
|
||||
</GridComponent>
|
||||
</div>
|
||||
|
||||
<div className="monitoring-panel">
|
||||
<div className="monitoring-panel-header">
|
||||
<h3>Letzte Fehler systemweit</h3>
|
||||
<span>{recentErrors.length} Einträge</span>
|
||||
</div>
|
||||
<GridComponent dataSource={recentErrors} allowPaging={true} pageSettings={{ pageSize: 8 }} allowSorting={true} height={320}>
|
||||
<ColumnsDirective>
|
||||
<ColumnDirective field="timestamp" headerText="Zeit" width="180" template={(props: MonitoringLogEntry) => formatTimestamp(props.timestamp)} />
|
||||
<ColumnDirective field="client_uuid" headerText="Client" width="220" />
|
||||
<ColumnDirective field="message" headerText="Nachricht" width="360" />
|
||||
</ColumnsDirective>
|
||||
<Inject services={[Page, Sort]} />
|
||||
</GridComponent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogComponent
|
||||
isModal={true}
|
||||
visible={!!selectedLogEntry}
|
||||
width="860px"
|
||||
minHeight="420px"
|
||||
header="Log-Details"
|
||||
animationSettings={{ effect: 'None' }}
|
||||
buttons={[]}
|
||||
showCloseIcon={true}
|
||||
close={() => setSelectedLogEntry(null)}
|
||||
>
|
||||
{selectedLogEntry && (
|
||||
<div className="monitoring-log-dialog-body">
|
||||
<div className="monitoring-log-dialog-content">
|
||||
<div className="monitoring-log-detail-row">
|
||||
<span>Zeit</span>
|
||||
<strong>{formatTimestamp(selectedLogEntry.timestamp)}</strong>
|
||||
</div>
|
||||
<div className="monitoring-log-detail-row">
|
||||
<span>Level</span>
|
||||
<strong>{selectedLogEntry.level || 'Unbekannt'}</strong>
|
||||
</div>
|
||||
<div className="monitoring-log-detail-row">
|
||||
<span>Nachricht</span>
|
||||
<strong style={{ whiteSpace: 'normal', textAlign: 'left' }}>{selectedLogEntry.message}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<div className="monitoring-log-context-title">Kontext</div>
|
||||
<pre className="monitoring-log-context">{renderContext(selectedLogEntry.context)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="monitoring-log-dialog-actions">
|
||||
<ButtonComponent onClick={() => setSelectedLogEntry(null)}>Schließen</ButtonComponent>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitoringDashboard;
|
||||
Reference in New Issue
Block a user