Automatic detect of clients

This commit is contained in:
2025-07-17 06:31:50 +00:00
parent 1a6faaa104
commit 4e525e4bae
10 changed files with 367 additions and 36 deletions

View File

@@ -14,6 +14,7 @@ import {
Monitor, Monitor,
MonitorDotIcon, MonitorDotIcon,
LogOut, LogOut,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { ToastProvider } from './components/ToastProvider'; import { ToastProvider } from './components/ToastProvider';
@@ -22,7 +23,8 @@ const sidebarItems = [
{ name: 'Termine', path: '/termine', icon: Calendar }, { name: 'Termine', path: '/termine', icon: Calendar },
{ name: 'Ressourcen', path: '/ressourcen', icon: Boxes }, { name: 'Ressourcen', path: '/ressourcen', icon: Boxes },
{ name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon }, { name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon },
{ name: 'Infoscreens', path: '/Infoscreens', icon: Monitor }, { name: 'Infoscreens', path: '/clients', icon: Monitor },
{ name: 'Erweiterungsmodus', path: '/setup', icon: Wrench },
{ name: 'Medien', path: '/medien', icon: Image }, { name: 'Medien', path: '/medien', icon: Image },
{ name: 'Benutzer', path: '/benutzer', icon: User }, { name: 'Benutzer', path: '/benutzer', icon: User },
{ name: 'Einstellungen', path: '/einstellungen', icon: Settings }, { name: 'Einstellungen', path: '/einstellungen', icon: Settings },
@@ -119,26 +121,72 @@ const Layout: React.FC = () => {
); );
}; };
const App: React.FC = () => ( import { useEffect } from 'react';
<Router> import { useNavigate } from 'react-router-dom';
import { fetchClientsWithoutDescription } from './apiClients';
// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API)
const ENV = import.meta.env.VITE_ENV || 'development';
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();
useEffect(() => {
if (!isLoggedIn) return;
fetchClientsWithoutDescription().then(list => {
if (list.length > 0) {
console.log('[Navigation] Weiterleitung zu /clients wegen fehlender Beschreibung');
navigate('/clients');
} else {
console.log('[Navigation] Dashboard wird angezeigt, alle Clients haben Beschreibung');
}
});
}, [isLoggedIn, navigate]);
return (
<ToastProvider> <ToastProvider>
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="termine" element={<Appointments />} /> <Route path="termine" element={<Appointments />} />
<Route path="ressourcen" element={<Ressourcen />} /> <Route path="ressourcen" element={<Ressourcen />} />
<Route path="Infoscreens" element={<Infoscreens />} />
<Route path="infoscr_groups" element={<Infoscreen_groups />} /> <Route path="infoscr_groups" element={<Infoscreen_groups />} />
<Route path="medien" element={<Media />} /> <Route path="medien" element={<Media />} />
<Route path="benutzer" element={<Benutzer />} /> <Route path="benutzer" element={<Benutzer />} />
<Route path="einstellungen" element={<Einstellungen />} /> <Route path="einstellungen" element={<Einstellungen />} />
<Route path="clients" element={<Infoscreens />} />
<Route path="setup" element={<SetupMode />} />
</Route> </Route>
</Routes> </Routes>
</ToastProvider> </ToastProvider>
);
};
const AppWrapper: React.FC = () => (
<Router>
<App />
</Router> </Router>
); );
export default App; export default AppWrapper;
// Dummy Components (können in eigene Dateien ausgelagert werden) // Dummy Components (können in eigene Dateien ausgelagert werden)
import Dashboard from './dashboard'; import Dashboard from './dashboard';
@@ -149,3 +197,4 @@ import Infoscreen_groups from './infoscreen_groups';
import Media from './media'; import Media from './media';
import Benutzer from './benutzer'; import Benutzer from './benutzer';
import Einstellungen from './einstellungen'; import Einstellungen from './einstellungen';
import SetupMode from './SetupMode';

106
dashboard/src/SetupMode.tsx Normal file
View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { fetchClientsWithoutDescription, setClientDescription } from './apiClients';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids';
type Client = {
uuid: string;
hostname?: string;
ip_address?: string;
last_alive?: string;
};
const SetupMode: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
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);
fetchClientsWithoutDescription().then(list => {
if (firstLoad) {
setLoading(false);
firstLoad = false;
}
setClients(prev => (isEqual(prev, list) ? prev : list));
});
};
fetchClients();
polling = setInterval(fetchClients, 5000); // alle 5 Sekunden
return () => {
if (polling) clearInterval(polling);
};
}, []);
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));
});
};
if (loading) return <div>Lade neue Clients ...</div>;
return (
<div>
<h2>Erweiterungsmodus: Neue Clients zuordnen</h2>
<GridComponent
dataSource={clients}
allowPaging={true}
pageSettings={{ pageSize: 10 }}
rowHeight={50}
>
<ColumnsDirective>
<ColumnDirective field="uuid" headerText="UUID" width="180" />
<ColumnDirective field="hostname" headerText="Hostname" width="140" />
<ColumnDirective field="ip_address" headerText="IP" width="120" />
<ColumnDirective field="last_alive" headerText="Letzter Kontakt" width="160" />
<ColumnDirective
headerText="Beschreibung"
width="220"
template={props => (
<TextBoxComponent
value={descriptions[props.uuid] || ''}
placeholder="Beschreibung eingeben"
change={e => handleDescriptionChange(props.uuid, e.value as string)}
/>
)}
/>
<ColumnDirective
headerText="Aktion"
width="120"
template={props => (
<ButtonComponent
content="Speichern"
disabled={!descriptions[props.uuid]}
onClick={() => handleSave(props.uuid)}
/>
)}
/>
</ColumnsDirective>
</GridComponent>
{clients.length === 0 && <div>Keine neuen Clients ohne Beschreibung.</div>}
</div>
);
};
export default SetupMode;

View File

@@ -1,12 +1,19 @@
// Funktion zum Laden der Clients von der API
export interface Client { export interface Client {
uuid: string; uuid: string;
location: string; hardware_token?: string;
hardware_hash: string; ip?: string;
ip_address: string; type?: string;
last_alive: string | null; hostname?: string;
group_id: number; // <--- Dieses Feld ergänzen os_version?: string;
software_version?: string;
macs?: string;
model?: string;
description?: string;
registration_time?: string;
last_alive?: string;
is_active?: boolean;
group_id?: number;
} }
export async function fetchClients(): Promise<Client[]> { export async function fetchClients(): Promise<Client[]> {
@@ -17,6 +24,24 @@ export async function fetchClients(): Promise<Client[]> {
return await response.json(); return await response.json();
} }
export async function fetchClientsWithoutDescription(): Promise<Client[]> {
const response = await fetch('/api/clients/without_description');
if (!response.ok) {
throw new Error('Fehler beim Laden der Clients ohne Beschreibung');
}
return await response.json();
}
export async function setClientDescription(uuid: string, description: string) {
const res = await fetch(`/api/clients/${uuid}/description`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description }),
});
if (!res.ok) throw new Error('Fehler beim Setzen der Beschreibung');
return await res.json();
}
export async function updateClientGroup(clientIds: string[], groupName: string) { export async function updateClientGroup(clientIds: string[], groupName: string) {
const res = await fetch('/api/clients/group', { const res = await fetch('/api/clients/group', {
method: 'PUT', method: 'PUT',

View File

@@ -1,8 +1,62 @@
import React from 'react'; import SetupModeButton from './components/SetupModeButton';
const Infoscreens: React.FC = () => ( import React, { useEffect, useState } from 'react';
<div> import { fetchClients, fetchClientsWithoutDescription, setClientDescription } from './apiClients';
<h2 className="text-xl font-bold mb-4">Infoscreens</h2> import type { Client } from './apiClients';
<p>Willkommen im Infoscreen-Management Infoscreens.</p>
// Dummy Modalbox (ersetzbar durch SyncFusion Dialog)
function ModalBox({ open, onClose }) {
if (!open) return null;
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.3)', zIndex: 1000 }}>
<div style={{ background: 'white', padding: 32, margin: '100px auto', maxWidth: 400, borderRadius: 8 }}>
<h3>Neue Clients ohne Beschreibung!</h3>
<p>Bitte ergänzen Sie die Beschreibung für neue Clients.</p>
<button onClick={onClose}>Speichern (Dummy)</button>
</div> </div>
); </div>
export default Infoscreens; );
}
const Clients: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
fetchClients().then(setClients);
fetchClientsWithoutDescription().then(list => {
if (list.length > 0) setShowModal(true);
});
}, []);
return (
<div>
<h2 className="text-xl font-bold mb-4">Client-Übersicht</h2>
<table className="min-w-full border">
<thead>
<tr>
<th>UUID</th>
<th>Hostname</th>
<th>Beschreibung</th>
<th>Gruppe</th>
</tr>
</thead>
<tbody>
{clients.map(c => (
<tr key={c.uuid}>
<td>{c.uuid}</td>
<td>{c.hostname}</td>
<td>{c.description}</td>
<td>{c.group_id}</td>
</tr>
))}
</tbody>
</table>
<div className="mb-4">
<SetupModeButton />
</div>
<ModalBox open={showModal} onClose={() => setShowModal(false)} />
</div>
);
};
export default Clients;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Wrench } from 'lucide-react';
const SetupModeButton: React.FC = () => {
const navigate = useNavigate();
return (
<button
className="setupmode-btn flex items-center gap-2 px-4 py-2 bg-yellow-200 hover:bg-yellow-300 rounded"
onClick={() => navigate('/setup')}
title="Erweiterungsmodus starten"
>
<Wrench size={18} />
Erweiterungsmodus
</button>
);
};
export default SetupModeButton;

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { fetchClients } from './apiClients'; import { fetchClients, fetchClientsWithoutDescription } from './apiClients';
import type { Client } from './apiClients'; import type { Client } from './apiClients';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
@@ -18,15 +19,18 @@ const Dashboard: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{clients.map(client => ( {clients.map(client => (
<div key={client.uuid} className="bg-white rounded shadow p-4 flex flex-col items-center"> <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.location || 'Unbekannter Standort'}</h4> <h4 className="text-lg font-bold mb-2">{client.description || 'Unbekannter Standort'}</h4>
<img <img
src={`/screenshots/${client.uuid}`} src={`/screenshots/${client.uuid}`}
alt={`Screenshot ${client.location}`} alt={`Screenshot ${client.description || 'Unbekannt'}`}
className="w-full h-48 object-contain bg-gray-100 mb-2" className="w-full h-48 object-contain bg-gray-100 mb-2"
onError={e => (e.currentTarget.style.display = 'none')} 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"> <div className="text-sm text-gray-700 mb-1">
<span className="font-semibold">IP:</span> {client.ip_address} <span className="font-semibold">IP:</span> {client.ip}
</div> </div>
<div className="text-sm text-gray-700"> <div className="text-sm text-gray-700">
<span className="font-semibold">Letztes Lebenszeichen:</span>{' '} <span className="font-semibold">Letztes Lebenszeichen:</span>{' '}

View File

@@ -106,8 +106,9 @@ const Infoscreen_groups: React.FC = () => {
data.map((c, i) => ({ data.map((c, i) => ({
...c, ...c,
Id: c.uuid, Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet', Status:
Summary: c.location || `Client ${i + 1}`, c.group_id === 1 ? 'Nicht zugeordnet' : groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.description || `Client ${i + 1}`,
})) }))
); );
}); });
@@ -166,7 +167,7 @@ const Infoscreen_groups: React.FC = () => {
...c, ...c,
Id: c.uuid, Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet', Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`, Summary: c.description || `Client ${i + 1}`,
})) }))
); );
} catch (err) { } catch (err) {
@@ -200,7 +201,7 @@ const Infoscreen_groups: React.FC = () => {
...c, ...c,
Id: c.uuid, Id: c.uuid,
Status: groupMap[c.group_id] || 'Nicht zugeordnet', Status: groupMap[c.group_id] || 'Nicht zugeordnet',
Summary: c.location || `Client ${i + 1}`, Summary: c.description || `Client ${i + 1}`,
})) }))
); );
} catch (err) { } catch (err) {

View File

@@ -1,11 +1,54 @@
from database import Session
from models.models import Client, ClientGroup
from flask import Blueprint, request, jsonify
import sys import sys
sys.path.append('/workspace') sys.path.append('/workspace')
from flask import Blueprint, request, jsonify
from models.models import Client, ClientGroup
from database import Session
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients") clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
@clients_bp.route("/without_description", methods=["GET"])
def get_clients_without_description():
session = Session()
clients = session.query(Client).filter(
(Client.description == None) | (Client.description == "")
).all()
result = [
{
"uuid": c.uuid,
"hardware_token": c.hardware_token,
"ip": c.ip,
"type": c.type,
"hostname": c.hostname,
"os_version": c.os_version,
"software_version": c.software_version,
"macs": c.macs,
"model": c.model,
"registration_time": c.registration_time.isoformat() if c.registration_time else None,
"last_alive": c.last_alive.isoformat() if c.last_alive else None,
"is_active": c.is_active,
"group_id": c.group_id,
}
for c in clients
]
session.close()
return jsonify(result)
@clients_bp.route("/<uuid>/description", methods=["PUT"])
def set_client_description(uuid):
data = request.get_json()
description = data.get("description", "").strip()
if not description:
return jsonify({"error": "Beschreibung darf nicht leer sein"}), 400
session = Session()
client = session.query(Client).filter_by(uuid=uuid).first()
if not client:
session.close()
return jsonify({"error": "Client nicht gefunden"}), 404
client.description = description
session.commit()
session.close()
return jsonify({"success": True})
@clients_bp.route("", methods=["GET"]) @clients_bp.route("", methods=["GET"])
def get_clients(): def get_clients():
@@ -14,10 +57,18 @@ def get_clients():
result = [ result = [
{ {
"uuid": c.uuid, "uuid": c.uuid,
"location": c.location, "hardware_token": c.hardware_token,
"hardware_hash": c.hardware_hash, "ip": c.ip,
"ip_address": c.ip_address, "type": c.type,
"hostname": c.hostname,
"os_version": c.os_version,
"software_version": c.software_version,
"macs": c.macs,
"model": c.model,
"description": c.description,
"registration_time": c.registration_time.isoformat() if c.registration_time else None,
"last_alive": c.last_alive.isoformat() if c.last_alive else None, "last_alive": c.last_alive.isoformat() if c.last_alive else None,
"is_active": c.is_active,
"group_id": c.group_id, "group_id": c.group_id,
} }
for c in clients for c in clients

21
server/routes/setup.py Normal file
View File

@@ -0,0 +1,21 @@
from flask import Blueprint, jsonify
from server.database import get_db
from models.models import Client
bp = Blueprint('setup', __name__, url_prefix='/api/setup')
@bp.route('/clients_without_description', methods=['GET'])
def clients_without_description():
db = get_db()
clients = db.query(Client).filter(Client.description == None).all()
result = []
for c in clients:
result.append({
'uuid': c.uuid,
'hostname': c.hostname,
'ip_address': c.ip_address,
'last_alive': c.last_alive,
'created_at': c.created_at,
'group': c.group_id,
})
return jsonify(result)

View File

@@ -35,7 +35,8 @@ def get_screenshot(uuid):
pattern = os.path.join("screenshots", f"{uuid}*.jpg") pattern = os.path.join("screenshots", f"{uuid}*.jpg")
files = glob.glob(pattern) files = glob.glob(pattern)
if not files: if not files:
return jsonify({"error": "Screenshot not found"}), 404 # Dummy-Bild als Redirect oder direkt als Response
return jsonify({"error": "Screenshot not found", "dummy": "https://placehold.co/400x300?text=No+Screenshot"}), 404
filename = os.path.basename(files[0]) filename = os.path.basename(files[0])
return send_from_directory("screenshots", filename) return send_from_directory("screenshots", filename)