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:
2026-03-24 11:18:33 +00:00
parent 3107d0f671
commit 9c330f984f
18 changed files with 2095 additions and 104 deletions

View File

@@ -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>

View 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;
}

View 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;
}
}

View 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;