feat(dashboard+api): card-based dashboard, camelCase API, UTC fixes

Dashboard: new Syncfusion card layout, global stats, filters, health bars, active event display, client details, bulk restart, 15s auto-refresh, manual refresh toasts
API: standardized responses to camelCase; added serializers.py and updated events endpoints
Time: ensured UTC storage; frontend appends 'Z' for parsing and displays local time
Docs: updated copilot-instructions.md, README.md, TECH-CHANGELOG.md
Program Info: bumped to 2025.1.0-alpha.12 with user-facing changelog
BREAKING: external API consumers must migrate field names from PascalCase to camelCase.
This commit is contained in:
RobbStarkAustria
2025-11-27 20:30:00 +00:00
parent 452ba3033b
commit 6dcf93f0dd
13 changed files with 1282 additions and 350 deletions

24
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.11",
"version": "2025.1.0-alpha.12",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,10 +26,24 @@
]
},
"buildInfo": {
"buildDate": "2025-10-25T12:00:00Z",
"buildDate": "2025-11-27T12:00:00Z",
"commitId": "9f2ae8b44c3a"
},
"changelog": [
{
"version": "2025.1.0-alpha.12",
"date": "2025-11-27",
"changes": [
"✨ Dashboard: Komplett überarbeitetes Dashboard mit Karten-Design für alle Raumgruppen.",
"📊 Dashboard: Globale Statistik-Übersicht zeigt Gesamt-Infoscreens, Online/Offline-Anzahl und Warnungen.",
"🔍 Dashboard: Filter-Buttons (Alle, Online, Offline, Warnungen) mit dynamischen Zählern.",
"🎯 Dashboard: Anzeige des aktuell laufenden Events pro Gruppe (Titel, Typ, Datum, Uhrzeit in lokaler Zeitzone).",
"📈 Dashboard: Farbcodierte Health-Bars zeigen Online/Offline-Verhältnis je Gruppe.",
"👥 Dashboard: Ausklappbare Client-Details mit 'Zeit seit letztem Lebenszeichen' (z.B. 'vor 5 Min.').",
"🔄 Dashboard: Sammel-Neustart-Funktion für alle offline Clients einer Gruppe.",
"⏱️ Dashboard: Auto-Aktualisierung alle 15 Sekunden; manueller Aktualisierungs-Button verfügbar."
]
},
{
"version": "2025.1.0-alpha.11",
"date": "2025-11-05",

View File

@@ -369,11 +369,11 @@ const Appointments: React.FC = () => {
const expandedEvents: Event[] = [];
for (const e of data) {
if (e.RecurrenceRule) {
if (e.recurrenceRule) {
// Parse EXDATE list
const exdates = new Set<string>();
if (e.RecurrenceException) {
e.RecurrenceException.split(',').forEach((dateStr: string) => {
if (e.recurrenceException) {
e.recurrenceException.split(',').forEach((dateStr: string) => {
const trimmed = dateStr.trim();
exdates.add(trimmed);
});
@@ -381,53 +381,53 @@ const Appointments: React.FC = () => {
// Let Syncfusion handle ALL recurrence patterns natively for proper badge display
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Id: e.id,
Subject: e.subject,
StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.endTime),
IsAllDay: e.isAllDay,
MediaId: e.mediaId,
SlideshowInterval: e.slideshowInterval,
PageProgress: e.pageProgress,
AutoProgress: e.autoProgress,
WebsiteUrl: e.websiteUrl,
Autoplay: e.autoplay,
Loop: e.loop,
Volume: e.volume,
Muted: e.muted,
Icon: e.icon,
Type: e.type,
OccurrenceOfId: e.occurrenceOfId,
Recurrence: true,
RecurrenceRule: e.RecurrenceRule,
RecurrenceEnd: e.RecurrenceEnd ?? null,
SkipHolidays: e.SkipHolidays ?? false,
RecurrenceException: e.RecurrenceException || undefined,
RecurrenceRule: e.recurrenceRule,
RecurrenceEnd: e.recurrenceEnd ?? null,
SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: e.recurrenceException || undefined,
});
} else {
// Non-recurring event - add as-is
expandedEvents.push({
Id: e.Id,
Subject: e.Subject,
StartTime: parseEventDate(e.StartTime),
EndTime: parseEventDate(e.EndTime),
IsAllDay: e.IsAllDay,
MediaId: e.MediaId,
SlideshowInterval: e.SlideshowInterval,
PageProgress: e.PageProgress,
AutoProgress: e.AutoProgress,
WebsiteUrl: e.WebsiteUrl,
Autoplay: e.Autoplay,
Loop: e.Loop,
Volume: e.Volume,
Muted: e.Muted,
Icon: e.Icon,
Type: e.Type,
OccurrenceOfId: e.OccurrenceOfId,
Id: e.id,
Subject: e.subject,
StartTime: parseEventDate(e.startTime),
EndTime: parseEventDate(e.endTime),
IsAllDay: e.isAllDay,
MediaId: e.mediaId,
SlideshowInterval: e.slideshowInterval,
PageProgress: e.pageProgress,
AutoProgress: e.autoProgress,
WebsiteUrl: e.websiteUrl,
Autoplay: e.autoplay,
Loop: e.loop,
Volume: e.volume,
Muted: e.muted,
Icon: e.icon,
Type: e.type,
OccurrenceOfId: e.occurrenceOfId,
Recurrence: false,
RecurrenceRule: null,
RecurrenceEnd: null,
SkipHolidays: e.SkipHolidays ?? false,
SkipHolidays: e.skipHolidays ?? false,
RecurrenceException: undefined,
});
}
@@ -512,7 +512,7 @@ const Appointments: React.FC = () => {
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
// Filter: Events with SkipHolidays=true are never shown on holidays, regardless of toggle
// Filter: Events with SkipHolidays=true (from internal Event type) are never shown on holidays
const filteredEvents = events.filter(ev => {
if (ev.SkipHolidays) {
// If event falls within a holiday, hide it
@@ -912,10 +912,10 @@ const Appointments: React.FC = () => {
let isMasterRecurring = false;
try {
masterEvent = await fetchEventById(eventId);
isMasterRecurring = !!masterEvent.RecurrenceRule;
isMasterRecurring = !!masterEvent.recurrenceRule;
console.log('Master event info:', {
masterRecurrenceRule: masterEvent.RecurrenceRule,
masterStartTime: masterEvent.StartTime,
masterRecurrenceRule: masterEvent.recurrenceRule,
masterStartTime: masterEvent.startTime,
isMasterRecurring
});
} catch (err) {

View File

@@ -1,204 +1,787 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients';
import {
GridComponent,
ColumnsDirective,
ColumnDirective,
Page,
DetailRow,
Inject,
Sort,
} from '@syncfusion/ej2-react-grids';
import { fetchEvents } from './apiEvents';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
const REFRESH_INTERVAL = 15000; // 15 Sekunden
// Typ für Collapse-Event
// type DetailRowCollapseArgs = {
// data?: { id?: string | number };
// };
type FilterType = 'all' | 'online' | 'offline' | 'warning';
// Typ für DataBound-Event
type DetailRowDataBoundArgs = {
data?: { id?: string | number };
};
interface ActiveEvent {
id: string;
title: string;
event_type: string;
start: string;
end: string;
recurrenceRule?: string;
isRecurring: boolean;
}
interface GroupEvents {
[groupId: number]: ActiveEvent | null;
}
const Dashboard: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
const gridRef = useRef<GridComponent | null>(null);
// Funktion für das Schließen einer Gruppe (Collapse)
// const onDetailCollapse = (args: DetailRowCollapseArgs) => {
// if (args && args.data && args.data.id) {
// const groupId = String(args.data.id);
// setExpandedGroupIds(prev => prev.filter(id => String(id) !== groupId));
// }
// };
// // Registriere das Event nach dem Mount am Grid
// useEffect(() => {
// if (gridRef.current) {
// gridRef.current.detailCollapse = onDetailCollapse;
// }
// }, []);
const [expandedCards, setExpandedCards] = useState<Set<number>>(new Set());
const [filter, setFilter] = useState<FilterType>('all');
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
const toastRef = React.useRef<ToastComponent>(null);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => {
let lastGroups: Group[] = [];
const fetchAndUpdate = async () => {
const newGroups = await fetchGroupsWithClients();
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
const changed =
lastGroups.length !== newGroups.length ||
lastGroups.some((g, i) => {
const ng = newGroups[i];
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
// Optional: Vergleiche tiefer, z.B. Alive-Status
for (let j = 0; j < g.clients.length; j++) {
if (
g.clients[j].uuid !== ng.clients[j].uuid ||
g.clients[j].is_alive !== ng.clients[j].is_alive
) {
return true;
}
}
return false;
});
if (changed) {
setGroups(newGroups);
lastGroups = newGroups;
setTimeout(() => {
expandedGroupIds.forEach(id => {
const rowIndex = newGroups.findIndex(g => String(g.id) === String(id));
if (rowIndex !== -1 && gridRef.current) {
gridRef.current.detailRowModule.expand(rowIndex);
try {
const newGroups = await fetchGroupsWithClients();
// Vergleiche nur die relevanten Felder (id, clients, is_alive)
const changed =
lastGroups.length !== newGroups.length ||
lastGroups.some((g, i) => {
const ng = newGroups[i];
if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true;
// Optional: Vergleiche tiefer, z.B. Alive-Status
for (let j = 0; j < g.clients.length; j++) {
if (
g.clients[j].uuid !== ng.clients[j].uuid ||
g.clients[j].is_alive !== ng.clients[j].is_alive
) {
return true;
}
}
return false;
});
}, 100);
if (changed) {
setGroups(newGroups);
lastGroups = newGroups;
setLastUpdate(new Date());
// Fetch active events for all groups
fetchActiveEventsForGroups(newGroups);
}
} catch (error) {
console.error('Fehler beim Laden der Gruppen:', error);
}
};
fetchAndUpdate();
const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [expandedGroupIds]);
}, []);
// Health-Badge
const getHealthBadge = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const ratio = total === 0 ? 0 : alive / total;
let color = 'danger';
let text = `${alive} / ${total} offline`;
if (ratio === 1) {
color = 'success';
text = `${alive} / ${total} alive`;
} else if (ratio >= 0.5) {
color = 'warning';
text = `${alive} / ${total} teilw. alive`;
}
return <span className={`e-badge e-badge-${color}`}>{text}</span>;
};
// Einfache Tabelle für Clients einer Gruppe
const getClientTable = (group: Group) => (
<div style={{ maxHeight: 300, overflowY: 'auto', marginBottom: '5px' }}>
<GridComponent dataSource={group.clients} allowSorting={true} height={'auto'}>
<ColumnsDirective>
<ColumnDirective field="description" headerText="Beschreibung" width="150" />
<ColumnDirective field="ip" headerText="IP" width="120" />
{/* <ColumnDirective
field="last_alive"
headerText="Letztes Lebenszeichen"
width="180"
template={(props: { last_alive: string | null }) => {
if (!props.last_alive) return '-';
const dateStr = props.last_alive.endsWith('Z')
? props.last_alive
: props.last_alive + 'Z';
const date = new Date(dateStr);
return isNaN(date.getTime()) ? props.last_alive : date.toLocaleString();
}}
/> */}
<ColumnDirective
field="is_alive"
headerText="Alive"
width="100"
template={(props: { is_alive: boolean }) => (
<span className={`e-badge e-badge-${props.is_alive ? 'success' : 'danger'}`}>
{props.is_alive ? 'alive' : 'offline'}
</span>
)}
sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)}
/>
<ColumnDirective
headerText="Aktionen"
width="150"
template={(props: { uuid: string }) => (
<button className="e-btn e-primary" onClick={() => handleRestartClient(props.uuid)}>
Neustart
</button>
)}
/>
</ColumnsDirective>
<Inject services={[Sort]} />
</GridComponent>
</div>
);
// Neustart-Logik
const handleRestartClient = async (uuid: string) => {
try {
const result = await restartClient(uuid);
alert(`Neustart erfolgreich: ${result.message}`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'message' in error) {
alert(`Fehler beim Neustart: ${(error as { message: string }).message}`);
} else {
alert('Unbekannter Fehler beim Neustart');
// Fetch currently active events for all groups
const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const now = new Date();
const eventsMap: GroupEvents = {};
for (const group of groupsList) {
try {
const events = await fetchEvents(String(group.id), false, {
start: new Date(now.getTime() - 60000), // 1 minute ago
end: new Date(now.getTime() + 60000), // 1 minute ahead
expand: true
});
// Find the first active event
if (events && events.length > 0) {
const activeEvent = events[0];
eventsMap[group.id] = {
id: activeEvent.id,
title: activeEvent.subject || 'Unbenannter Event',
event_type: activeEvent.type || 'unknown',
start: activeEvent.startTime, // Keep as string, will be parsed in format functions
end: activeEvent.endTime,
recurrenceRule: activeEvent.recurrenceRule,
isRecurring: !!activeEvent.recurrenceRule
};
} else {
eventsMap[group.id] = null;
}
} catch {
console.error(`Fehler beim Laden der Events für Gruppe ${group.id}`);
eventsMap[group.id] = null;
}
}
setActiveEvents(eventsMap);
};
// SyncFusion Grid liefert im Event die Zeile/Gruppe
const onDetailDataBound = (args: DetailRowDataBoundArgs) => {
if (args && args.data && args.data.id) {
const groupId = String(args.data.id);
setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
// Toggle card expansion
const toggleCard = (groupId: number) => {
setExpandedCards(prev => {
const newSet = new Set(prev);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
return newSet;
});
};
// Health-Statistik berechnen
const getHealthStats = (group: Group) => {
const total = group.clients.length;
const alive = group.clients.filter((c: Client) => c.is_alive).length;
const offline = total - alive;
const ratio = total === 0 ? 0 : alive / total;
let statusColor = '#e74c3c'; // danger red
let statusText = 'Kritisch';
if (ratio === 1) {
statusColor = '#27ae60'; // success green
statusText = 'Optimal';
} else if (ratio >= 0.5) {
statusColor = '#f39c12'; // warning orange
statusText = 'Teilweise';
}
return { total, alive, offline, ratio, statusColor, statusText };
};
// Neustart-Logik
const handleRestartClient = async (uuid: string, description: string) => {
try {
const result = await restartClient(uuid);
toastRef.current?.show({
title: 'Neustart erfolgreich',
content: `${description || uuid}: ${result.message}`,
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
});
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: 'Unbekannter Fehler beim Neustart';
toastRef.current?.show({
title: 'Fehler beim Neustart',
content: message,
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Bulk restart für offline Clients einer Gruppe
const handleRestartAllOffline = async (group: Group) => {
const offlineClients = group.clients.filter(c => !c.is_alive);
if (offlineClients.length === 0) {
toastRef.current?.show({
title: 'Keine Offline-Geräte',
content: `Alle Infoscreens in "${group.name}" sind online.`,
cssClass: 'e-toast-info',
icon: 'e-info toast-icons',
});
return;
}
const confirmed = window.confirm(
`Möchten Sie ${offlineClients.length} offline Infoscreen(s) in "${group.name}" neu starten?`
);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const client of offlineClients) {
try {
await restartClient(client.uuid);
successCount++;
} catch {
failCount++;
}
}
toastRef.current?.show({
title: 'Bulk-Neustart abgeschlossen',
content: `${successCount} erfolgreich, ✗ ${failCount} fehlgeschlagen`,
cssClass: failCount > 0 ? 'e-toast-warning' : 'e-toast-success',
icon: failCount > 0 ? 'e-warning toast-icons' : 'e-success toast-icons',
});
};
// Berechne Gesamtstatistiken
const getGlobalStats = () => {
const allClients = groups.flatMap(g => g.clients);
const total = allClients.length;
const online = allClients.filter(c => c.is_alive).length;
const offline = total - online;
const ratio = total === 0 ? 0 : online / total;
// Warnungen: Gruppen mit teilweise offline Clients
const warningGroups = groups.filter(g => {
const groupStats = getHealthStats(g);
return groupStats.ratio > 0 && groupStats.ratio < 1;
}).length;
return { total, online, offline, ratio, warningGroups };
};
// Berechne "Zeit seit letztem Lebenszeichen"
const getTimeSinceLastAlive = (lastAlive: string | null | undefined): string => {
if (!lastAlive) return 'Nie';
try {
const dateStr = lastAlive.endsWith('Z') ? lastAlive : lastAlive + 'Z';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unbekannt';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`;
} catch {
return 'Unbekannt';
}
};
// Manuelle Aktualisierung
const handleManualRefresh = async () => {
try {
const newGroups = await fetchGroupsWithClients();
setGroups(newGroups);
setLastUpdate(new Date());
await fetchActiveEventsForGroups(newGroups);
toastRef.current?.show({
title: 'Aktualisiert',
content: 'Daten wurden erfolgreich aktualisiert',
cssClass: 'e-toast-success',
icon: 'e-success toast-icons',
timeOut: 2000,
});
} catch {
toastRef.current?.show({
title: 'Fehler',
content: 'Daten konnten nicht aktualisiert werden',
cssClass: 'e-toast-danger',
icon: 'e-error toast-icons',
});
}
};
// Get event type icon
const getEventTypeIcon = (eventType: string): string => {
switch (eventType) {
case 'presentation': return '📊';
case 'website': return '🌐';
case 'webuntis': return '📅';
case 'video': return '🎬';
case 'message': return '💬';
default: return '📄';
}
};
// Get event type label
const getEventTypeLabel = (eventType: string): string => {
switch (eventType) {
case 'presentation': return 'Präsentation';
case 'website': return 'Website';
case 'webuntis': return 'WebUntis';
case 'video': return 'Video';
case 'message': return 'Nachricht';
default: return 'Inhalt';
}
};
// Format time for display
const formatEventTime = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} catch {
return '—';
}
};
// Format date for display
const formatEventDate = (dateStr: string): string => {
try {
// API returns UTC ISO strings without 'Z', so append 'Z' to parse as UTC
const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z';
const date = new Date(utcString);
if (isNaN(date.getTime())) return '—';
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return 'Heute';
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
} catch {
return '—';
}
};
// Filter Gruppen basierend auf Status
const getFilteredGroups = () => {
return groups.filter(group => {
const stats = getHealthStats(group);
switch (filter) {
case 'online':
return stats.ratio === 1;
case 'offline':
return stats.ratio === 0;
case 'warning':
return stats.ratio > 0 && stats.ratio < 1;
default:
return true;
}
});
};
return (
<div>
<header style={{ marginBottom: 32, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>Dashboard</h2>
<ToastComponent
ref={toastRef}
position={{ X: 'Right', Y: 'Top' }}
timeOut={4000}
/>
<header style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '2px solid #d6c3a6' }}>
<h2 style={{ fontSize: '1.75rem', fontWeight: 800, margin: 0, marginBottom: 8 }}>
Dashboard
</h2>
<p style={{ color: '#666', fontSize: '0.95rem', margin: 0 }}>
Übersicht aller Raumgruppen und deren Infoscreens
</p>
</header>
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, marginTop: 24, marginBottom: 16 }}>
Raumgruppen Übersicht
</h3>
<GridComponent
dataSource={groups}
allowPaging={true}
pageSettings={{ pageSize: 5 }}
height={400}
detailTemplate={(props: Group) => getClientTable(props)}
detailDataBound={onDetailDataBound}
ref={gridRef}
>
<Inject services={[Page, DetailRow]} />
<ColumnsDirective>
<ColumnDirective field="name" headerText="Raumgruppe" width="180" />
<ColumnDirective
headerText="Health"
width="160"
template={(props: Group) => getHealthBadge(props)}
/>
</ColumnsDirective>
</GridComponent>
{groups.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
)}
{/* Global Statistics Summary */}
{(() => {
const globalStats = getGlobalStats();
return (
<div className="e-card" style={{
marginBottom: '24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
borderRadius: '8px',
padding: '24px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '20px',
alignItems: 'center'
}}>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gesamt Infoscreens
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.total}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🟢 Online
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.online}
<span style={{ fontSize: '1rem', marginLeft: '8px', opacity: 0.8 }}>
({globalStats.total > 0 ? Math.round(globalStats.ratio * 100) : 0}%)
</span>
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
🔴 Offline
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.offline}
</div>
</div>
<div>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '4px' }}>
Gruppen mit Warnungen
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{globalStats.warningGroups}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '0.85rem', opacity: 0.9, marginBottom: '8px' }}>
Zuletzt aktualisiert: {getTimeSinceLastAlive(lastUpdate.toISOString())}
</div>
<ButtonComponent
cssClass="e-small e-info"
iconCss="e-icons e-refresh"
onClick={handleManualRefresh}
>
Aktualisieren
</ButtonComponent>
</div>
</div>
</div>
);
})()}
{/* Filter Buttons */}
<div style={{
display: 'flex',
gap: '12px',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
<ButtonComponent
cssClass={filter === 'all' ? 'e-primary' : 'e-outline'}
onClick={() => setFilter('all')}
>
Alle anzeigen ({groups.length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'online' ? 'e-success' : 'e-outline'}
onClick={() => setFilter('online')}
>
🟢 Nur Online ({groups.filter(g => getHealthStats(g).ratio === 1).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'offline' ? 'e-danger' : 'e-outline'}
onClick={() => setFilter('offline')}
>
🔴 Nur Offline ({groups.filter(g => getHealthStats(g).ratio === 0).length})
</ButtonComponent>
<ButtonComponent
cssClass={filter === 'warning' ? 'e-warning' : 'e-outline'}
onClick={() => setFilter('warning')}
>
Mit Warnungen ({groups.filter(g => {
const stats = getHealthStats(g);
return stats.ratio > 0 && stats.ratio < 1;
}).length})
</ButtonComponent>
</div>
{/* Group Cards */}
{(() => {
const filteredGroups = getFilteredGroups();
if (filteredGroups.length === 0) {
return (
<div className="e-card" style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: '1.1rem' }}>
{filter === 'all'
? 'Keine Raumgruppen gefunden'
: `Keine Gruppen mit Filter "${filter}" gefunden`
}
</div>
</div>
);
}
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
gap: '24px'
}}>
{filteredGroups
.sort((a, b) => {
// 'Nicht zugeordnet' always comes last
if (a.name === 'Nicht zugeordnet') return 1;
if (b.name === 'Nicht zugeordnet') return -1;
// Otherwise, sort alphabetically
return a.name.localeCompare(b.name);
})
.map((group) => {
const stats = getHealthStats(group);
const isExpanded = expandedCards.has(group.id);
return (
<div key={group.id} className="e-card" style={{
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'all 0.3s ease',
border: `2px solid ${stats.statusColor}20`
}}>
{/* Card Header */}
<div className="e-card-header" style={{
background: `linear-gradient(135deg, ${stats.statusColor}15, ${stats.statusColor}05)`,
borderBottom: `3px solid ${stats.statusColor}`,
padding: '16px 20px'
}}>
<div className="e-card-header-title" style={{
fontSize: '1.25rem',
fontWeight: '700',
color: '#333',
marginBottom: '8px'
}}>
{group.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<span className={`e-badge e-badge-${stats.ratio === 1 ? 'success' : stats.ratio >= 0.5 ? 'warning' : 'danger'}`}>
{stats.statusText}
</span>
<span style={{ color: '#666', fontSize: '0.9rem' }}>
{stats.total} {stats.total === 1 ? 'Infoscreen' : 'Infoscreens'}
</span>
</div>
</div>
{/* Card Content - Statistics */}
<div className="e-card-content" style={{ padding: '20px' }}>
{/* Currently Active Event */}
{activeEvents[group.id] ? (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f0f7ff',
borderLeft: '4px solid #2196F3',
borderRadius: '4px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div style={{
fontSize: '0.75rem',
color: '#666',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
🎯 Aktuell angezeigt
</div>
{activeEvents[group.id]?.isRecurring && (
<span style={{
fontSize: '0.75rem',
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '2px 8px',
borderRadius: '12px',
fontWeight: '600'
}}>
🔄 Wiederkehrend
</span>
)}
</div>
<div style={{
fontSize: '0.95rem',
fontWeight: '600',
color: '#333',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span>{getEventTypeIcon(activeEvents[group.id]?.event_type || 'unknown')}</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}>
{activeEvents[group.id]?.title || 'Unbenannter Event'}
</span>
</div>
<div style={{
fontSize: '0.8rem',
color: '#666',
marginBottom: '6px'
}}>
{getEventTypeLabel(activeEvents[group.id]?.event_type || 'unknown')}
</div>
<div style={{
fontSize: '0.8rem',
color: '#555',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap'
}}>
<span style={{ fontWeight: '500' }}>
📅 {activeEvents[group.id]?.start ? formatEventDate(activeEvents[group.id]!.start) : 'Kein Datum'}
</span>
<span></span>
<span>
🕐 {activeEvents[group.id]?.start && activeEvents[group.id]?.end
? `${formatEventTime(activeEvents[group.id]!.start)} - ${formatEventTime(activeEvents[group.id]!.end)}`
: 'Keine Zeit'}
</span>
</div>
</div>
) : (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#f5f5f5',
borderLeft: '4px solid #999',
borderRadius: '4px'
}}>
<div style={{
fontSize: '0.85rem',
color: '#666',
fontStyle: 'italic'
}}>
📭 Kein aktiver Event
</div>
</div>
)}
{/* Health Bar */}
<div style={{ marginBottom: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '8px',
fontSize: '0.85rem',
color: '#666'
}}>
<span>🟢 Online: {stats.alive}</span>
<span>🔴 Offline: {stats.offline}</span>
</div>
<div style={{
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${stats.ratio * 100}%`,
backgroundColor: stats.statusColor,
transition: 'width 0.5s ease'
}} />
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px', marginBottom: isExpanded ? '16px' : '0' }}>
<ButtonComponent
cssClass="e-outline"
iconCss={isExpanded ? 'e-icons e-chevron-up' : 'e-icons e-chevron-down'}
onClick={() => toggleCard(group.id)}
style={{ flex: 1 }}
>
{isExpanded ? 'Details ausblenden' : 'Details anzeigen'}
</ButtonComponent>
{stats.offline > 0 && (
<ButtonComponent
cssClass="e-danger e-small"
iconCss="e-icons e-refresh"
onClick={() => handleRestartAllOffline(group)}
title={`${stats.offline} offline Gerät(e) neu starten`}
style={{ flexShrink: 0 }}
>
Offline neustarten
</ButtonComponent>
)}
</div>
{/* Expanded Client List */}
{isExpanded && (
<div style={{
marginTop: '16px',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid #e0e0e0',
borderRadius: '4px'
}}>
{group.clients.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
Keine Infoscreens in dieser Gruppe
</div>
) : (
group.clients.map((client, index) => (
<div
key={client.uuid}
style={{
padding: '12px 16px',
borderBottom: index < group.clients.length - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: client.is_alive ? '#f8fff9' : '#fff5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: '600',
fontSize: '0.95rem',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{client.description || client.hostname || 'Unbenannt'}
</div>
<div style={{
fontSize: '0.85rem',
color: '#666',
display: 'flex',
gap: '12px',
flexWrap: 'wrap'
}}>
<span>📍 {client.ip || 'Keine IP'}</span>
{client.hostname && (
<span title={client.hostname} style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '150px'
}}>
🖥 {client.hostname}
</span>
)}
</div>
<div style={{
fontSize: '0.75rem',
color: client.is_alive ? '#27ae60' : '#e74c3c',
marginTop: '4px',
fontWeight: '500'
}}>
{client.is_alive
? `✓ Aktiv ${getTimeSinceLastAlive(client.last_alive)}`
: `⚠ Offline seit ${getTimeSinceLastAlive(client.last_alive)}`
}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
<span className={`e-badge e-badge-${client.is_alive ? 'success' : 'danger'}`}>
{client.is_alive ? 'Online' : 'Offline'}
</span>
<ButtonComponent
cssClass="e-small e-primary"
iconCss="e-icons e-refresh"
onClick={() => handleRestartClient(client.uuid, client.description || client.hostname || 'Infoscreen')}
title="Neustart"
/>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
})}
</div>
);
})()}
</div>
);
};