implement functionality to delete clients in

clients and SetupMode components
This commit is contained in:
2025-07-22 16:04:26 +00:00
parent c0202e5802
commit 7f4800496a
12 changed files with 651 additions and 179 deletions

View File

@@ -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"
}
},

View File

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

View File

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

View File

@@ -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 (
<div className="flex min-h-screen">
<div className="layout-container">
{/* Sidebar */}
<aside
className={`sidebar-theme flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-30'}`}
@@ -60,6 +74,7 @@ const Layout: React.FC = () => {
className="sidebar-btn p-2 focus:outline-none transition-colors"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
type="button"
>
<span style={{ fontSize: 20 }}>{collapsed ? '▶' : '◀'}</span>
</button>
@@ -88,6 +103,7 @@ const Layout: React.FC = () => {
// Hier ggf. Logout-Logik einfügen
window.location.href = '/logout';
}}
type="button"
>
<LogOut size={22} />
{!collapsed && 'Abmelden'}
@@ -119,7 +135,7 @@ const Layout: React.FC = () => {
[Organisationsname]
</span>
</header>
<main className="flex-1 p-8 bg-gray-100">
<main className="flex-1 p-8 bg-gray-100 page-content-scroll">
<Outlet />
</main>
</div>
@@ -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';

View File

@@ -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<Client[]>([]);
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
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<typeof setInterval> | 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 <div>Lade neue Clients ...</div>;
@@ -68,14 +89,16 @@ const SetupMode: React.FC = () => {
allowPaging={true}
pageSettings={{ pageSize: 10 }}
rowHeight={50}
width="100%"
allowTextWrap={false}
>
<ColumnsDirective>
<ColumnDirective field="uuid" headerText="UUID" width="180" />
<ColumnDirective field="hostname" headerText="Hostname" width="140" />
<ColumnDirective field="ip" headerText="IP" width="120" />
<ColumnDirective field="hostname" headerText="Hostname" width="90" />
<ColumnDirective field="ip_address" headerText="IP" width="80" />
<ColumnDirective
headerText="Letzter Kontakt"
width="160"
width="120"
template={(props: Client) => {
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)}
/>
)}
/>
<ColumnDirective
headerText="Aktion"
width="120"
width="180"
template={(props: Client) => (
<ButtonComponent
content="Speichern"
disabled={!descriptions[props.uuid]}
onClick={() => handleSave(props.uuid)}
/>
<div style={{ display: 'flex', gap: '8px' }}>
<ButtonComponent
content="Speichern"
disabled={!descriptions[props.uuid]}
onClick={() => handleSave(props.uuid)}
/>
<ButtonComponent
content="Entfernen"
cssClass="e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
/>
</div>
)}
/>
</ColumnsDirective>
</GridComponent>
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>}
{/* Syncfusion Dialog für Sicherheitsabfrage */}
{showDialog && deleteClientId && (
<DialogComponent
visible={showDialog}
header="Bestätigung"
content={(() => {
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}
/>
)}
</div>
);
};

View File

@@ -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<Group[]> {
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<Client[]> {
@@ -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();
}

View File

@@ -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)}
</td>
</tr>
@@ -111,7 +109,11 @@ function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProp
const Clients: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]);
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [detailsClient, setDetailsClient] = useState<Client | null>(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) => (
<button
className="e-btn e-primary"
onClick={() => setSelectedClient(props)}
style={{ minWidth: 80 }}
>
Details
</button>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="e-btn e-primary"
onClick={() => setDetailsClient(props)}
style={{ minWidth: 80 }}
>
Details
</button>
<button
className="e-btn e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
style={{ minWidth: 80 }}
>
Entfernen
</button>
</div>
);
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<string, unknown>;
}) => {
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 = () => {
<ColumnDirective
field="description"
headerText="Beschreibung"
width="150"
allowEditing={true}
width="180"
/>
<ColumnDirective
field="group_name"
headerText="Raumgruppe"
width="120"
allowEditing={false}
/>
<ColumnDirective field="uuid" headerText="UUID" width="220" allowEditing={false} />
<ColumnDirective
field="ip"
headerText="IP-Adresse"
width="150"
allowEditing={false}
width="140"
/>
<ColumnDirective field="uuid" headerText="UUID" allowEditing={false} width="160" />
<ColumnDirective field="ip" headerText="IP-Adresse" allowEditing={false} width="80" />
<ColumnDirective
field="last_alive"
headerText="Last Alive"
width="120"
allowEditing={false}
width="120"
/>
<ColumnDirective field="model" headerText="Model" width="120" allowEditing={true} />
<ColumnDirective field="model" headerText="Model" allowEditing={true} width="120" />
<ColumnDirective
headerText="Details"
width="100"
headerText="Aktion"
width="190"
template={detailsButtonTemplate}
textAlign="Center"
allowEditing={false}
@@ -217,15 +233,30 @@ const Clients: React.FC = () => {
<Inject services={[Page, Toolbar, Search, Sort, Edit]} />
</GridComponent>
<DetailsModal
open={!!selectedClient}
client={selectedClient}
open={!!detailsClient}
client={detailsClient}
groupIdToName={groupIdToName}
onClose={() => setSelectedClient(null)}
onClose={() => setDetailsClient(null)}
/>
</>
) : (
<div className="text-gray-500">Raumgruppen werden geladen ...</div>
)}
{/* DialogComponent für Bestätigung */}
{showDialog && deleteClientId && (
<DialogComponent
visible={showDialog}
header="Bestätigung"
content="Möchten Sie diesen Client wirklich entfernen?"
showCloseIcon={true}
width="400px"
buttons={[
{ click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } },
{ click: cancelDelete, buttonModel: { content: 'Abbrechen' } },
]}
close={cancelDelete}
/>
)}
</div>
);
};

View File

@@ -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<Client[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
const gridRef = useRef<any>(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 <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: 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 (
<div>
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
</header>
<h3 className="text-lg font-semibold mt-6 mb-4">Infoscreens</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{clients.map(client => (
<div key={client.uuid} className="bg-white rounded shadow p-4 flex flex-col items-center">
<h4 className="text-lg font-bold mb-2">{client.description || 'Unbekannter Standort'}</h4>
<img
src={`/screenshots/${client.uuid}`}
alt={`Screenshot ${client.description || 'Unbekannt'}`}
className="w-full h-48 object-contain bg-gray-100 mb-2"
onError={e => {
e.currentTarget.onerror = null; // verhindert Endlosschleife
e.currentTarget.src = "https://placehold.co/400x300?text=No+Screenshot";
}}
/>
<div className="text-sm text-gray-700 mb-1">
<span className="font-semibold">IP:</span> {client.ip}
</div>
<div className="text-sm text-gray-700">
<span className="font-semibold">Letztes Lebenszeichen:</span>{' '}
{client.last_alive ? new Date(client.last_alive).toLocaleString() : '-'}
</div>
</div>
))}
{clients.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Clients gefunden.</div>
)}
</div>
<h3 className="text-lg font-semibold mt-6 mb-4">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: any) => getHealthBadge(props)}
/>
</ColumnsDirective>
</GridComponent>
{groups.length === 0 && (
<div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
)}
</div>
);
};

View File

@@ -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<string | null>(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,
};
}

View File

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

View File

@@ -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("/<uuid>/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("/<uuid>", 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})

View File

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