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:
24
dashboard/.gitignore
vendored
Normal file
24
dashboard/.gitignore
vendored
Normal 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?
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user