diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index caf28b1..339ae92 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -12,10 +12,11 @@
"@syncfusion/ej2-react-calendars": "^30.1.37",
"@syncfusion/ej2-react-dropdowns": "^30.1.37",
"@syncfusion/ej2-react-filemanager": "^30.1.38",
- "@syncfusion/ej2-react-grids": "^30.1.37",
+ "@syncfusion/ej2-react-grids": "^30.1.40",
"@syncfusion/ej2-react-inputs": "^30.1.38",
"@syncfusion/ej2-react-kanban": "^30.1.37",
"@syncfusion/ej2-react-layouts": "^30.1.40",
+ "@syncfusion/ej2-react-navigations": "^30.1.39",
"@syncfusion/ej2-react-notifications": "^30.1.37",
"@syncfusion/ej2-react-popups": "^30.1.37",
"@syncfusion/ej2-react-schedule": "^30.1.37",
@@ -1027,9 +1028,9 @@
}
},
"node_modules/@syncfusion/ej2-data": {
- "version": "30.1.38",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-data/-/ej2-data-30.1.38.tgz",
- "integrity": "sha512-BdqvjLzzK4OuUR1YlzPSG3SmeGg1mrLz/6ih5oD9dSpRXDoMG24bpO1rwCK7mjy8Dp9IJ8mliyCbPfoDycxM9Q==",
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-data/-/ej2-data-30.1.40.tgz",
+ "integrity": "sha512-jxQll5rn3yailAP/wcCnIw96ROfafwFzMJ6BIcI5FTn+/mk3hLQTp/GiZ/In7uDGgXQll2yQeolPJcOkILtWkw==",
"license": "SEE LICENSE IN license",
"dependencies": {
"@syncfusion/ej2-base": "~30.1.38"
@@ -1084,50 +1085,64 @@
"@syncfusion/ej2-splitbuttons": "~30.1.37"
}
},
- "node_modules/@syncfusion/ej2-filemanager/node_modules/@syncfusion/ej2-grids": {
- "version": "30.1.38",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.38.tgz",
- "integrity": "sha512-0ULWC/P8AsYco3fhUfrkppEdU+IMzrIWyoP057/yp0Mktq9UI5mgvQ12ruZbEMQXl0vK5S5DKaWMDJU2vBTDWQ==",
+ "node_modules/@syncfusion/ej2-grids": {
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.40.tgz",
+ "integrity": "sha512-t6WHFhF2dD0vusdn9rEOFi8Opony/TDIFlu9iBRYd2g+RvKDzK4jo97qWQbAzOnbgvJGhSy3DLnVnIMwFdnqRw==",
"license": "SEE LICENSE IN license",
"dependencies": {
"@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-buttons": "~30.1.37",
"@syncfusion/ej2-calendars": "~30.1.37",
"@syncfusion/ej2-compression": "~30.1.37",
- "@syncfusion/ej2-data": "~30.1.38",
- "@syncfusion/ej2-dropdowns": "~30.1.37",
+ "@syncfusion/ej2-data": "~30.1.40",
+ "@syncfusion/ej2-dropdowns": "~30.1.40",
"@syncfusion/ej2-excel-export": "~30.1.37",
"@syncfusion/ej2-file-utils": "~30.1.37",
- "@syncfusion/ej2-inputs": "~30.1.38",
+ "@syncfusion/ej2-inputs": "~30.1.40",
"@syncfusion/ej2-lists": "~30.1.37",
- "@syncfusion/ej2-navigations": "~30.1.37",
+ "@syncfusion/ej2-navigations": "~30.1.39",
"@syncfusion/ej2-notifications": "~30.1.37",
"@syncfusion/ej2-pdf-export": "~30.1.38",
- "@syncfusion/ej2-popups": "~30.1.37",
- "@syncfusion/ej2-splitbuttons": "~30.1.37"
+ "@syncfusion/ej2-popups": "~30.1.40",
+ "@syncfusion/ej2-splitbuttons": "~30.1.39"
}
},
- "node_modules/@syncfusion/ej2-grids": {
- "version": "30.1.37",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.37.tgz",
- "integrity": "sha512-EG8RzzPCML9UULAN+SZdo/G5j8AsmzKzwQvocvZoL/YOfDPWom8WaGXNbE5pssMmIEnF2YnQkMuwRWg1tLHyog==",
+ "node_modules/@syncfusion/ej2-grids/node_modules/@syncfusion/ej2-dropdowns": {
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-dropdowns/-/ej2-dropdowns-30.1.40.tgz",
+ "integrity": "sha512-8vpO0+X4OnA+CLPfVBqOBon2AbDHXUTK5UdP/A2JEJQw0ktNpURm8ux3K1VT9OXvYmuNgyN/WrM6Pf5m3Oexkw==",
"license": "SEE LICENSE IN license",
"dependencies": {
- "@syncfusion/ej2-base": "~30.1.37",
- "@syncfusion/ej2-buttons": "~30.1.37",
- "@syncfusion/ej2-calendars": "~30.1.37",
- "@syncfusion/ej2-compression": "~30.1.37",
- "@syncfusion/ej2-data": "~30.1.37",
- "@syncfusion/ej2-dropdowns": "~30.1.37",
- "@syncfusion/ej2-excel-export": "~30.1.37",
- "@syncfusion/ej2-file-utils": "~30.1.37",
- "@syncfusion/ej2-inputs": "~30.1.37",
+ "@syncfusion/ej2-base": "~30.1.38",
+ "@syncfusion/ej2-data": "~30.1.40",
+ "@syncfusion/ej2-inputs": "~30.1.40",
"@syncfusion/ej2-lists": "~30.1.37",
- "@syncfusion/ej2-navigations": "~30.1.37",
+ "@syncfusion/ej2-navigations": "~30.1.39",
"@syncfusion/ej2-notifications": "~30.1.37",
- "@syncfusion/ej2-pdf-export": "~30.1.37",
- "@syncfusion/ej2-popups": "~30.1.37",
- "@syncfusion/ej2-splitbuttons": "~30.1.37"
+ "@syncfusion/ej2-popups": "~30.1.40"
+ }
+ },
+ "node_modules/@syncfusion/ej2-grids/node_modules/@syncfusion/ej2-inputs": {
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-inputs/-/ej2-inputs-30.1.40.tgz",
+ "integrity": "sha512-qMbwX8x6s+pS4C9BKz6Dy9z7t+GYCSDvbJXVykvqmdpU54EjmIwb8l7LV9P2B4ooiXQ4QgkFf/CtGjP3z3H1yg==",
+ "license": "SEE LICENSE IN license",
+ "dependencies": {
+ "@syncfusion/ej2-base": "~30.1.38",
+ "@syncfusion/ej2-buttons": "~30.1.37",
+ "@syncfusion/ej2-popups": "~30.1.40",
+ "@syncfusion/ej2-splitbuttons": "~30.1.39"
+ }
+ },
+ "node_modules/@syncfusion/ej2-grids/node_modules/@syncfusion/ej2-popups": {
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-popups/-/ej2-popups-30.1.40.tgz",
+ "integrity": "sha512-yf3FKJW+jdZeargitw9CkOF2Jxg6jCw4PSq9L6aypDiAm9H3GEuZGGRwmnyTLEi+M14oxFSyKOpw5oMSPoZKuw==",
+ "license": "SEE LICENSE IN license",
+ "dependencies": {
+ "@syncfusion/ej2-base": "~30.1.38",
+ "@syncfusion/ej2-buttons": "~30.1.37"
}
},
"node_modules/@syncfusion/ej2-icons": {
@@ -1187,15 +1202,15 @@
}
},
"node_modules/@syncfusion/ej2-navigations": {
- "version": "30.1.37",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-navigations/-/ej2-navigations-30.1.37.tgz",
- "integrity": "sha512-FD3hsT4a8fMdS3S7EFu9tnu741980HRK97fDAUgUtLGqDANC6Ee2HyeZZTjVeUpVAHfdomb/n7oYvtDUXZxwYw==",
+ "version": "30.1.39",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-navigations/-/ej2-navigations-30.1.39.tgz",
+ "integrity": "sha512-mehYOsIcgGEMHbSwdnxxSqcPwhyQpVCJ6VgLESBikkTIww3NAFwRkhMcs4o4n0z+3KyySUVKNi1zU13mzlYkoQ==",
"license": "SEE LICENSE IN license",
"dependencies": {
- "@syncfusion/ej2-base": "~30.1.37",
+ "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-buttons": "~30.1.37",
- "@syncfusion/ej2-data": "~30.1.37",
- "@syncfusion/ej2-inputs": "~30.1.37",
+ "@syncfusion/ej2-data": "~30.1.38",
+ "@syncfusion/ej2-inputs": "~30.1.38",
"@syncfusion/ej2-lists": "~30.1.37",
"@syncfusion/ej2-popups": "~30.1.37"
}
@@ -1284,13 +1299,13 @@
}
},
"node_modules/@syncfusion/ej2-react-grids": {
- "version": "30.1.37",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-grids/-/ej2-react-grids-30.1.37.tgz",
- "integrity": "sha512-QNnvwD88GYezX9glpGUEltFmUCKZORXz7w93BS2CfgTxsjJjdaZ8xzDi98J+aAUrUh6I44pMut5Sfb9QOBFH+g==",
+ "version": "30.1.40",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-grids/-/ej2-react-grids-30.1.40.tgz",
+ "integrity": "sha512-NaqR/r8yN1tomNkp4X5lUQe55wLmtKBvX2Qj0Sm6+SAb2taRR2v2MSKX+MHWToV1cGpUhmVDKo/PikbW98r2eA==",
"license": "SEE LICENSE IN license",
"dependencies": {
- "@syncfusion/ej2-base": "~30.1.37",
- "@syncfusion/ej2-grids": "30.1.37",
+ "@syncfusion/ej2-base": "~30.1.38",
+ "@syncfusion/ej2-grids": "30.1.40",
"@syncfusion/ej2-react-base": "~30.1.37"
}
},
@@ -1327,6 +1342,17 @@
"@syncfusion/ej2-react-base": "~30.1.37"
}
},
+ "node_modules/@syncfusion/ej2-react-navigations": {
+ "version": "30.1.39",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-navigations/-/ej2-react-navigations-30.1.39.tgz",
+ "integrity": "sha512-wXrfM3f1h3IfTjp+HxiKhmUu4wjzd+wSeCCaekTG2yVaKekfzTno/8dVh+c/PhLwJ3wearf+rW8st5nfhAeQdA==",
+ "license": "SEE LICENSE IN license",
+ "dependencies": {
+ "@syncfusion/ej2-base": "~30.1.38",
+ "@syncfusion/ej2-navigations": "30.1.39",
+ "@syncfusion/ej2-react-base": "~30.1.37"
+ }
+ },
"node_modules/@syncfusion/ej2-react-notifications": {
"version": "30.1.37",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz",
@@ -1379,12 +1405,12 @@
}
},
"node_modules/@syncfusion/ej2-splitbuttons": {
- "version": "30.1.37",
- "resolved": "https://registry.npmjs.org/@syncfusion/ej2-splitbuttons/-/ej2-splitbuttons-30.1.37.tgz",
- "integrity": "sha512-V4MNbw8qBNumd7B4AlbmndxXGqILcSUoCrA+wBfLt6KZArZnuK8Y2zaD7fN9xoR1c/hoxlgo9sXyIxdPfi9Lyg==",
+ "version": "30.1.39",
+ "resolved": "https://registry.npmjs.org/@syncfusion/ej2-splitbuttons/-/ej2-splitbuttons-30.1.39.tgz",
+ "integrity": "sha512-MViD8imxEHukX7tvtHWquc76+pL95NB86YPUFb2lx3gxwLUBTIA/KVS2ZmAlQXYaFI7RZQPGopgIbwfb/Zhz5A==",
"license": "SEE LICENSE IN license",
"dependencies": {
- "@syncfusion/ej2-base": "~30.1.37",
+ "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-popups": "~30.1.37"
}
},
diff --git a/dashboard/package.json b/dashboard/package.json
index 36d1ba6..04e62b2 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -14,10 +14,11 @@
"@syncfusion/ej2-react-calendars": "^30.1.37",
"@syncfusion/ej2-react-dropdowns": "^30.1.37",
"@syncfusion/ej2-react-filemanager": "^30.1.38",
- "@syncfusion/ej2-react-grids": "^30.1.37",
+ "@syncfusion/ej2-react-grids": "^30.1.40",
"@syncfusion/ej2-react-inputs": "^30.1.38",
"@syncfusion/ej2-react-kanban": "^30.1.37",
"@syncfusion/ej2-react-layouts": "^30.1.40",
+ "@syncfusion/ej2-react-navigations": "^30.1.39",
"@syncfusion/ej2-react-notifications": "^30.1.37",
"@syncfusion/ej2-react-popups": "^30.1.37",
"@syncfusion/ej2-react-schedule": "^30.1.37",
diff --git a/dashboard/src/App.css b/dashboard/src/App.css
index 4148e0a..de1d541 100644
--- a/dashboard/src/App.css
+++ b/dashboard/src/App.css
@@ -25,11 +25,22 @@ body {
--sidebar-border: #d6c3a6;
}
+/* Layout-Container für Sidebar und Content */
+.layout-container {
+ display: flex;
+ min-height: 100vh;
+ overflow: hidden; /* Verhindert, dass der Scrollbalken den gesamten Container betrifft */
+}
+
+/* Sidebar fixieren, keine Scrollbalken */
.sidebar-theme {
background-color: var(--sidebar-bg);
color: var(--sidebar-fg);
font-size: 1.15rem;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
+ width: 240px; /* Feste Breite für die Sidebar */
+ flex-shrink: 0;
+ overflow: hidden;
}
.sidebar-theme .sidebar-link {
@@ -63,6 +74,16 @@ body {
color: var(--sidebar-bg);
}
+
+/* Nur der Page-Content bekommt den horizontalen Scrollbalken */
+.page-content-scroll {
+ flex: 1 1 auto;
+ overflow-x: auto;
+ padding: 16px; /* Optional: Abstand innerhalb des Contents */
+ height: 100%;
+ box-sizing: border-box; /* Padding wird in die Breite/Höhe einbezogen */
+}
+
/* Kanban-Karten im Sidebar-Style */
.e-kanban .e-card,
.e-kanban .e-card .e-card-content,
@@ -106,4 +127,11 @@ body {
color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important;
}
+/* Entferne den globalen Scrollbalken von .main-content! */
+.main-content {
+ width: 100%;
+ overflow-x: auto; /* Wiederherstellen des ursprünglichen Scroll-Verhaltens */
+ padding-bottom: 8px;
+}
+
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index af25ec6..1800ae1 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -1,5 +1,11 @@
import React, { useState } from 'react';
-import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom';
+import {
+ BrowserRouter as Router,
+ Routes,
+ Route,
+ Link,
+ Outlet,
+} from 'react-router-dom';
import logo from './assets/logo.png';
import './App.css';
@@ -30,17 +36,25 @@ const sidebarItems = [
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings },
];
-import { useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
+// Dummy Components (können in eigene Dateien ausgelagert werden)
+import Dashboard from './dashboard';
+import Appointments from './appointments';
+import Ressourcen from './ressourcen';
+import Infoscreens from './clients';
+import Infoscreen_groups from './infoscreen_groups';
+import Media from './media';
+import Benutzer from './benutzer';
+import Einstellungen from './einstellungen';
+import SetupMode from './SetupMode';
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
-const ENV = import.meta.env.VITE_ENV || 'development';
+// const ENV = import.meta.env.VITE_ENV || 'development';
const Layout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
return (
-
+
{/* Sidebar */}
@@ -127,26 +143,8 @@ const Layout: React.FC = () => {
);
};
-function useLoginCheck() {
- const [isLoggedIn, setIsLoggedIn] = useState(false);
-
- useEffect(() => {
- if (ENV === 'development') {
- setIsLoggedIn(true); // Im Development immer eingeloggt
- console.log('[Login] Development-Modus: User automatisch eingeloggt');
- return;
- }
- // Hier echte Loginlogik einbauen (z.B. Token prüfen)
- // setIsLoggedIn(...)
- // console.log('[Login] Produktiv-Modus: Login-Check ausgeführt');
- }, []);
-
- return isLoggedIn;
-}
const App: React.FC = () => {
- const navigate = useNavigate();
- const isLoggedIn = useLoginCheck();
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt
@@ -176,14 +174,3 @@ const AppWrapper: React.FC = () => (
);
export default AppWrapper;
-
-// Dummy Components (können in eigene Dateien ausgelagert werden)
-import Dashboard from './dashboard';
-import Appointments from './appointments';
-import Ressourcen from './ressourcen';
-import Infoscreens from './clients';
-import Infoscreen_groups from './infoscreen_groups';
-import Media from './media';
-import Benutzer from './benutzer';
-import Einstellungen from './einstellungen';
-import SetupMode from './SetupMode';
diff --git a/dashboard/src/SetupMode.tsx b/dashboard/src/SetupMode.tsx
index a378e87..8778606 100644
--- a/dashboard/src/SetupMode.tsx
+++ b/dashboard/src/SetupMode.tsx
@@ -3,6 +3,8 @@ import { fetchClientsWithoutDescription, setClientDescription } from './apiClien
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
+import { DialogComponent } from '@syncfusion/ej2-react-popups';
+import { useClientDelete } from './hooks/useClientDelete';
type Client = {
uuid: string;
@@ -14,48 +16,67 @@ type Client = {
const SetupMode: React.FC = () => {
const [clients, setClients] = useState
([]);
const [descriptions, setDescriptions] = useState>({});
- const [loading, setLoading] = useState(false);
+ const [loading /* setLoading */] = useState(false);
+ const [inputActive, setInputActive] = useState(false);
+
+ // Lösch-Logik aus Hook (analog zu clients.tsx)
+ const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
+ async uuid => {
+ // Nach dem Löschen neu laden!
+ const updated = await fetchClientsWithoutDescription();
+ setClients(updated);
+ setDescriptions(prev => {
+ const copy = { ...prev };
+ delete copy[uuid];
+ return copy;
+ });
+ }
+ );
+
+ // Hilfsfunktion zum Vergleich der Clients
+ const isEqual = (a: Client[], b: Client[]) => {
+ if (a.length !== b.length) return false;
+ const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
+ const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid));
+ for (let i = 0; i < aSorted.length; i++) {
+ if (aSorted[i].uuid !== bSorted[i].uuid) return false;
+ if (aSorted[i].hostname !== bSorted[i].hostname) return false;
+ if (aSorted[i].ip_address !== bSorted[i].ip_address) return false;
+ if (aSorted[i].last_alive !== bSorted[i].last_alive) return false;
+ }
+ return true;
+ };
useEffect(() => {
let polling: ReturnType | null = null;
- const isEqual = (a: Client[], b: Client[]) => {
- if (a.length !== b.length) return false;
- const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
- const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid));
- for (let i = 0; i < aSorted.length; i++) {
- if (aSorted[i].uuid !== bSorted[i].uuid) return false;
- if (aSorted[i].hostname !== bSorted[i].hostname) return false;
- if (aSorted[i].ip_address !== bSorted[i].ip_address) return false;
- if (aSorted[i].last_alive !== bSorted[i].last_alive) return false;
- }
- return true;
- };
- let firstLoad = true;
+
const fetchClients = () => {
- if (firstLoad) setLoading(true);
+ if (inputActive) return;
fetchClientsWithoutDescription().then(list => {
- if (firstLoad) {
- setLoading(false);
- firstLoad = false;
- }
setClients(prev => (isEqual(prev, list) ? prev : list));
});
};
+
fetchClients();
- polling = setInterval(fetchClients, 5000); // alle 5 Sekunden
+ polling = setInterval(fetchClients, 5000);
+
return () => {
if (polling) clearInterval(polling);
};
- }, []);
+ }, [inputActive]);
const handleDescriptionChange = (uuid: string, value: string) => {
setDescriptions(prev => ({ ...prev, [uuid]: value }));
};
const handleSave = (uuid: string) => {
- setClientDescription(uuid, descriptions[uuid] || '').then(() => {
- setClients(prev => prev.filter(c => c.uuid !== uuid));
- });
+ setClientDescription(uuid, descriptions[uuid] || '')
+ .then(() => {
+ setClients(prev => prev.filter(c => c.uuid !== uuid));
+ })
+ .catch(err => {
+ console.error('Fehler beim Speichern der Beschreibung:', err);
+ });
};
if (loading) return Lade neue Clients ...
;
@@ -68,14 +89,16 @@ const SetupMode: React.FC = () => {
allowPaging={true}
pageSettings={{ pageSize: 10 }}
rowHeight={50}
+ width="100%"
+ allowTextWrap={false}
>
-
-
+
+
{
if (!props.last_alive) return '';
let iso = props.last_alive;
@@ -93,23 +116,57 @@ const SetupMode: React.FC = () => {
value={descriptions[props.uuid] || ''}
placeholder="Beschreibung eingeben"
change={e => handleDescriptionChange(props.uuid, e.value as string)}
+ focus={() => setInputActive(true)}
+ blur={() => setInputActive(false)}
/>
)}
/>
(
- handleSave(props.uuid)}
- />
+
+ handleSave(props.uuid)}
+ />
+ {
+ e.stopPropagation();
+ handleDelete(props.uuid);
+ }}
+ />
+
)}
/>
{clients.length === 0 && Keine neuen Clients ohne Beschreibung.
}
+
+ {/* Syncfusion Dialog für Sicherheitsabfrage */}
+ {showDialog && deleteClientId && (
+ {
+ const client = clients.find(c => c.uuid === deleteClientId);
+ const hostname = client?.hostname ? ` (${client.hostname})` : '';
+ return client
+ ? `Möchten Sie diesen Client${hostname} wirklich entfernen?`
+ : 'Client nicht gefunden.';
+ })()}
+ showCloseIcon={true}
+ width="400px"
+ buttons={[
+ { click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
+ { click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
+ ]}
+ close={cancelDelete}
+ />
+ )}
);
};
diff --git a/dashboard/src/apiClients.ts b/dashboard/src/apiClients.ts
index 119e6e8..e7cbf93 100644
--- a/dashboard/src/apiClients.ts
+++ b/dashboard/src/apiClients.ts
@@ -13,6 +13,24 @@ export interface Client {
last_alive?: string;
is_active?: boolean;
group_id?: number;
+ // Für Health-Status
+ is_alive?: boolean;
+}
+
+export interface Group {
+ id: number;
+ name: string;
+ created_at?: string;
+ is_active?: boolean;
+ clients: Client[];
+}
+// Liefert alle Gruppen mit zugehörigen Clients
+export async function fetchGroupsWithClients(): Promise {
+ const response = await fetch('/api/groups/with_clients');
+ if (!response.ok) {
+ throw new Error('Fehler beim Laden der Gruppen mit Clients');
+ }
+ return await response.json();
}
export async function fetchClients(): Promise {
@@ -60,3 +78,22 @@ export async function updateClient(uuid: string, data: { description?: string; m
if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
return await res.json();
}
+
+export async function restartClient(uuid: string): Promise<{ success: boolean; message?: string }> {
+ const response = await fetch(`/api/clients/${uuid}/restart`, {
+ method: 'POST',
+ });
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Fehler beim Neustart des Clients');
+ }
+ return await response.json();
+}
+
+export async function deleteClient(uuid: string) {
+ const res = await fetch(`/api/clients/${uuid}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error('Fehler beim Entfernen des Clients');
+ return await res.json();
+}
diff --git a/dashboard/src/clients.tsx b/dashboard/src/clients.tsx
index 7e461b3..fa31249 100644
--- a/dashboard/src/clients.tsx
+++ b/dashboard/src/clients.tsx
@@ -1,7 +1,6 @@
import SetupModeButton from './components/SetupModeButton';
import React, { useEffect, useState } from 'react';
-// ...ButtonComponent entfernt...
-// Card-Komponente wird als eigenes Layout umgesetzt
+import { useClientDelete } from './hooks/useClientDelete';
import { fetchClients, updateClient } from './apiClients';
import type { Client } from './apiClients';
import {
@@ -15,6 +14,7 @@ import {
Sort,
Edit,
} from '@syncfusion/ej2-react-grids';
+import { DialogComponent } from '@syncfusion/ej2-react-popups';
// Raumgruppen werden dynamisch aus der API geladen
@@ -88,9 +88,7 @@ function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProp
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
).toLocaleString()
: key === 'last_alive' && value
- ? new Date(
- (value as string).endsWith('Z') ? (value as string) : value + 'Z'
- ).toLocaleString()
+ ? String(value) // Wert direkt anzeigen, nicht erneut parsen
: String(value)}
@@ -111,7 +109,11 @@ function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProp
const Clients: React.FC = () => {
const [clients, setClients] = useState([]);
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
- const [selectedClient, setSelectedClient] = useState(null);
+ const [detailsClient, setDetailsClient] = useState(null);
+
+ const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete(
+ uuid => setClients(prev => prev.filter(c => c.uuid !== uuid))
+ );
useEffect(() => {
fetchClients().then(setClients);
@@ -136,15 +138,27 @@ const Clients: React.FC = () => {
: '',
}));
- // DataGrid row template für Details-Button
+ // DataGrid row template für Details- und Entfernen-Button
const detailsButtonTemplate = (props: Client) => (
-
+
+
+
+
);
return (
@@ -169,9 +183,16 @@ const Clients: React.FC = () => {
allowDeleting: false,
mode: 'Normal',
}}
- actionComplete={async (args: { requestType: string; data: any }) => {
+ actionComplete={async (args: {
+ requestType: string;
+ data: Record;
+ }) => {
if (args.requestType === 'save') {
- const { uuid, description, model } = args.data;
+ const { uuid, description, model } = args.data as {
+ uuid: string;
+ description: string;
+ model: string;
+ };
// API-Aufruf zum Speichern
await updateClient(uuid, { description, model });
// Nach dem Speichern neu laden
@@ -183,32 +204,27 @@ const Clients: React.FC = () => {
-
-
+
+
-
+
{
setSelectedClient(null)}
+ onClose={() => setDetailsClient(null)}
/>
>
) : (
Raumgruppen werden geladen ...
)}
+ {/* DialogComponent für Bestätigung */}
+ {showDialog && deleteClientId && (
+
+ )}
);
};
diff --git a/dashboard/src/dashboard.tsx b/dashboard/src/dashboard.tsx
index 305aac2..dc9ef57 100644
--- a/dashboard/src/dashboard.tsx
+++ b/dashboard/src/dashboard.tsx
@@ -1,47 +1,188 @@
-import React, { useEffect, useState } from 'react';
-import { fetchClients, fetchClientsWithoutDescription } from './apiClients';
-import type { Client } from './apiClients';
+import React, { useEffect, useState, useRef } from 'react';
+import { fetchGroupsWithClients, restartClient } from './apiClients';
+import type { Group } from './apiClients';
+import {
+ GridComponent,
+ ColumnsDirective,
+ ColumnDirective,
+ Page,
+ DetailRow,
+ Inject,
+ Sort,
+} from '@syncfusion/ej2-react-grids';
+const REFRESH_INTERVAL = 15000; // 15 Sekunden
const Dashboard: React.FC = () => {
- const [clients, setClients] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [expandedGroupIds, setExpandedGroupIds] = useState([]);
+ const gridRef = useRef(null);
+ // Funktion für das Schließen einer Gruppe (Collapse)
+ const onDetailCollapse = (args: any) => {
+ 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(() => {
- fetchClients().then(setClients).catch(console.error);
+ if (gridRef.current) {
+ gridRef.current.detailCollapse = onDetailCollapse;
+ }
}, []);
+ // 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);
+ }
+ });
+ }, 100);
+ }
+ };
+
+ 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: any) => 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 {text};
+ };
+
+ // Einfache Tabelle für Clients einer Gruppe
+ const getClientTable = (group: Group) => (
+
+
+
+
+
+ {
+ 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();
+ }}
+ />
+ (
+
+ {props.is_alive ? 'alive' : 'offline'}
+
+ )}
+ sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)}
+ />
+ (
+
+ )}
+ />
+
+
+
+
+ );
+
+ // Neustart-Logik
+ const handleRestartClient = async (uuid: string) => {
+ try {
+ const result = await restartClient(uuid);
+ alert(`Neustart erfolgreich: ${result.message}`);
+ } catch (error: any) {
+ alert(`Fehler beim Neustart: ${error.message}`);
+ }
+ };
+
+ // SyncFusion Grid liefert im Event die Zeile/Gruppe
+ const onDetailDataBound = (args: any) => {
+ if (args && args.data && args.data.id) {
+ const groupId = args.data.id;
+ setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId]));
+ }
+ };
+
return (
-
Infoscreens
-
- {clients.map(client => (
-
-
{client.description || 'Unbekannter Standort'}
-

{
- e.currentTarget.onerror = null; // verhindert Endlosschleife
- e.currentTarget.src = "https://placehold.co/400x300?text=No+Screenshot";
- }}
- />
-
- IP: {client.ip}
-
-
- Letztes Lebenszeichen:{' '}
- {client.last_alive ? new Date(client.last_alive).toLocaleString() : '-'}
-
-
- ))}
- {clients.length === 0 && (
-
Keine Clients gefunden.
- )}
-
+
Raumgruppen Übersicht
+
getClientTable(props)}
+ detailDataBound={onDetailDataBound}
+ ref={gridRef}
+ >
+
+
+
+ getHealthBadge(props)}
+ />
+
+
+ {groups.length === 0 && (
+
Keine Gruppen gefunden.
+ )}
);
};
diff --git a/dashboard/src/hooks/useClientDelete.ts b/dashboard/src/hooks/useClientDelete.ts
new file mode 100644
index 0000000..11c576a
--- /dev/null
+++ b/dashboard/src/hooks/useClientDelete.ts
@@ -0,0 +1,36 @@
+import { useState } from 'react';
+import { deleteClient } from '../apiClients';
+
+export function useClientDelete(onDeleted?: (uuid: string) => void) {
+ const [showDialog, setShowDialog] = useState(false);
+ const [deleteClientId, setDeleteClientId] = useState(null);
+
+ // Details-Modal separat im Parent verwalten!
+
+ const handleDelete = (uuid: string) => {
+ setDeleteClientId(uuid);
+ setShowDialog(true);
+ };
+
+ const confirmDelete = async () => {
+ if (deleteClientId) {
+ await deleteClient(deleteClientId);
+ setShowDialog(false);
+ if (onDeleted) onDeleted(deleteClientId);
+ setDeleteClientId(null);
+ }
+ };
+
+ const cancelDelete = () => {
+ setShowDialog(false);
+ setDeleteClientId(null);
+ };
+
+ return {
+ showDialog,
+ deleteClientId,
+ handleDelete,
+ confirmDelete,
+ cancelDelete,
+ };
+}
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
index f9553e8..fa4492d 100644
--- a/server/Dockerfile.dev
+++ b/server/Dockerfile.dev
@@ -45,3 +45,4 @@ USER infoscreen_taa
# Default Command: Flask Development Server
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "wsgi.py"]
+# CMD ["sleep", "infinity"]
diff --git a/server/routes/clients.py b/server/routes/clients.py
index 9b8ccc3..71766e1 100644
--- a/server/routes/clients.py
+++ b/server/routes/clients.py
@@ -131,3 +131,70 @@ def get_client_group(uuid):
group_id = client.group_id
session.close()
return jsonify({"group_id": group_id})
+
+# Neue Route: Liefert alle Clients mit Alive-Status
+
+
+@clients_bp.route("/with_alive_status", methods=["GET"])
+def get_clients_with_alive_status():
+ session = Session()
+ clients = session.query(Client).all()
+ result = []
+ for c in clients:
+ result.append({
+ "uuid": c.uuid,
+ "description": c.description,
+ "ip": c.ip,
+ "last_alive": c.last_alive.isoformat() if c.last_alive else None,
+ "is_active": c.is_active,
+ "is_alive": bool(c.last_alive and c.is_active),
+ })
+ session.close()
+ return jsonify(result)
+
+
+@clients_bp.route("//restart", methods=["POST"])
+def restart_client(uuid):
+ """
+ Route to restart a specific client by UUID.
+ Sends an MQTT message to the broker to trigger the restart.
+ """
+ import paho.mqtt.client as mqtt
+ import json
+
+ # MQTT broker configuration
+ MQTT_BROKER = "mqtt"
+ MQTT_PORT = 1883
+ MQTT_TOPIC = f"clients/{uuid}/restart"
+
+ # Connect to the database to check if the client exists
+ session = Session()
+ client = session.query(Client).filter_by(uuid=uuid).first()
+ if not client:
+ session.close()
+ return jsonify({"error": "Client nicht gefunden"}), 404
+ session.close()
+
+ # Send MQTT message
+ try:
+ mqtt_client = mqtt.Client()
+ mqtt_client.connect(MQTT_BROKER, MQTT_PORT)
+ payload = {"action": "restart"}
+ mqtt_client.publish(MQTT_TOPIC, json.dumps(payload))
+ mqtt_client.disconnect()
+ return jsonify({"success": True, "message": f"Restart signal sent to client {uuid}"}), 200
+ except Exception as e:
+ return jsonify({"error": f"Failed to send MQTT message: {str(e)}"}), 500
+
+
+@clients_bp.route("/", methods=["DELETE"])
+def delete_client(uuid):
+ session = Session()
+ client = session.query(Client).filter_by(uuid=uuid).first()
+ if not client:
+ session.close()
+ return jsonify({"error": "Client nicht gefunden"}), 404
+ session.delete(client)
+ session.commit()
+ session.close()
+ return jsonify({"success": True})
diff --git a/server/routes/groups.py b/server/routes/groups.py
index cb26817..39da2bd 100644
--- a/server/routes/groups.py
+++ b/server/routes/groups.py
@@ -1,13 +1,45 @@
+from models.models import Client
+# Neue Route: Liefert alle Gruppen mit zugehörigen Clients und deren Alive-Status
+
from database import Session
from models.models import ClientGroup
from flask import Blueprint, request, jsonify
from sqlalchemy import func
import sys
+import os
+from datetime import datetime, timedelta
+
sys.path.append('/workspace')
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
+def get_grace_period():
+ """Wählt die Grace-Periode abhängig von ENV."""
+ env = os.environ.get("ENV", "production").lower()
+ if env == "development" or env == "dev":
+ return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_DEV", "15"))
+ return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "180"))
+
+
+def is_client_alive(last_alive, is_active):
+ """Berechnet, ob ein Client als alive gilt."""
+ if not last_alive or not is_active:
+ return False
+ grace_period = get_grace_period()
+ # last_alive kann ein String oder datetime sein
+ if isinstance(last_alive, str):
+ last_alive_str = last_alive[:-
+ 1] if last_alive.endswith('Z') else last_alive
+ try:
+ last_alive_dt = datetime.fromisoformat(last_alive_str)
+ except Exception:
+ return False
+ else:
+ last_alive_dt = last_alive
+ return datetime.utcnow() - last_alive_dt <= timedelta(seconds=grace_period)
+
+
@groups_bp.route("", methods=["POST"])
def create_group():
data = request.get_json()
@@ -127,3 +159,31 @@ def rename_group_by_name(old_name):
}
session.close()
return jsonify(result)
+
+
+@groups_bp.route("/with_clients", methods=["GET"])
+def get_groups_with_clients():
+ session = Session()
+ groups = session.query(ClientGroup).all()
+ result = []
+ for g in groups:
+ clients = session.query(Client).filter_by(group_id=g.id).all()
+ client_list = []
+ for c in clients:
+ client_list.append({
+ "uuid": c.uuid,
+ "description": c.description,
+ "ip": c.ip,
+ "last_alive": c.last_alive.isoformat() if c.last_alive else None,
+ "is_active": c.is_active,
+ "is_alive": is_client_alive(c.last_alive, c.is_active),
+ })
+ result.append({
+ "id": g.id,
+ "name": g.name,
+ "created_at": g.created_at.isoformat() if g.created_at else None,
+ "is_active": g.is_active,
+ "clients": client_list,
+ })
+ session.close()
+ return jsonify(result)