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-calendars": "^30.1.37",
"@syncfusion/ej2-react-dropdowns": "^30.1.37", "@syncfusion/ej2-react-dropdowns": "^30.1.37",
"@syncfusion/ej2-react-filemanager": "^30.1.38", "@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-inputs": "^30.1.38",
"@syncfusion/ej2-react-kanban": "^30.1.37", "@syncfusion/ej2-react-kanban": "^30.1.37",
"@syncfusion/ej2-react-layouts": "^30.1.40", "@syncfusion/ej2-react-layouts": "^30.1.40",
"@syncfusion/ej2-react-navigations": "^30.1.39",
"@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-notifications": "^30.1.37",
"@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37",
"@syncfusion/ej2-react-schedule": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37",
@@ -1027,9 +1028,9 @@
} }
}, },
"node_modules/@syncfusion/ej2-data": { "node_modules/@syncfusion/ej2-data": {
"version": "30.1.38", "version": "30.1.40",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-data/-/ej2-data-30.1.38.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-data/-/ej2-data-30.1.40.tgz",
"integrity": "sha512-BdqvjLzzK4OuUR1YlzPSG3SmeGg1mrLz/6ih5oD9dSpRXDoMG24bpO1rwCK7mjy8Dp9IJ8mliyCbPfoDycxM9Q==", "integrity": "sha512-jxQll5rn3yailAP/wcCnIw96ROfafwFzMJ6BIcI5FTn+/mk3hLQTp/GiZ/In7uDGgXQll2yQeolPJcOkILtWkw==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.38" "@syncfusion/ej2-base": "~30.1.38"
@@ -1084,50 +1085,64 @@
"@syncfusion/ej2-splitbuttons": "~30.1.37" "@syncfusion/ej2-splitbuttons": "~30.1.37"
} }
}, },
"node_modules/@syncfusion/ej2-filemanager/node_modules/@syncfusion/ej2-grids": { "node_modules/@syncfusion/ej2-grids": {
"version": "30.1.38", "version": "30.1.40",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.38.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.40.tgz",
"integrity": "sha512-0ULWC/P8AsYco3fhUfrkppEdU+IMzrIWyoP057/yp0Mktq9UI5mgvQ12ruZbEMQXl0vK5S5DKaWMDJU2vBTDWQ==", "integrity": "sha512-t6WHFhF2dD0vusdn9rEOFi8Opony/TDIFlu9iBRYd2g+RvKDzK4jo97qWQbAzOnbgvJGhSy3DLnVnIMwFdnqRw==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.38", "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-buttons": "~30.1.37", "@syncfusion/ej2-buttons": "~30.1.37",
"@syncfusion/ej2-calendars": "~30.1.37", "@syncfusion/ej2-calendars": "~30.1.37",
"@syncfusion/ej2-compression": "~30.1.37", "@syncfusion/ej2-compression": "~30.1.37",
"@syncfusion/ej2-data": "~30.1.38", "@syncfusion/ej2-data": "~30.1.40",
"@syncfusion/ej2-dropdowns": "~30.1.37", "@syncfusion/ej2-dropdowns": "~30.1.40",
"@syncfusion/ej2-excel-export": "~30.1.37", "@syncfusion/ej2-excel-export": "~30.1.37",
"@syncfusion/ej2-file-utils": "~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-lists": "~30.1.37",
"@syncfusion/ej2-navigations": "~30.1.37", "@syncfusion/ej2-navigations": "~30.1.39",
"@syncfusion/ej2-notifications": "~30.1.37", "@syncfusion/ej2-notifications": "~30.1.37",
"@syncfusion/ej2-pdf-export": "~30.1.38", "@syncfusion/ej2-pdf-export": "~30.1.38",
"@syncfusion/ej2-popups": "~30.1.37", "@syncfusion/ej2-popups": "~30.1.40",
"@syncfusion/ej2-splitbuttons": "~30.1.37" "@syncfusion/ej2-splitbuttons": "~30.1.39"
} }
}, },
"node_modules/@syncfusion/ej2-grids": { "node_modules/@syncfusion/ej2-grids/node_modules/@syncfusion/ej2-dropdowns": {
"version": "30.1.37", "version": "30.1.40",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.1.37.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-dropdowns/-/ej2-dropdowns-30.1.40.tgz",
"integrity": "sha512-EG8RzzPCML9UULAN+SZdo/G5j8AsmzKzwQvocvZoL/YOfDPWom8WaGXNbE5pssMmIEnF2YnQkMuwRWg1tLHyog==", "integrity": "sha512-8vpO0+X4OnA+CLPfVBqOBon2AbDHXUTK5UdP/A2JEJQw0ktNpURm8ux3K1VT9OXvYmuNgyN/WrM6Pf5m3Oexkw==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.37", "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-buttons": "~30.1.37", "@syncfusion/ej2-data": "~30.1.40",
"@syncfusion/ej2-calendars": "~30.1.37", "@syncfusion/ej2-inputs": "~30.1.40",
"@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-lists": "~30.1.37", "@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-notifications": "~30.1.37",
"@syncfusion/ej2-pdf-export": "~30.1.37", "@syncfusion/ej2-popups": "~30.1.40"
"@syncfusion/ej2-popups": "~30.1.37", }
"@syncfusion/ej2-splitbuttons": "~30.1.37" },
"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": { "node_modules/@syncfusion/ej2-icons": {
@@ -1187,15 +1202,15 @@
} }
}, },
"node_modules/@syncfusion/ej2-navigations": { "node_modules/@syncfusion/ej2-navigations": {
"version": "30.1.37", "version": "30.1.39",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-navigations/-/ej2-navigations-30.1.37.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-navigations/-/ej2-navigations-30.1.39.tgz",
"integrity": "sha512-FD3hsT4a8fMdS3S7EFu9tnu741980HRK97fDAUgUtLGqDANC6Ee2HyeZZTjVeUpVAHfdomb/n7oYvtDUXZxwYw==", "integrity": "sha512-mehYOsIcgGEMHbSwdnxxSqcPwhyQpVCJ6VgLESBikkTIww3NAFwRkhMcs4o4n0z+3KyySUVKNi1zU13mzlYkoQ==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.37", "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-buttons": "~30.1.37", "@syncfusion/ej2-buttons": "~30.1.37",
"@syncfusion/ej2-data": "~30.1.37", "@syncfusion/ej2-data": "~30.1.38",
"@syncfusion/ej2-inputs": "~30.1.37", "@syncfusion/ej2-inputs": "~30.1.38",
"@syncfusion/ej2-lists": "~30.1.37", "@syncfusion/ej2-lists": "~30.1.37",
"@syncfusion/ej2-popups": "~30.1.37" "@syncfusion/ej2-popups": "~30.1.37"
} }
@@ -1284,13 +1299,13 @@
} }
}, },
"node_modules/@syncfusion/ej2-react-grids": { "node_modules/@syncfusion/ej2-react-grids": {
"version": "30.1.37", "version": "30.1.40",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-grids/-/ej2-react-grids-30.1.37.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-grids/-/ej2-react-grids-30.1.40.tgz",
"integrity": "sha512-QNnvwD88GYezX9glpGUEltFmUCKZORXz7w93BS2CfgTxsjJjdaZ8xzDi98J+aAUrUh6I44pMut5Sfb9QOBFH+g==", "integrity": "sha512-NaqR/r8yN1tomNkp4X5lUQe55wLmtKBvX2Qj0Sm6+SAb2taRR2v2MSKX+MHWToV1cGpUhmVDKo/PikbW98r2eA==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.37", "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-grids": "30.1.37", "@syncfusion/ej2-grids": "30.1.40",
"@syncfusion/ej2-react-base": "~30.1.37" "@syncfusion/ej2-react-base": "~30.1.37"
} }
}, },
@@ -1327,6 +1342,17 @@
"@syncfusion/ej2-react-base": "~30.1.37" "@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": { "node_modules/@syncfusion/ej2-react-notifications": {
"version": "30.1.37", "version": "30.1.37",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz", "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": { "node_modules/@syncfusion/ej2-splitbuttons": {
"version": "30.1.37", "version": "30.1.39",
"resolved": "https://registry.npmjs.org/@syncfusion/ej2-splitbuttons/-/ej2-splitbuttons-30.1.37.tgz", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-splitbuttons/-/ej2-splitbuttons-30.1.39.tgz",
"integrity": "sha512-V4MNbw8qBNumd7B4AlbmndxXGqILcSUoCrA+wBfLt6KZArZnuK8Y2zaD7fN9xoR1c/hoxlgo9sXyIxdPfi9Lyg==", "integrity": "sha512-MViD8imxEHukX7tvtHWquc76+pL95NB86YPUFb2lx3gxwLUBTIA/KVS2ZmAlQXYaFI7RZQPGopgIbwfb/Zhz5A==",
"license": "SEE LICENSE IN license", "license": "SEE LICENSE IN license",
"dependencies": { "dependencies": {
"@syncfusion/ej2-base": "~30.1.37", "@syncfusion/ej2-base": "~30.1.38",
"@syncfusion/ej2-popups": "~30.1.37" "@syncfusion/ej2-popups": "~30.1.37"
} }
}, },

View File

@@ -14,10 +14,11 @@
"@syncfusion/ej2-react-calendars": "^30.1.37", "@syncfusion/ej2-react-calendars": "^30.1.37",
"@syncfusion/ej2-react-dropdowns": "^30.1.37", "@syncfusion/ej2-react-dropdowns": "^30.1.37",
"@syncfusion/ej2-react-filemanager": "^30.1.38", "@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-inputs": "^30.1.38",
"@syncfusion/ej2-react-kanban": "^30.1.37", "@syncfusion/ej2-react-kanban": "^30.1.37",
"@syncfusion/ej2-react-layouts": "^30.1.40", "@syncfusion/ej2-react-layouts": "^30.1.40",
"@syncfusion/ej2-react-navigations": "^30.1.39",
"@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-notifications": "^30.1.37",
"@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37",
"@syncfusion/ej2-react-schedule": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37",

View File

@@ -25,11 +25,22 @@ body {
--sidebar-border: #d6c3a6; --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 { .sidebar-theme {
background-color: var(--sidebar-bg); background-color: var(--sidebar-bg);
color: var(--sidebar-fg); color: var(--sidebar-fg);
font-size: 1.15rem; font-size: 1.15rem;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; 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 { .sidebar-theme .sidebar-link {
@@ -63,6 +74,16 @@ body {
color: var(--sidebar-bg); 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 */ /* Kanban-Karten im Sidebar-Style */
.e-kanban .e-card, .e-kanban .e-card,
.e-kanban .e-card .e-card-content, .e-kanban .e-card .e-card-content,
@@ -106,4 +127,11 @@ body {
color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important; 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 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 logo from './assets/logo.png';
import './App.css'; import './App.css';
@@ -30,17 +36,25 @@ const sidebarItems = [
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings }, { name: 'Einstellungen', path: '/einstellungen', icon: Settings },
]; ];
import { useEffect } from 'react'; // Dummy Components (können in eigene Dateien ausgelagert werden)
import { useNavigate } from 'react-router-dom'; 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) // 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 Layout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (
<div className="flex min-h-screen"> <div className="layout-container">
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={`sidebar-theme flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-30'}`} 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" className="sidebar-btn p-2 focus:outline-none transition-colors"
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'} aria-label={collapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
type="button"
> >
<span style={{ fontSize: 20 }}>{collapsed ? '▶' : '◀'}</span> <span style={{ fontSize: 20 }}>{collapsed ? '▶' : '◀'}</span>
</button> </button>
@@ -88,6 +103,7 @@ const Layout: React.FC = () => {
// Hier ggf. Logout-Logik einfügen // Hier ggf. Logout-Logik einfügen
window.location.href = '/logout'; window.location.href = '/logout';
}} }}
type="button"
> >
<LogOut size={22} /> <LogOut size={22} />
{!collapsed && 'Abmelden'} {!collapsed && 'Abmelden'}
@@ -119,7 +135,7 @@ const Layout: React.FC = () => {
[Organisationsname] [Organisationsname]
</span> </span>
</header> </header>
<main className="flex-1 p-8 bg-gray-100"> <main className="flex-1 p-8 bg-gray-100 page-content-scroll">
<Outlet /> <Outlet />
</main> </main>
</div> </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 App: React.FC = () => {
const navigate = useNavigate();
const isLoggedIn = useLoginCheck();
// Automatische Navigation zu /clients bei leerer Beschreibung entfernt // Automatische Navigation zu /clients bei leerer Beschreibung entfernt
@@ -176,14 +174,3 @@ const AppWrapper: React.FC = () => (
); );
export default AppWrapper; 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 { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids'; import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
import { useClientDelete } from './hooks/useClientDelete';
type Client = { type Client = {
uuid: string; uuid: string;
@@ -14,10 +16,24 @@ type Client = {
const SetupMode: React.FC = () => { const SetupMode: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
const [descriptions, setDescriptions] = useState<Record<string, string>>({}); const [descriptions, setDescriptions] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading /* setLoading */] = useState(false);
const [inputActive, setInputActive] = useState(false);
useEffect(() => { // Lösch-Logik aus Hook (analog zu clients.tsx)
let polling: ReturnType<typeof setInterval> | null = null; 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[]) => { const isEqual = (a: Client[], b: Client[]) => {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid)); const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid));
@@ -30,31 +46,36 @@ const SetupMode: React.FC = () => {
} }
return true; return true;
}; };
let firstLoad = true;
useEffect(() => {
let polling: ReturnType<typeof setInterval> | null = null;
const fetchClients = () => { const fetchClients = () => {
if (firstLoad) setLoading(true); if (inputActive) return;
fetchClientsWithoutDescription().then(list => { fetchClientsWithoutDescription().then(list => {
if (firstLoad) {
setLoading(false);
firstLoad = false;
}
setClients(prev => (isEqual(prev, list) ? prev : list)); setClients(prev => (isEqual(prev, list) ? prev : list));
}); });
}; };
fetchClients(); fetchClients();
polling = setInterval(fetchClients, 5000); // alle 5 Sekunden polling = setInterval(fetchClients, 5000);
return () => { return () => {
if (polling) clearInterval(polling); if (polling) clearInterval(polling);
}; };
}, []); }, [inputActive]);
const handleDescriptionChange = (uuid: string, value: string) => { const handleDescriptionChange = (uuid: string, value: string) => {
setDescriptions(prev => ({ ...prev, [uuid]: value })); setDescriptions(prev => ({ ...prev, [uuid]: value }));
}; };
const handleSave = (uuid: string) => { const handleSave = (uuid: string) => {
setClientDescription(uuid, descriptions[uuid] || '').then(() => { setClientDescription(uuid, descriptions[uuid] || '')
.then(() => {
setClients(prev => prev.filter(c => c.uuid !== uuid)); setClients(prev => prev.filter(c => c.uuid !== uuid));
})
.catch(err => {
console.error('Fehler beim Speichern der Beschreibung:', err);
}); });
}; };
@@ -68,14 +89,16 @@ const SetupMode: React.FC = () => {
allowPaging={true} allowPaging={true}
pageSettings={{ pageSize: 10 }} pageSettings={{ pageSize: 10 }}
rowHeight={50} rowHeight={50}
width="100%"
allowTextWrap={false}
> >
<ColumnsDirective> <ColumnsDirective>
<ColumnDirective field="uuid" headerText="UUID" width="180" /> <ColumnDirective field="uuid" headerText="UUID" width="180" />
<ColumnDirective field="hostname" headerText="Hostname" width="140" /> <ColumnDirective field="hostname" headerText="Hostname" width="90" />
<ColumnDirective field="ip" headerText="IP" width="120" /> <ColumnDirective field="ip_address" headerText="IP" width="80" />
<ColumnDirective <ColumnDirective
headerText="Letzter Kontakt" headerText="Letzter Kontakt"
width="160" width="120"
template={(props: Client) => { template={(props: Client) => {
if (!props.last_alive) return ''; if (!props.last_alive) return '';
let iso = props.last_alive; let iso = props.last_alive;
@@ -93,23 +116,57 @@ const SetupMode: React.FC = () => {
value={descriptions[props.uuid] || ''} value={descriptions[props.uuid] || ''}
placeholder="Beschreibung eingeben" placeholder="Beschreibung eingeben"
change={e => handleDescriptionChange(props.uuid, e.value as string)} change={e => handleDescriptionChange(props.uuid, e.value as string)}
focus={() => setInputActive(true)}
blur={() => setInputActive(false)}
/> />
)} )}
/> />
<ColumnDirective <ColumnDirective
headerText="Aktion" headerText="Aktion"
width="120" width="180"
template={(props: Client) => ( template={(props: Client) => (
<div style={{ display: 'flex', gap: '8px' }}>
<ButtonComponent <ButtonComponent
content="Speichern" content="Speichern"
disabled={!descriptions[props.uuid]} disabled={!descriptions[props.uuid]}
onClick={() => handleSave(props.uuid)} onClick={() => handleSave(props.uuid)}
/> />
<ButtonComponent
content="Entfernen"
cssClass="e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
/>
</div>
)} )}
/> />
</ColumnsDirective> </ColumnsDirective>
</GridComponent> </GridComponent>
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>} {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> </div>
); );
}; };

View File

@@ -13,6 +13,24 @@ export interface Client {
last_alive?: string; last_alive?: string;
is_active?: boolean; is_active?: boolean;
group_id?: number; 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[]> { 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'); if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients');
return await res.json(); 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 SetupModeButton from './components/SetupModeButton';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
// ...ButtonComponent entfernt... import { useClientDelete } from './hooks/useClientDelete';
// Card-Komponente wird als eigenes Layout umgesetzt
import { fetchClients, updateClient } from './apiClients'; import { fetchClients, updateClient } from './apiClients';
import type { Client } from './apiClients'; import type { Client } from './apiClients';
import { import {
@@ -15,6 +14,7 @@ import {
Sort, Sort,
Edit, Edit,
} from '@syncfusion/ej2-react-grids'; } from '@syncfusion/ej2-react-grids';
import { DialogComponent } from '@syncfusion/ej2-react-popups';
// Raumgruppen werden dynamisch aus der API geladen // 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' (value as string).endsWith('Z') ? (value as string) : value + 'Z'
).toLocaleString() ).toLocaleString()
: key === 'last_alive' && value : key === 'last_alive' && value
? new Date( ? String(value) // Wert direkt anzeigen, nicht erneut parsen
(value as string).endsWith('Z') ? (value as string) : value + 'Z'
).toLocaleString()
: String(value)} : String(value)}
</td> </td>
</tr> </tr>
@@ -111,7 +109,11 @@ function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProp
const Clients: React.FC = () => { const Clients: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
const [groups, setGroups] = useState<{ id: number; name: string }[]>([]); 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(() => { useEffect(() => {
fetchClients().then(setClients); 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) => ( const detailsButtonTemplate = (props: Client) => (
<div style={{ display: 'flex', gap: '8px' }}>
<button <button
className="e-btn e-primary" className="e-btn e-primary"
onClick={() => setSelectedClient(props)} onClick={() => setDetailsClient(props)}
style={{ minWidth: 80 }} style={{ minWidth: 80 }}
> >
Details Details
</button> </button>
<button
className="e-btn e-danger"
onClick={e => {
e.stopPropagation();
handleDelete(props.uuid);
}}
style={{ minWidth: 80 }}
>
Entfernen
</button>
</div>
); );
return ( return (
@@ -169,9 +183,16 @@ const Clients: React.FC = () => {
allowDeleting: false, allowDeleting: false,
mode: 'Normal', mode: 'Normal',
}} }}
actionComplete={async (args: { requestType: string; data: any }) => { actionComplete={async (args: {
requestType: string;
data: Record<string, unknown>;
}) => {
if (args.requestType === 'save') { 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 // API-Aufruf zum Speichern
await updateClient(uuid, { description, model }); await updateClient(uuid, { description, model });
// Nach dem Speichern neu laden // Nach dem Speichern neu laden
@@ -183,32 +204,27 @@ const Clients: React.FC = () => {
<ColumnDirective <ColumnDirective
field="description" field="description"
headerText="Beschreibung" headerText="Beschreibung"
width="150"
allowEditing={true} allowEditing={true}
width="180"
/> />
<ColumnDirective <ColumnDirective
field="group_name" field="group_name"
headerText="Raumgruppe" 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} allowEditing={false}
width="140"
/> />
<ColumnDirective field="uuid" headerText="UUID" allowEditing={false} width="160" />
<ColumnDirective field="ip" headerText="IP-Adresse" allowEditing={false} width="80" />
<ColumnDirective <ColumnDirective
field="last_alive" field="last_alive"
headerText="Last Alive" headerText="Last Alive"
width="120"
allowEditing={false} allowEditing={false}
width="120"
/> />
<ColumnDirective field="model" headerText="Model" width="120" allowEditing={true} /> <ColumnDirective field="model" headerText="Model" allowEditing={true} width="120" />
<ColumnDirective <ColumnDirective
headerText="Details" headerText="Aktion"
width="100" width="190"
template={detailsButtonTemplate} template={detailsButtonTemplate}
textAlign="Center" textAlign="Center"
allowEditing={false} allowEditing={false}
@@ -217,15 +233,30 @@ const Clients: React.FC = () => {
<Inject services={[Page, Toolbar, Search, Sort, Edit]} /> <Inject services={[Page, Toolbar, Search, Sort, Edit]} />
</GridComponent> </GridComponent>
<DetailsModal <DetailsModal
open={!!selectedClient} open={!!detailsClient}
client={selectedClient} client={detailsClient}
groupIdToName={groupIdToName} groupIdToName={groupIdToName}
onClose={() => setSelectedClient(null)} onClose={() => setDetailsClient(null)}
/> />
</> </>
) : ( ) : (
<div className="text-gray-500">Raumgruppen werden geladen ...</div> <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> </div>
); );
}; };

View File

@@ -1,48 +1,189 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { fetchClients, fetchClientsWithoutDescription } from './apiClients'; import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Client } 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 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(() => { 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 ( return (
<div> <div>
<header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]"> <header className="mb-8 pb-4 border-b-2 border-[#d6c3a6]">
<h2 className="text-3xl font-extrabold mb-2">Dashboard</h2> <h2 className="text-3xl font-extrabold mb-2">Dashboard</h2>
</header> </header>
<h3 className="text-lg font-semibold mt-6 mb-4">Infoscreens</h3> <h3 className="text-lg font-semibold mt-6 mb-4">Raumgruppen Übersicht</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <GridComponent
{clients.map(client => ( dataSource={groups}
<div key={client.uuid} className="bg-white rounded shadow p-4 flex flex-col items-center"> allowPaging={true}
<h4 className="text-lg font-bold mb-2">{client.description || 'Unbekannter Standort'}</h4> pageSettings={{ pageSize: 5 }}
<img height={400}
src={`/screenshots/${client.uuid}`} detailTemplate={(props: Group) => getClientTable(props)}
alt={`Screenshot ${client.description || 'Unbekannt'}`} detailDataBound={onDetailDataBound}
className="w-full h-48 object-contain bg-gray-100 mb-2" ref={gridRef}
onError={e => { >
e.currentTarget.onerror = null; // verhindert Endlosschleife <Inject services={[Page, DetailRow]} />
e.currentTarget.src = "https://placehold.co/400x300?text=No+Screenshot"; <ColumnsDirective>
}} <ColumnDirective field="name" headerText="Raumgruppe" width="180" />
<ColumnDirective
headerText="Health"
width="160"
template={(props: any) => getHealthBadge(props)}
/> />
<div className="text-sm text-gray-700 mb-1"> </ColumnsDirective>
<span className="font-semibold">IP:</span> {client.ip} </GridComponent>
</div> {groups.length === 0 && (
<div className="text-sm text-gray-700"> <div className="col-span-full text-center text-gray-400">Keine Gruppen gefunden.</div>
<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> </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 # Default Command: Flask Development Server
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "wsgi.py"] 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 group_id = client.group_id
session.close() session.close()
return jsonify({"group_id": group_id}) 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 database import Session
from models.models import ClientGroup from models.models import ClientGroup
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from sqlalchemy import func from sqlalchemy import func
import sys import sys
import os
from datetime import datetime, timedelta
sys.path.append('/workspace') sys.path.append('/workspace')
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") 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"]) @groups_bp.route("", methods=["POST"])
def create_group(): def create_group():
data = request.get_json() data = request.get_json()
@@ -127,3 +159,31 @@ def rename_group_by_name(old_name):
} }
session.close() session.close()
return jsonify(result) 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)