From 7f4800496ad0bc74bc164a9fb423d404cb52860e Mon Sep 17 00:00:00 2001 From: olaf Date: Tue, 22 Jul 2025 16:04:26 +0000 Subject: [PATCH] implement functionality to delete clients in clients and SetupMode components --- dashboard/package-lock.json | 118 ++++++++------ dashboard/package.json | 3 +- dashboard/src/App.css | 28 ++++ dashboard/src/App.tsx | 57 +++---- dashboard/src/SetupMode.tsx | 123 +++++++++++---- dashboard/src/apiClients.ts | 37 +++++ dashboard/src/clients.tsx | 95 ++++++++---- dashboard/src/dashboard.tsx | 205 +++++++++++++++++++++---- dashboard/src/hooks/useClientDelete.ts | 36 +++++ server/Dockerfile.dev | 1 + server/routes/clients.py | 67 ++++++++ server/routes/groups.py | 60 ++++++++ 12 files changed, 651 insertions(+), 179 deletions(-) create mode 100644 dashboard/src/hooks/useClientDelete.ts 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 (

Dashboard

-

Infoscreens

-
- {clients.map(client => ( -
-

{client.description || 'Unbekannter Standort'}

- {`Screenshot { - 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)