From a445fef52121361e049860ea68510966faeb6308 Mon Sep 17 00:00:00 2001 From: jhartworks Date: Tue, 23 Jun 2026 10:59:58 +0200 Subject: [PATCH] update --- .env.example | 5 +- .gitignore | 4 +- README.md | 54 +- api/package.json | 3 +- api/src/config.js | 5 +- api/src/db.js | 66 +- api/src/index.js | 139 ++- api/src/lib/isp.js | 217 +++- api/src/lib/store.js | 15 +- collector/Dockerfile | 7 + collector/package.json | 9 + collector/src/index.js | 436 ++++++++ docker-compose.wago.yml | 19 + docker-compose.yml | 31 +- docker/mariadb/init/01-init.sql | 4 +- web/package.json | 7 +- web/src/App.jsx | 1570 +++++++++++++++++++++++------ web/src/api.js | 102 +- web/src/components/Gauge.jsx | 6 +- web/src/components/TrendChart.jsx | 468 +++++++-- web/src/styles.css | 658 +++++++++--- 21 files changed, 3161 insertions(+), 664 deletions(-) create mode 100644 collector/Dockerfile create mode 100644 collector/package.json create mode 100644 collector/src/index.js create mode 100644 docker-compose.wago.yml diff --git a/.env.example b/.env.example index 1c79884..127b150 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,7 @@ ADMIN_USERNAME=admin ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=ChangeMe123! ADMIN_NAME=System Admin -VITE_API_URL=http://localhost:18080/api +MARIADB_ROOT_PASSWORD=SE3112 +DB_PASSWORD_FALLBACK=root +CORS_ORIGIN=* +VITE_API_PORT=18080 diff --git a/.gitignore b/.gitignore index 1f98e8b..07a1a32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -node_modules +node_modules dist .env .DS_Store npm-debug.log* .scratch/runtime - +data/ diff --git a/README.md b/README.md index f887ebd..2a2fac6 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,45 @@ # SE Local Trenddata. -Modernisierte Trendoberflaeche fuer Wago-Bestandsdatenbanken mit React, Node.js, MariaDB und Redis. +Modernisierte Trendoberfläche für Wago-Bestandsdatenbanken mit React, Node.js, MariaDB, Redis, Dockhand und vorbereitetem Collector-Service. ## Enthalten -- React-Frontend mit Light/Dark/System-Theme -- Username-Login und Rollen fuer `viewer`, `technician`, `admin` -- Alias-Verwaltung fuer `Wert1` bis `Wert200` je `ispXX` -- CSV-Import und CSV-Export fuer Aliasdaten -- Gespeicherte Auswahlen ueber mehrere ISPs -- Dashboard mit Graph-, Zahl- und Gauge-Widgets -- Vollbild fuer die Trendansicht -- SQL-Import fuer vorhandene Langzeitdaten -- Redis fuer Metadaten, Auswahlen, Dashboard und User-Praferenzen -- MariaDB-Init fuer `isp01` bis `isp20` plus `app_users` +- React-Frontend mit System-, Hell- und Dunkelmodus +- Username-Login, Rollen, Passwortwechsel und Benutzerverwaltung +- Alias-Verwaltung für `Wert1` bis `Wert200` je Trendtabelle +- CSV-Import und CSV-Export für Aliasdaten +- Gespeicherte Auswahlen über mehrere ISPs hinweg +- Dashboard mit Graph-, Zahl- und Zeigermanometer-Widgets +- Fokusansicht für den Trendgraph statt Browser-Vollbild +- Export des Trendgraphs als CSV, XLSX, PNG, JPG, SVG und PDF +- SQL-Import für vorhandene Langzeitdaten +- Redis für Metadaten, Auswahlen, Dashboard und Benutzerpräferenzen +- MariaDB-Init für `isp01` bis `isp20` plus `app_users` +- Dockhand im Compose-Stack +- Collector-Gerüst für spätere Modbus-, OPC-UA-, BACnet- und KNX-Anbindung ## Starten 1. Optional `.env.example` nach `.env` kopieren und Werte anpassen. 2. `docker compose up -d --build` 3. Frontend: `http://localhost:5173` 4. API: `http://localhost:18080/api` +5. Dockhand: `http://localhost:3000` +6. Collector: `http://localhost:18110/health` ## Standard-Login - Username: `admin` - Passwort: `ChangeMe123!` -Bitte nach dem ersten Login in der Benutzerverwaltung einen eigenen Admin anlegen oder die Zugangsdaten anpassen. +## Datenbank-Kompatibilität +- Bestehende Anlagen können weiter mit `root` und Passwort `SE3112` auf die MariaDB gehen. +- Die API versucht zusätzlich als Fallback noch das ältere Root-Passwort `root`, damit vorhandene Entwicklungs-Volumes nicht sofort umgebaut werden müssen. -## Datenmodell -- Bestehende Trenddaten bleiben in den breiten Tabellen `isp01`, `isp02`, ... -- Die Anwendung erkennt vorhandene `isp`-Tabellen dynamisch. -- Metadaten wie Alias, Einheit, Faktor und Dashboard-Konfiguration liegen in Redis. +## Wago / SD-Karte +- Für Wago-Systeme liegt mit [docker-compose.wago.yml](/C:/Projects/SeLTD/docker-compose.wago.yml) ein Override bereit. +- Start auf Wago mit SD-Karte: `docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build` +- Dabei landen die großen Daten unter `/media/sd/seltd/...`. -## Wichtige Container -- `seltd-mariadb` -- `seltd-redis` -- `seltd-api` -- `seltd-web` - -## Hinweise fuer Bestandsdaten -- Vorhandene SQL-Dumps koennen im Admin-Bereich direkt importiert werden. -- Die Anwendung liest die Tabellenstruktur mit `datum`, `userlevel` und `Wert1..Wert200` direkt. -- Wenn spaeter weitere Tabellen wie `isp21` oder `isp35` auftauchen, werden sie automatisch in der Oberflaeche erkannt. +## Hinweise +- Vorhandene SQL-Dumps können im Admin-Bereich direkt importiert werden. +- Leere Trendtabellen tauchen nicht im Trend-Dropdown auf, aber weiter im Admin-Bereich. +- Der Collector ist bewusst erst als technisches Gerüst enthalten; Protokoll-Scan und Feldbus-Importe sind noch nicht vollständig implementiert. +- Für Dockhand wurde der Container ergänzt. Eine sichere automatische Vorbelegung eines lokalen Admin-Users war ohne offizielle Initialisierungsdokumentation nicht belastbar genug und wurde deshalb nicht blind in die SQLite-Datenbank geschrieben. diff --git a/api/package.json b/api/package.json index 213768a..93a83bb 100644 --- a/api/package.json +++ b/api/package.json @@ -16,5 +16,4 @@ "jsonwebtoken": "^9.0.2", "mysql2": "^3.12.0" } -} - +} \ No newline at end of file diff --git a/api/src/config.js b/api/src/config.js index ce78d59..02e70c5 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -8,10 +8,11 @@ export const config = { dbPort: Number(process.env.DB_PORT || 3306), dbName: process.env.DB_NAME || "wago", dbUser: process.env.DB_USER || "root", - dbPassword: process.env.DB_PASSWORD || "root", + dbPassword: process.env.DB_PASSWORD || "SE3112", + dbPasswordFallback: process.env.DB_PASSWORD_FALLBACK || "root", redisUrl: process.env.REDIS_URL || "redis://127.0.0.1:6379", jwtSecret: process.env.JWT_SECRET || "change-me", - corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173", + corsOrigin: process.env.CORS_ORIGIN || "*", adminUsername: process.env.ADMIN_USERNAME || "admin", adminEmail: process.env.ADMIN_EMAIL || "admin@example.com", adminPassword: process.env.ADMIN_PASSWORD || "ChangeMe123!", diff --git a/api/src/db.js b/api/src/db.js index fa2a248..c4f049a 100644 --- a/api/src/db.js +++ b/api/src/db.js @@ -1,14 +1,58 @@ import mysql from "mysql2/promise"; import { config } from "./config.js"; -export const pool = mysql.createPool({ - host: config.dbHost, - port: config.dbPort, - user: config.dbUser, - password: config.dbPassword, - database: config.dbName, - waitForConnections: true, - connectionLimit: 10, - namedPlaceholders: true, - multipleStatements: true, -}); +function createPool(password) { + return mysql.createPool({ + host: config.dbHost, + port: config.dbPort, + user: config.dbUser, + password, + database: config.dbName, + waitForConnections: true, + connectionLimit: 10, + namedPlaceholders: true, + multipleStatements: true, + charset: "utf8mb4", + }); +} + +const candidatePasswords = [...new Set([config.dbPassword, config.dbPasswordFallback].filter(Boolean))]; +let activePassword = candidatePasswords[0]; +let activePool = createPool(activePassword); + +function isAccessDenied(error) { + return error?.code === "ER_ACCESS_DENIED_ERROR"; +} + +async function query(sql, values) { + try { + return await activePool.query(sql, values); + } catch (error) { + if (!isAccessDenied(error) || candidatePasswords.length < 2) { + throw error; + } + + for (const password of candidatePasswords) { + if (password === activePassword) { + continue; + } + + const fallbackPool = createPool(password); + try { + const result = await fallbackPool.query(sql, values); + activePool = fallbackPool; + activePassword = password; + return result; + } catch (fallbackError) { + await fallbackPool.end().catch(() => undefined); + if (!isAccessDenied(fallbackError)) { + throw fallbackError; + } + } + } + + throw error; + } +} + +export const pool = { query }; diff --git a/api/src/index.js b/api/src/index.js index d065cdb..e270c23 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -5,10 +5,12 @@ import { authenticateToken, createToken, requireRoles } from "./auth.js"; import { config } from "./config.js"; import { pool } from "./db.js"; import { + createWideTrendTable, ensureIspMetadata, fetchLatestValues, fetchTrendSeries, getAvailableIsps, + normalizeEngineeringUnit, normalizeRange, sanitizeIspName, sanitizePointIndex, @@ -29,9 +31,21 @@ import { redis } from "./redis.js"; const app = express(); const wrap = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); +const allowedOrigins = config.corsOrigin + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + app.use( cors({ - origin: config.corsOrigin, + origin(origin, callback) { + if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + + callback(new Error("Origin not allowed.")); + }, }) ); app.use(express.json({ limit: "50mb" })); @@ -111,6 +125,22 @@ async function resolveSelectionPoints(body, user) { return assertPoints(body.points || []); } +async function ensureUtf8Database() { + await pool.query(`ALTER DATABASE \`${config.dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + + const [tables] = await pool.query( + `SELECT TABLE_NAME AS tableName + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? + AND (TABLE_NAME = 'app_users' OR TABLE_NAME LIKE 'isp%' OR TABLE_NAME LIKE 'trend_%')`, + [config.dbName] + ); + + for (const table of tables) { + await pool.query(`ALTER TABLE \`${table.tableName}\` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + } +} + async function ensureUserSchema() { await pool.query( ` @@ -209,20 +239,20 @@ app.post("/api/auth/login", wrap(async (req, res) => { const password = String(req.body.password || ""); const [rows] = await pool.query( - "SELECT * FROM app_users WHERE username = ? AND active = 1 LIMIT 1", - [username] + "SELECT * FROM app_users WHERE (username = ? OR email = ?) AND active = 1 LIMIT 1", + [username, username] ); const user = rows[0]; if (!user) { - return res.status(401).json({ error: "Invalid credentials." }); + return res.status(401).json({ error: "Ungültige Zugangsdaten." }); } const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { - return res.status(401).json({ error: "Invalid credentials." }); + return res.status(401).json({ error: "Ungültige Zugangsdaten." }); } const serializedUser = serializeUser(user); @@ -233,11 +263,37 @@ app.post("/api/auth/login", wrap(async (req, res) => { }); })); +app.post("/api/auth/change-password", authenticateToken, wrap(async (req, res) => { + const currentPassword = String(req.body.currentPassword || ""); + const nextPassword = String(req.body.nextPassword || ""); + + if (nextPassword.length < 8) { + return res.status(400).json({ error: "Das neue Passwort muss mindestens 8 Zeichen lang sein." }); + } + + const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]); + const user = rows[0]; + + if (!user) { + return res.status(404).json({ error: "Benutzer nicht gefunden." }); + } + + const isValid = await bcrypt.compare(currentPassword, user.password_hash); + + if (!isValid) { + return res.status(400).json({ error: "Das aktuelle Passwort stimmt nicht." }); + } + + const passwordHash = await bcrypt.hash(nextPassword, 10); + await pool.query("UPDATE app_users SET password_hash = ? WHERE id = ?", [passwordHash, user.id]); + res.json({ ok: true }); +})); + app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => { const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [req.user.sub]); if (!rows[0]) { - return res.status(404).json({ error: "User not found." }); + return res.status(404).json({ error: "Benutzer nicht gefunden." }); } return res.json({ user: serializeUser(rows[0]) }); @@ -286,11 +342,12 @@ app.put( metadata.points[key] = { ...metadata.points[key], alias: String(point.alias || metadata.points[key].alias).trim() || metadata.points[key].alias, - unit: String(point.unit || metadata.points[key].unit).trim(), + unit: normalizeEngineeringUnit(point.unit || metadata.points[key].unit), factor: Number.isFinite(Number(point.factor)) ? Number(point.factor) : metadata.points[key].factor, kind: point.kind === "digital" ? "digital" : "analog", min: Number.isFinite(Number(point.min)) ? Number(point.min) : metadata.points[key].min, max: Number.isFinite(Number(point.max)) ? Number(point.max) : metadata.points[key].max, + color: typeof point.color === "string" ? point.color : metadata.points[key].color, }; }); } @@ -302,6 +359,42 @@ app.put( }) ); +app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(async (req, res) => { + const rawName = String(req.body.tableName || "").trim().toLowerCase(); + const displayName = String(req.body.displayName || rawName).trim(); + const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`); + const metadata = await updateIspMetadata(redis, safeName, (current) => ({ + ...current, + displayName: displayName || current.displayName, + })); + res.status(201).json({ tableName: safeName, metadata }); +})); + +app.post( + "/api/admin/normalize-alias-metadata", + authenticateToken, + requireRoles("technician", "admin"), + wrap(async (req, res) => { + const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null; + + if (!targetIsp && req.user.role !== "admin") { + return res.status(403).json({ error: "Nur Admins dürfen alle ISPs auf einmal bereinigen." }); + } + + const targets = targetIsp + ? [{ tableName: targetIsp, pointCount: 200 }] + : await getAvailableIsps(pool, config.dbName); + + const normalized = []; + for (const isp of targets) { + const metadata = await ensureIspMetadata(redis, isp.tableName, isp.pointCount || 200); + normalized.push({ isp: isp.tableName, metadata }); + } + + res.json({ ok: true, normalizedCount: normalized.length, items: normalized }); + }) +); + app.post( "/api/admin/import-sql", authenticateToken, @@ -310,7 +403,7 @@ app.post( const sql = normalizeSqlDump(req.body.sql); if (!sql) { - throw new Error("SQL content is empty."); + throw new Error("SQL-Inhalt ist leer."); } await pool.query(sql); @@ -334,7 +427,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req const password = String(req.body.password || ""); if (!username || !name || password.length < 8) { - return res.status(400).json({ error: "Username, name and password are required." }); + return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." }); } const passwordHash = await bcrypt.hash(password, 10); @@ -357,22 +450,16 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]); if (!rows[0]) { - return res.status(404).json({ error: "User not found." }); + return res.status(404).json({ error: "Benutzer nicht gefunden." }); } const current = rows[0]; - const username = typeof payload.username === "string" - ? payload.username.trim().toLowerCase() || current.username - : current.username; + const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username; const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name; - const email = typeof payload.email === "string" - ? payload.email.trim().toLowerCase() || null - : current.email; + const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email; const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role; const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active; - const passwordHash = payload.password - ? await bcrypt.hash(String(payload.password), 10) - : current.password_hash; + const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash; await pool.query( ` @@ -410,11 +497,11 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) const existing = await getSelection(redis, req.params.selectionId); if (!existing) { - return res.status(404).json({ error: "Selection not found." }); + return res.status(404).json({ error: "Auswahl nicht gefunden." }); } if (existing.ownerId !== req.user.sub && req.user.role !== "admin") { - return res.status(403).json({ error: "You can only edit your own selections." }); + return res.status(403).json({ error: "Du kannst nur eigene Auswahlen bearbeiten." }); } const points = assertPoints(req.body.points || existing.points || []); @@ -437,11 +524,11 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r const existing = await getSelection(redis, req.params.selectionId); if (!existing) { - return res.status(404).json({ error: "Selection not found." }); + return res.status(404).json({ error: "Auswahl nicht gefunden." }); } if (existing.ownerId !== req.user.sub && req.user.role !== "admin") { - return res.status(403).json({ error: "You can only delete your own selections." }); + return res.status(403).json({ error: "Du kannst nur eigene Auswahlen löschen." }); } await deleteSelection(redis, existing.id); @@ -475,9 +562,7 @@ app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => { const results = []; for (const widget of widgets) { - const points = widget.selectionId - ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) - : assertPoints(widget.points || []); + const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []); if (widget.type === "chart") { const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range)); @@ -506,9 +591,11 @@ app.use((error, _req, res, _next) => { res.status(400).json({ error: error.message || "Unexpected error." }); }); +await ensureUtf8Database(); await ensureUserSchema(); await ensureAdminUser(); app.listen(config.port, () => { console.log(`SE Local Trenddata API listening on port ${config.port}`); }); + diff --git a/api/src/lib/isp.js b/api/src/lib/isp.js index cc0aeba..0825ce0 100644 --- a/api/src/lib/isp.js +++ b/api/src/lib/isp.js @@ -7,8 +7,8 @@ export function buildPointKey(isp, pointIndex) { export function sanitizeIspName(value) { const normalized = String(value || "").toLowerCase(); - if (!/^isp\d+$/.test(normalized)) { - throw new Error("Invalid ISP table name."); + if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(normalized)) { + throw new Error("Invalid trend table name."); } return normalized; @@ -45,6 +45,113 @@ export function parseTrendValue(rawValue, factor = 1) { }; } +function buildValueColumns() { + const columns = []; + + for (let index = 1; index <= 200; index += 1) { + columns.push('`Wert' + index + '` TEXT NULL'); + } + + return columns.join(", "); +} + +function hslToHex(hue, saturation, lightness) { + const s = saturation / 100; + const l = lightness / 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((hue / 60) % 2 - 1)); + const m = l - c / 2; + + let red = 0; + let green = 0; + let blue = 0; + + if (hue < 60) { + red = c; + green = x; + } else if (hue < 120) { + red = x; + green = c; + } else if (hue < 180) { + green = c; + blue = x; + } else if (hue < 240) { + green = x; + blue = c; + } else if (hue < 300) { + red = x; + blue = c; + } else { + red = c; + blue = x; + } + + const toHex = (value) => Math.round((value + m) * 255).toString(16).padStart(2, "0"); + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; +} + +function parseHslColor(value) { + const match = String(value || "").match(/^hsl\((\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\)$/i); + if (!match) { + return null; + } + + return { + hue: Number(match[1]), + saturation: Number(match[2]), + lightness: Number(match[3]), + }; +} + +function createRandomPointColor(seed) { + const hue = Math.round((seed * 47 + 17 + Math.random() * 28) % 360); + const saturation = 72 + (seed % 4) * 4; + const lightness = 45 + (seed % 3) * 4; + return hslToHex(hue, saturation, lightness); +} + +export function normalizeEngineeringUnit(value) { + const raw = String(value ?? "").trim(); + if (!raw) { + return "°C"; + } + + const normalized = raw + .replace(/°/g, "°") + .replace(/^\?C$/i, "°C") + .replace(/^°\s*C$/i, "°C"); + + return normalized || "°C"; +} + +function createDefaultPoint(index) { + return { + alias: `Wert${index}`, + unit: "°C", + factor: 1, + kind: "analog", + min: 0, + max: 100, + color: createRandomPointColor(index), + }; +} + +export async function createWideTrendTable(pool, tableName) { + const safeTable = sanitizeIspName(tableName); + const sql = ` + CREATE TABLE IF NOT EXISTS \`${safeTable}\` ( + \`id\` INT(11) NOT NULL AUTO_INCREMENT, + \`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + \`userlevel\` INT(1) NOT NULL, + ${buildValueColumns()}, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `; + + await pool.query(sql); + return safeTable; +} + export async function getAvailableIsps(pool, dbName) { const [tables] = await pool.query( ` @@ -59,43 +166,42 @@ export async function getAvailableIsps(pool, dbName) { ) AS pointCount FROM information_schema.TABLES t WHERE t.TABLE_SCHEMA = ? - AND t.TABLE_NAME LIKE 'isp%' + AND (t.TABLE_NAME LIKE 'isp%' OR t.TABLE_NAME LIKE 'trend_%') ORDER BY t.TABLE_NAME `, [dbName] ); - const latestMap = new Map(); + const stats = new Map(); await Promise.all( tables.map(async (table) => { const safeTable = sanitizeIspName(table.tableName); const [rows] = await pool.query( - `SELECT MAX(datum) AS latestTimestamp FROM \`${safeTable}\`` + `SELECT COUNT(*) AS rowCount, MAX(datum) AS latestTimestamp FROM \`${safeTable}\`` ); - latestMap.set(safeTable, rows[0]?.latestTimestamp || null); + stats.set(safeTable, rows[0] || { rowCount: 0, latestTimestamp: null }); }) ); - return tables.map((table) => ({ - tableName: sanitizeIspName(table.tableName), - pointCount: Number(table.pointCount || 0), - latestTimestamp: latestMap.get(sanitizeIspName(table.tableName)) || null, - })); + return tables.map((table) => { + const safeTable = sanitizeIspName(table.tableName); + const info = stats.get(safeTable) || {}; + + return { + tableName: safeTable, + pointCount: Number(table.pointCount || 0), + rowCount: Number(info.rowCount || 0), + latestTimestamp: info.latestTimestamp || null, + }; + }); } export function createDefaultMetadata(isp, pointCount = 200) { const points = {}; for (let index = 1; index <= pointCount; index += 1) { - points[String(index)] = { - alias: `Wert${index}`, - unit: "", - factor: 1, - kind: "analog", - min: 0, - max: 100, - }; + points[String(index)] = createDefaultPoint(index); } return { @@ -113,11 +219,62 @@ export async function ensureIspMetadata(redis, isp, pointCount = 200) { if (existing) { const parsed = JSON.parse(existing); + let changed = false; + + if (!parsed.points) { + parsed.points = {}; + changed = true; + } for (let index = 1; index <= pointCount; index += 1) { - if (!parsed.points?.[String(index)]) { - parsed.points[String(index)] = createDefaultMetadata(safeIsp, pointCount).points[String(index)]; + const pointKey = String(index); + const defaultPoint = createDefaultPoint(index); + + if (!parsed.points[pointKey]) { + parsed.points[pointKey] = defaultPoint; + changed = true; + continue; } + + const point = parsed.points[pointKey]; + if (!point.alias) { + point.alias = defaultPoint.alias; + changed = true; + } + const normalizedUnit = normalizeEngineeringUnit(point.unit); + if (normalizedUnit !== point.unit) { + point.unit = normalizedUnit; + changed = true; + } + if (!point.kind) { + point.kind = defaultPoint.kind; + changed = true; + } + if (!Number.isFinite(Number(point.factor))) { + point.factor = defaultPoint.factor; + changed = true; + } + if (!Number.isFinite(Number(point.min))) { + point.min = defaultPoint.min; + changed = true; + } + if (!Number.isFinite(Number(point.max))) { + point.max = defaultPoint.max; + changed = true; + } + const parsedHslColor = parseHslColor(point.color); + if (parsedHslColor) { + point.color = hslToHex(parsedHslColor.hue, parsedHslColor.saturation, parsedHslColor.lightness); + changed = true; + } + if (!point.color) { + point.color = defaultPoint.color; + changed = true; + } + } + + if (changed) { + await redis.set(key, JSON.stringify(parsed)); } return parsed; @@ -162,11 +319,12 @@ export async function getPointDescriptors(redis, points) { key: buildPointKey(isp, pointIndex), displayName: metadata.displayName, alias: pointMeta.alias, - unit: pointMeta.unit, + unit: normalizeEngineeringUnit(pointMeta.unit), factor: pointMeta.factor, kind: pointMeta.kind, min: pointMeta.min, max: pointMeta.max, + color: pointMeta.color || "", }); }); } @@ -218,9 +376,7 @@ export async function fetchTrendSeries(pool, redis, points, range) { return { series: descriptors, - rows: [...mergedRows.values()].sort((left, right) => - left.timestamp.localeCompare(right.timestamp) - ), + rows: [...mergedRows.values()].sort((left, right) => left.timestamp.localeCompare(right.timestamp)), }; } @@ -242,10 +398,7 @@ export async function fetchLatestValues(pool, redis, points) { .map((descriptor) => `\`Wert${descriptor.pointIndex}\``) .join(", "); - const [rows] = await pool.query( - `SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1` - ); - + const [rows] = await pool.query(`SELECT datum, ${columns} FROM \`${isp}\` ORDER BY datum DESC LIMIT 1`); const row = rows[0]; pointDescriptors.forEach((descriptor) => { @@ -267,9 +420,7 @@ export function normalizeRange(input = {}) { const maxRows = Number(input.maxRows || 4000); const now = new Date(); const to = input.to ? new Date(input.to) : now; - const from = input.from - ? new Date(input.from) - : new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000); + const from = input.from ? new Date(input.from) : new Date(now.getTime() - presetDays * 24 * 60 * 60 * 1000); return { from, @@ -277,3 +428,5 @@ export function normalizeRange(input = {}) { maxRows: Number.isFinite(maxRows) ? Math.max(100, Math.min(maxRows, 10000)) : 4000, }; } + + diff --git a/api/src/lib/store.js b/api/src/lib/store.js index 01f7f29..7b8fe37 100644 --- a/api/src/lib/store.js +++ b/api/src/lib/store.js @@ -1,4 +1,4 @@ -const SELECTION_SET_KEY = "selection:ids"; +const SELECTION_SET_KEY = "selection:ids"; const SELECTION_SEQ_KEY = "selection:seq"; function safeParse(jsonValue) { @@ -54,12 +54,21 @@ export async function saveDashboard(redis, userId, dashboard) { } export async function getPreferences(redis, userId) { - return safeParse(await redis.get(`preferences:${userId}`)) || { theme: "system" }; + return safeParse(await redis.get(`preferences:${userId}`)) || { + theme: "system", + csvDelimiter: ";", + sidebarPinned: true, + }; } export async function savePreferences(redis, userId, preferences) { + const current = await getPreferences(redis, userId); const payload = { - theme: preferences.theme || "system", + ...current, + ...preferences, + theme: preferences.theme || current.theme || "system", + csvDelimiter: preferences.csvDelimiter || current.csvDelimiter || ";", + sidebarPinned: typeof preferences.sidebarPinned === "boolean" ? preferences.sidebarPinned : current.sidebarPinned, updatedAt: new Date().toISOString(), }; await redis.set(`preferences:${userId}`, JSON.stringify(payload)); diff --git a/collector/Dockerfile b/collector/Dockerfile new file mode 100644 index 0000000..21132d8 --- /dev/null +++ b/collector/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +COPY src ./src +RUN mkdir -p /app/data +EXPOSE 18110 +CMD ["npm", "run", "start"] diff --git a/collector/package.json b/collector/package.json new file mode 100644 index 0000000..5a9bba9 --- /dev/null +++ b/collector/package.json @@ -0,0 +1,9 @@ +{ + "name": "seltd-collector", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js" + } +} \ No newline at end of file diff --git a/collector/src/index.js b/collector/src/index.js new file mode 100644 index 0000000..d103125 --- /dev/null +++ b/collector/src/index.js @@ -0,0 +1,436 @@ +import { createServer } from "node:http"; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Buffer } from "node:buffer"; +import net from "node:net"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dataDir = join(__dirname, "..", "data"); +const storeFile = join(dataDir, "sources.json"); +const port = Number(process.env.PORT || 18110); + +mkdirSync(dataDir, { recursive: true }); + +function baseStore() { + return { + sources: [], + datapoints: [], + scans: { opcua: [], bacnet: [] }, + updatedAt: null, + }; +} + +function readStore() { + if (!existsSync(storeFile)) { + return baseStore(); + } + + try { + const parsed = JSON.parse(readFileSync(storeFile, "utf8")); + return { + ...baseStore(), + ...parsed, + scans: { ...baseStore().scans, ...(parsed.scans || {}) }, + sources: Array.isArray(parsed.sources) ? parsed.sources : [], + datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [], + }; + } catch { + return baseStore(); + } +} + +function writeStore(payload) { + writeFileSync(storeFile, JSON.stringify(payload, null, 2), "utf8"); +} + +function touchStore(mutator) { + const store = readStore(); + const next = mutator(structuredClone(store)) || store; + next.updatedAt = new Date().toISOString(); + writeStore(next); + return next; +} + +function replyJson(res, statusCode, body) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }); + res.end(JSON.stringify(body)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + const text = Buffer.concat(chunks).toString("utf8"); + resolve(text ? JSON.parse(text) : {}); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function slugify(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "quelle"; +} + +function normalizeProtocol(value) { + const protocol = String(value || "").toLowerCase(); + if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"].includes(protocol)) { + return protocol; + } + return "unknown"; +} + +function normalizeWriteMode(value) { + return String(value || "").toLowerCase() === "cov" ? "cov" : "interval"; +} + +function normalizePollInterval(value) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60; +} + +function ensureSource(store, body) { + const now = new Date().toISOString(); + const source = { + id: body.id || `src-${Date.now()}`, + protocol: normalizeProtocol(body.protocol), + name: body.name || "Neue Quelle", + host: body.host || "", + port: body.port || "", + deviceId: body.deviceId || "", + displayName: body.displayName || body.name || "Neue Quelle", + tableName: body.tableName || `trend_${slugify(body.name || body.protocol || "daten")}`, + writeMode: normalizeWriteMode(body.writeMode), + pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds), + options: { + ...(body.options || {}), + writeMode: normalizeWriteMode(body.writeMode), + pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds), + }, + createdAt: body.createdAt || now, + updatedAt: now, + }; + + const existingIndex = store.sources.findIndex((item) => item.id === source.id); + if (existingIndex >= 0) { + store.sources[existingIndex] = { ...store.sources[existingIndex], ...source }; + } else { + store.sources.push(source); + } + return source; +} + +function splitDelimited(text, delimiter) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split(delimiter).map((part) => part.trim().replace(/^"|"$/g, ""))); +} + +function parseModbusCsv(payload) { + const delimiter = payload.delimiter || ";"; + const rows = splitDelimited(payload.csv || "", delimiter); + if (rows.length < 2) { + return []; + } + + const header = rows[0].map((value) => value.toLowerCase()); + return rows.slice(1).map((row, index) => { + const get = (name, fallback = "") => { + const columnIndex = header.indexOf(name); + return columnIndex >= 0 ? row[columnIndex] || fallback : fallback; + }; + + return { + id: `dp-modbus-${Date.now()}-${index}`, + sourceId: payload.sourceId || "", + protocol: "modbus-tcp", + address: get("address", get("register", String(index + 1))), + pointIndex: Number(get("pointindex", index + 1)) || index + 1, + name: get("name", `Register ${index + 1}`), + alias: get("alias", get("name", `Register ${index + 1}`)), + unit: get("unit"), + dataType: get("datatype", "holding-register"), + scale: Number(get("scale", 1)) || 1, + host: payload.host || "", + tableName: payload.tableName || "", + createdAt: new Date().toISOString(), + }; + }); +} + +function decodeXmlEntities(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("'", "'"); +} + +function parseKnxXml(payload) { + const xml = String(payload.xml || ""); + const datapoints = []; + const pattern = /]*)\/?>(?:<\/GroupAddress>)?/gi; + let match; + let index = 0; + + while ((match = pattern.exec(xml))) { + const attrs = match[1]; + const getAttr = (name) => { + const attrMatch = attrs.match(new RegExp(`${name}="([^"]*)"`, "i")); + return attrMatch ? decodeXmlEntities(attrMatch[1]) : ""; + }; + + const address = getAttr("Address") || getAttr("address"); + const name = getAttr("Name") || getAttr("name") || `KNX ${index + 1}`; + const dpt = getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || ""; + + if (address) { + datapoints.push({ + id: `dp-knx-${Date.now()}-${index}`, + sourceId: payload.sourceId || "", + protocol: "knx-ip", + address, + pointIndex: index + 1, + name, + alias: name, + unit: "", + dataType: dpt || "group-address", + host: payload.host || "", + tableName: payload.tableName || "", + createdAt: new Date().toISOString(), + }); + index += 1; + } + } + + return datapoints; +} + +function tryTcpConnect(host, portNumber, timeout = 1200) { + return new Promise((resolve) => { + if (!host || !portNumber) { + resolve(false); + return; + } + + const socket = new net.Socket(); + let settled = false; + const finish = (value) => { + if (!settled) { + settled = true; + socket.destroy(); + resolve(value); + } + }; + + socket.setTimeout(timeout); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + socket.connect(portNumber, host); + }); +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + if (req.method === "OPTIONS") { + replyJson(res, 204, {}); + return; + } + + if (req.method === "GET" && url.pathname === "/health") { + replyJson(res, 200, { ok: true, service: "collector", timestamp: new Date().toISOString() }); + return; + } + + if (req.method === "GET" && url.pathname === "/capabilities") { + replyJson(res, 200, { + protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"], + imports: ["modbus-csv", "knx-xml"], + scans: ["opc-ua", "bacnet-ip"], + note: "Importe und erste Scan-Stubs sind aktiv. Poll-Intervall und Schreibmodus werden bereits pro Quelle gespeichert; die echte Live-Kommunikation folgt als nächste Ausbaustufe.", + }); + return; + } + + if (req.method === "GET" && url.pathname === "/sources") { + replyJson(res, 200, readStore()); + return; + } + + if (req.method === "GET" && url.pathname === "/datapoints") { + const store = readStore(); + const sourceId = url.searchParams.get("sourceId"); + const items = sourceId ? store.datapoints.filter((item) => item.sourceId === sourceId) : store.datapoints; + replyJson(res, 200, { datapoints: items, total: items.length, updatedAt: store.updatedAt }); + return; + } + + if (req.method === "POST" && url.pathname === "/sources") { + try { + const body = await readBody(req); + const next = touchStore((store) => { + const source = ensureSource(store, body); + return { ...store, lastSource: source.id }; + }); + replyJson(res, 201, next.sources.at(-1)); + } catch { + replyJson(res, 400, { error: "Ungültige JSON-Daten." }); + } + return; + } + + if (req.method === "PUT" && url.pathname.startsWith("/sources/")) { + try { + const sourceId = url.pathname.split("/").at(-1); + const body = await readBody(req); + const next = touchStore((store) => { + const existing = store.sources.find((item) => item.id === sourceId); + if (!existing) { + throw new Error("not-found"); + } + ensureSource(store, { ...existing, ...body, id: sourceId }); + return store; + }); + replyJson(res, 200, next.sources.find((item) => item.id === sourceId)); + } catch (error) { + replyJson(res, error.message === "not-found" ? 404 : 400, { + error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.", + }); + } + return; + } + + if (req.method === "DELETE" && url.pathname.startsWith("/sources/")) { + const sourceId = url.pathname.split("/").at(-1); + const next = touchStore((store) => ({ + ...store, + sources: store.sources.filter((item) => item.id !== sourceId), + datapoints: store.datapoints.filter((item) => item.sourceId !== sourceId), + })); + replyJson(res, 200, { ok: true, sources: next.sources.length, datapoints: next.datapoints.length }); + return; + } + + if (req.method === "POST" && url.pathname === "/imports/modbus-csv") { + try { + const body = await readBody(req); + const imported = parseModbusCsv(body); + const next = touchStore((store) => { + if (body.sourceId) { + store.datapoints = store.datapoints.filter((item) => !(item.sourceId === body.sourceId && item.protocol === "modbus-tcp")); + } + store.datapoints.push(...imported); + return store; + }); + replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt }); + } catch { + replyJson(res, 400, { error: "CSV-Import fehlgeschlagen." }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/imports/knx-xml") { + try { + const body = await readBody(req); + const imported = parseKnxXml(body); + const next = touchStore((store) => { + if (body.sourceId) { + store.datapoints = store.datapoints.filter((item) => !(item.sourceId === body.sourceId && item.protocol === "knx-ip")); + } + store.datapoints.push(...imported); + return store; + }); + replyJson(res, 200, { imported: imported.length, datapoints: imported, updatedAt: next.updatedAt }); + } catch { + replyJson(res, 400, { error: "KNX-XML-Import fehlgeschlagen." }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/scan/opcua") { + try { + const body = await readBody(req); + const host = body.host || ""; + const portNumber = Number(body.port || 4840); + const reachable = await tryTcpConnect(host, portNumber); + const result = { + host, + port: portNumber, + reachable, + endpoints: reachable + ? [ + { url: `opc.tcp://${host}:${portNumber}`, securityMode: "None", securityPolicy: "None" }, + ] + : [], + sampleNodes: reachable + ? [ + { nodeId: "ns=0;i=2258", browseName: "Server", displayName: "Server" }, + { nodeId: "ns=0;i=2267", browseName: "ServerStatus", displayName: "ServerStatus" }, + ] + : [], + scannedAt: new Date().toISOString(), + }; + touchStore((store) => { + store.scans.opcua.unshift(result); + store.scans.opcua = store.scans.opcua.slice(0, 20); + return store; + }); + replyJson(res, 200, result); + } catch { + replyJson(res, 400, { error: "OPC-UA-Scan fehlgeschlagen." }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/scan/bacnet") { + try { + const body = await readBody(req); + const hosts = Array.isArray(body.hosts) ? body.hosts : [body.host].filter(Boolean); + const results = []; + for (const host of hosts) { + const reachable = await tryTcpConnect(host, Number(body.port || 47808), 650); + results.push({ + host, + port: Number(body.port || 47808), + reachable, + deviceId: body.deviceId || "", + name: reachable ? `BACnet Gerät ${host}` : "Nicht erreichbar", + }); + } + const payload = { devices: results, scannedAt: new Date().toISOString() }; + touchStore((store) => { + store.scans.bacnet.unshift(payload); + store.scans.bacnet = store.scans.bacnet.slice(0, 20); + return store; + }); + replyJson(res, 200, payload); + } catch { + replyJson(res, 400, { error: "BACnet-Scan fehlgeschlagen." }); + } + return; + } + + replyJson(res, 404, { error: "Nicht gefunden." }); +}); + +server.listen(port, () => { + console.log(`SE Local Trenddata collector listening on port ${port}`); +}); diff --git a/docker-compose.wago.yml b/docker-compose.wago.yml new file mode 100644 index 0000000..212d935 --- /dev/null +++ b/docker-compose.wago.yml @@ -0,0 +1,19 @@ +services: + mariadb: + volumes: + - /media/sd/seltd/mariadb:/var/lib/mysql + - ./docker/mariadb/init:/docker-entrypoint-initdb.d:ro + + redis: + volumes: + - /media/sd/seltd/redis:/data + - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro + + collector: + volumes: + - /media/sd/seltd/collector:/app/data + + dockhand: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /media/sd/seltd/dockhand:/app/data diff --git a/docker-compose.yml b/docker-compose.yml index 9b1a445..efac2e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ container_name: seltd-mariadb restart: unless-stopped environment: - MARIADB_ROOT_PASSWORD: root + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} MARIADB_DATABASE: wago MARIADB_USER: app MARIADB_PASSWORD: app @@ -46,14 +46,15 @@ DB_PORT: 3306 DB_NAME: wago DB_USER: root - DB_PASSWORD: root + DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} + DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root} REDIS_URL: redis://redis:6379 JWT_SECRET: ${JWT_SECRET:-change-me} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!} ADMIN_NAME: ${ADMIN_NAME:-System Admin} - CORS_ORIGIN: http://localhost:5173 + CORS_ORIGIN: ${CORS_ORIGIN:-*} ports: - "18080:8080" depends_on: @@ -68,12 +69,34 @@ container_name: seltd-web restart: unless-stopped environment: - VITE_API_URL: ${VITE_API_URL:-http://localhost:18080/api} + VITE_API_PORT: ${VITE_API_PORT:-18080} ports: - "5173:5173" depends_on: - api + collector: + build: + context: ./collector + container_name: seltd-collector + restart: unless-stopped + ports: + - "18110:18110" + volumes: + - collector_data:/app/data + + dockhand: + image: fnsys/dockhand:latest + container_name: dockhand + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - dockhand_data:/app/data + volumes: mariadb_data: redis_data: + collector_data: + dockhand_data: diff --git a/docker/mariadb/init/01-init.sql b/docker/mariadb/init/01-init.sql index e0c1166..12b93b6 100644 --- a/docker/mariadb/init/01-init.sql +++ b/docker/mariadb/init/01-init.sql @@ -1,4 +1,4 @@ -CREATE DATABASE IF NOT EXISTS `wago` CHARACTER SET latin1 COLLATE latin1_swedish_ci; +CREATE DATABASE IF NOT EXISTS `wago` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE `wago`; DELIMITER // @@ -25,7 +25,7 @@ BEGIN '`userlevel` INT(1) NOT NULL', value_columns, ', PRIMARY KEY (`id`)', - ') ENGINE=InnoDB DEFAULT CHARSET=latin1;' + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;' ); PREPARE stmt FROM stmt_sql; diff --git a/web/package.json b/web/package.json index f1589b0..1b1d84d 100644 --- a/web/package.json +++ b/web/package.json @@ -9,13 +9,14 @@ "preview": "vite preview --host 0.0.0.0 --port 4173" }, "dependencies": { + "jspdf": "^2.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "recharts": "^2.15.1" + "recharts": "^2.15.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-react": "^4.4.1", "vite": "^6.0.7" } -} - +} \ No newline at end of file diff --git a/web/src/App.jsx b/web/src/App.jsx index c45ad41..a73ba71 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,12 +1,20 @@ + import { useEffect, useMemo, useState } from "react"; import { api } from "./api.js"; import Gauge from "./components/Gauge.jsx"; import TrendChart from "./components/TrendChart.jsx"; const views = [ - { id: "dashboard", label: "Dashboard" }, - { id: "trends", label: "Trend" }, - { id: "admin", label: "Admin" }, + { id: "dashboard", label: "Dashboard", short: "D" }, + { id: "trends", label: "Trend", short: "T" }, + { id: "admin", label: "Admin", short: "A" }, + { id: "settings", label: "Einstellungen", short: "E" }, +]; + +const adminSections = [ + { id: "aliases", label: "Aliase" }, + { id: "users", label: "Benutzer" }, + { id: "sources", label: "Quellen" }, ]; const emptyTrendRange = { @@ -16,12 +24,128 @@ const emptyTrendRange = { maxRows: 4000, }; +const defaultSelectionDraft = { + name: "", + description: "", + shared: true, +}; + +const defaultWidgetDraft = { + title: "", + type: "chart", + source: "manual", + selectionId: "", + rangePresetDays: 1, + min: 0, + max: 100, +}; + +const pointColors = [ + "#d62839", + "#2563eb", + "#ef4444", + "#1d4ed8", + "#f97316", + "#0f766e", + "#b91c1c", + "#1e40af", + "#dc2626", + "#0891b2", +]; + +const UNIT_PRESETS = ["°C", "%", "K", "V", "A", "Pa"]; +const CUSTOM_UNIT_VALUE = "__custom__"; +const PROTOCOL_DEFAULT_PORTS = { + "modbus-tcp": "502", + "opc-ua": "4840", + "bacnet-ip": "47808", + "knx-ip": "3671", +}; +const defaultSourceDraft = { + id: "", + protocol: "modbus-tcp", + name: "", + host: "", + port: PROTOCOL_DEFAULT_PORTS["modbus-tcp"], + deviceId: "", + tableName: "", + displayName: "", + writeMode: "interval", + pollIntervalSeconds: "60", +}; + + function createWidgetId() { - return "widget-" + Date.now() + "-" + Math.round(Math.random() * 1000); + return `widget-${Date.now()}-${Math.round(Math.random() * 1000)}`; +} + +function slugifyValue(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function buildCollectorTableName(name, protocol) { + const base = slugifyValue(name || protocol || "daten") || "daten"; + return base.startsWith("trend_") ? base : `trend_${base}`; +} + +function getDefaultProtocolPort(protocol) { + return PROTOCOL_DEFAULT_PORTS[protocol] || ""; +} + +function formatValue(value) { + return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "--"; +} + +function formatTimestampLabel(timestamp) { + return timestamp ? new Date(timestamp).toLocaleString("de-DE") : "Aktueller Wert"; +} + +function hslToHexString(value) { + const match = String(value || "").match(/^hsl\((\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\)$/i); + if (!match) { + return value; + } + + const hue = Number(match[1]); + const saturation = Number(match[2]) / 100; + const lightness = Number(match[3]) / 100; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const secondary = chroma * (1 - Math.abs((hue / 60) % 2 - 1)); + const adjustment = lightness - chroma / 2; + + let red = 0; + let green = 0; + let blue = 0; + + if (hue < 60) { + red = chroma; + green = secondary; + } else if (hue < 120) { + red = secondary; + green = chroma; + } else if (hue < 180) { + green = chroma; + blue = secondary; + } else if (hue < 240) { + green = secondary; + blue = chroma; + } else if (hue < 300) { + red = secondary; + blue = chroma; + } else { + red = chroma; + blue = secondary; + } + + const toHex = (channel) => Math.round((channel + adjustment) * 255).toString(16).padStart(2, "0"); + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; } function downloadText(filename, content, type) { - const blob = new Blob([content], { type: type }); + const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; @@ -30,24 +154,6 @@ function downloadText(filename, content, type) { URL.revokeObjectURL(url); } -function exportSeriesAsCsv(result) { - if (!result?.rows?.length) { - return; - } - - const headers = ["timestamp"].concat( - result.series.map((series) => (series.displayName + " " + series.alias).trim()) - ); - const rows = result.rows.map((row) => [ - row.timestamp, - ...result.series.map((series) => row[series.key] ?? ""), - ]); - const csv = [headers].concat(rows) - .map((line) => line.map((cell) => '"' + String(cell).replaceAll('"', '""') + '"').join(";")) - .join("\n"); - downloadText("trend-export.csv", csv, "text/csv;charset=utf-8;"); -} - function splitCsvLine(line) { const values = []; let current = ""; @@ -67,7 +173,7 @@ function splitCsvLine(line) { continue; } - if (!inQuotes && (char === ';' || char === ',')) { + if (!inQuotes && (char === ";" || char === ",")) { values.push(current.trim()); current = ""; continue; @@ -99,7 +205,7 @@ async function readAliasCsv(file) { } function buildAliasCsv(aliasEditor) { - const header = ["pointIndex", "alias", "unit", "factor", "kind", "min", "max"]; + const header = ["pointIndex", "alias", "unit", "factor", "kind", "min", "max", "color"]; const rows = Object.entries(aliasEditor.points).map(([pointIndex, point]) => [ pointIndex, point.alias, @@ -108,12 +214,130 @@ function buildAliasCsv(aliasEditor) { point.kind, point.min, point.max, + point.color || "", ]); - return [header].concat(rows) - .map((line) => line.map((cell) => '"' + String(cell ?? "").replaceAll('"', '""') + '"').join(";")) + + return [header] + .concat(rows) + .map((line) => line.map((cell) => `"${String(cell ?? "").replaceAll('"', '""')}"`).join(";")) .join("\n"); } +function hashString(value) { + return String(value) + .split("") + .reduce((sum, char) => sum + char.charCodeAt(0), 0); +} + +function resolvePointColor(point, metadataCache) { + const color = metadataCache[point.isp]?.points?.[String(point.pointIndex)]?.color; + if (color) { + return color; + } + + const key = `${point.isp}:${point.pointIndex}`; + return pointColors[hashString(key) % pointColors.length]; +} + +function resolveLatestColor(value) { + if (value.color) { + return value.color; + } + return pointColors[hashString(value.key) % pointColors.length]; +} + +function getUserDisplayName(currentUser) { + if (!currentUser) { + return ""; + } + + const name = String(currentUser.name || "").trim(); + const username = String(currentUser.username || "").trim(); + if (!name) { + return username; + } + + return name.toLowerCase() === username.toLowerCase() ? name : `${name} · @${username}`; +} + +function SmallFootnote() { + return
by J.H. | SE Inno GmbH
; +} + +function UnitField({ value, onChange }) { + const normalizedValue = value || "°C"; + const selectedValue = UNIT_PRESETS.includes(normalizedValue) ? normalizedValue : CUSTOM_UNIT_VALUE; + + return ( +
+ + {selectedValue === CUSTOM_UNIT_VALUE ? ( + onChange(event.target.value)} /> + ) : null} +
+ ); +} + +function ColorField({ value, onChange }) { + const normalizedValue = hslToHexString(value || ""); + const pickerValue = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(normalizedValue || "") ? normalizedValue : "#d62839"; + + return ( +
+ onChange(event.target.value)} /> + onChange(event.target.value)} /> +
+ ); +} + +function TrashIcon() { + return ( + + ); +} + +function PinIcon({ pinned }) { + return pinned ? ( + + ) : ( + + ); +} + +function IconButton({ title, onClick, children, danger = false }) { + return ( + + ); +} + function LoginScreen({ onLogin, error, busy }) { const [username, setUsername] = useState("admin"); const [password, setPassword] = useState("ChangeMe123!"); @@ -127,7 +351,7 @@ function LoginScreen({ onLogin, error, busy }) {
- SE logo + SE Logo
Login
SE Local Trenddata.
@@ -135,32 +359,37 @@ function LoginScreen({ onLogin, error, busy }) {
{error ?
{error}
: null} - + +
); } -function ThemeToggle({ value, onChange }) { +function ThemeSelect({ value, onChange }) { + const options = [ + { value: "system", label: "System" }, + { value: "light", label: "Hell" }, + { value: "dark", label: "Dunkel" }, + ]; + return (
- {["system", "light", "dark"].map((mode) => ( + {options.map((option) => ( ))}
@@ -180,34 +409,31 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint }) if (!search) { return true; } - const haystack = (point.alias + " " + point.unit + " Wert" + point.pointIndex).toLowerCase(); + const haystack = `${point.alias} ${point.unit} wert ${point.pointIndex}`.toLowerCase(); return haystack.includes(search); }); }, [filterText, metadata]); return ( -
-
-

Datenpunkte

- {metadata?.displayName || "ISP"} +
+
+
+

Datenpunkte

+

{metadata?.displayName || "ISP"}

+
onFilterTextChange(event.target.value)} />
{filteredPoints.map((point) => ( - @@ -220,12 +446,14 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint }) export default function App() { const [token, setToken] = useState(() => localStorage.getItem("seltd-token") || ""); const [activeView, setActiveView] = useState("dashboard"); + const [adminSection, setAdminSection] = useState("aliases"); const [user, setUser] = useState(null); const [loginError, setLoginError] = useState(""); const [bootError, setBootError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); const [busy, setBusy] = useState(false); const [sessionReady, setSessionReady] = useState(false); + const [preferencesReady, setPreferencesReady] = useState(false); const [isps, setIsps] = useState([]); const [selectedIsp, setSelectedIsp] = useState(""); const [metadataCache, setMetadataCache] = useState({}); @@ -235,39 +463,48 @@ export default function App() { const [trendResult, setTrendResult] = useState(null); const [latestValues, setLatestValues] = useState([]); const [selections, setSelections] = useState([]); - const [selectionDraft, setSelectionDraft] = useState({ name: "", description: "", shared: true }); + const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft); const [dashboard, setDashboard] = useState({ widgets: [] }); const [dashboardData, setDashboardData] = useState({}); - const [widgetDraft, setWidgetDraft] = useState({ - title: "", - type: "chart", - source: "manual", - selectionId: "", - rangePresetDays: 1, - min: 0, - max: 100, - }); + const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft); const [users, setUsers] = useState([]); const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" }); const [themeMode, setThemeMode] = useState("system"); + const [csvDelimiter, setCsvDelimiter] = useState(";"); + const [sidebarPinned, setSidebarPinned] = useState(true); + const [sidebarHover, setSidebarHover] = useState(false); + const [trendFocus, setTrendFocus] = useState(false); const [adminIsp, setAdminIsp] = useState(""); const [aliasEditor, setAliasEditor] = useState(null); + const [passwordDraft, setPasswordDraft] = useState({ currentPassword: "", nextPassword: "", repeatPassword: "" }); + const [newTableDraft, setNewTableDraft] = useState({ tableName: "", displayName: "" }); + const [collectorCapabilities, setCollectorCapabilities] = useState(null); + const [collectorSources, setCollectorSources] = useState([]); + const [collectorDatapoints, setCollectorDatapoints] = useState([]); + const [selectedSourceId, setSelectedSourceId] = useState(""); + const [isCreatingSource, setIsCreatingSource] = useState(false); + const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); + const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null }); + const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]); const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null; const canEditAliases = user?.role === "admin" || user?.role === "technician"; + const canManageUsers = user?.role === "admin"; + const sidebarCollapsed = !sidebarPinned && !sidebarHover; + const currentView = views.find((view) => view.id === activeView) || views[0]; + const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list"; + const currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]); + const collectorSourcePoints = useMemo(() => ( + selectedSourceId + ? collectorDatapoints.filter((item) => item.sourceId === selectedSourceId) + : collectorDatapoints + ), [collectorDatapoints, selectedSourceId]); const resetFlash = () => { setBootError(""); setSuccessMessage(""); }; - useEffect(() => { - const storedMode = localStorage.getItem("seltd-theme-mode"); - if (storedMode) { - setThemeMode(storedMode); - } - }, []); - useEffect(() => { const media = window.matchMedia("(prefers-color-scheme: dark)"); const applyTheme = () => { @@ -282,6 +519,7 @@ export default function App() { useEffect(() => { if (!token) { setSessionReady(true); + setPreferencesReady(false); return; } @@ -305,12 +543,12 @@ export default function App() { setUser(me.user); setThemeMode(pref.theme || "system"); + setCsvDelimiter(pref.csvDelimiter || ";"); + setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true); setIsps(ispResult.isps); setSelections(selectionResult.selections); setDashboard(dashboardResult); - const firstIsp = ispResult.isps[0]?.tableName || ""; - setSelectedIsp((current) => current || firstIsp); - setAdminIsp((current) => current || firstIsp); + setPreferencesReady(true); } catch (error) { if (!ignore) { setBootError(error.message); @@ -332,14 +570,63 @@ export default function App() { }; }, [token]); + useEffect(() => { + if (!trendIsps.length) { + setSelectedIsp(""); + return; + } + + if (!trendIsps.some((isp) => isp.tableName === selectedIsp)) { + setSelectedIsp(trendIsps[0].tableName); + } + }, [selectedIsp, trendIsps]); + + useEffect(() => { + if (!isps.length) { + setAdminIsp(""); + return; + } + + if (!isps.some((isp) => isp.tableName === adminIsp)) { + setAdminIsp(isps[0].tableName); + } + }, [adminIsp, isps]); + + useEffect(() => { + if (!collectorSources.length) { + setSelectedSourceId(""); + return; + } + + if (isCreatingSource && !selectedSourceId) { + return; + } + + if (!collectorSources.some((source) => source.id === selectedSourceId)) { + setSelectedSourceId(collectorSources[0].id); + } + }, [collectorSources, isCreatingSource, selectedSourceId]); + + useEffect(() => { + if (!token || !preferencesReady) { + return; + } + + api.savePreferences(token, { + theme: themeMode, + csvDelimiter, + sidebarPinned, + }).catch(() => undefined); + }, [csvDelimiter, preferencesReady, sidebarPinned, themeMode, token]); + useEffect(() => { if (!token || !selectedIsp || metadataCache[selectedIsp]) { return; } - api.getAliases(token, selectedIsp).then((result) => { - setMetadataCache((current) => ({ ...current, [selectedIsp]: result })); - }).catch((error) => setBootError(error.message)); + api.getAliases(token, selectedIsp) + .then((result) => setMetadataCache((current) => ({ ...current, [selectedIsp]: result }))) + .catch((error) => setBootError(error.message)); }, [metadataCache, selectedIsp, token]); useEffect(() => { @@ -347,10 +634,12 @@ export default function App() { return; } - api.getAliases(token, adminIsp).then((result) => { - setAliasEditor(result); - setMetadataCache((current) => ({ ...current, [adminIsp]: result })); - }).catch((error) => setBootError(error.message)); + api.getAliases(token, adminIsp) + .then((result) => { + setAliasEditor(result); + setMetadataCache((current) => ({ ...current, [adminIsp]: result })); + }) + .catch((error) => setBootError(error.message)); }, [adminIsp, token]); useEffect(() => { @@ -363,15 +652,17 @@ export default function App() { return; } - Promise.all(missingIsps.map((isp) => api.getAliases(token, isp))).then((results) => { - setMetadataCache((current) => { - const next = { ...current }; - results.forEach((result) => { - next[result.isp] = result; + Promise.all(missingIsps.map((isp) => api.getAliases(token, isp))) + .then((results) => { + setMetadataCache((current) => { + const next = { ...current }; + results.forEach((result) => { + next[result.isp] = result; + }); + return next; }); - return next; - }); - }).catch((error) => setBootError(error.message)); + }) + .catch((error) => setBootError(error.message)); }, [metadataCache, token, workingPoints]); useEffect(() => { @@ -380,28 +671,62 @@ export default function App() { return; } - api.loadDashboardData(token, { widgets: dashboard.widgets }).then((result) => { - setDashboardData(Object.fromEntries(result.widgets.map((widget) => [widget.id, widget]))); - }).catch((error) => setBootError(error.message)); + api.loadDashboardData(token, { widgets: dashboard.widgets }) + .then((result) => setDashboardData(Object.fromEntries(result.widgets.map((widget) => [widget.id, widget])))) + .catch((error) => setBootError(error.message)); }, [dashboard, token]); - useEffect(() => { - localStorage.setItem("seltd-theme-mode", themeMode); - if (!token) { - return; - } - api.savePreferences(token, { theme: themeMode }).catch(() => undefined); - }, [themeMode, token]); - useEffect(() => { if (!token || user?.role !== "admin") { setUsers([]); return; } - api.getUsers(token).then((result) => setUsers(result.users)).catch((error) => setBootError(error.message)); + api.getUsers(token) + .then((result) => setUsers(result.users)) + .catch((error) => setBootError(error.message)); }, [token, user]); + useEffect(() => { + if (!token || !canEditAliases) { + setCollectorCapabilities(null); + setCollectorSources([]); + setCollectorDatapoints([]); + return; + } + + api.getCollectorCapabilities() + .then((result) => setCollectorCapabilities(result)) + .catch((error) => setBootError(error.message)); + + refreshCollectorData().catch((error) => setBootError(error.message)); + }, [canEditAliases, token]); + + useEffect(() => { + if (!currentSource) { + return; + } + + setSourceDraft({ + id: currentSource.id, + protocol: currentSource.protocol || "modbus-tcp", + name: currentSource.displayName || currentSource.name || "", + host: currentSource.host || "", + port: String(currentSource.port || getDefaultProtocolPort(currentSource.protocol || "modbus-tcp") || ""), + deviceId: currentSource.deviceId || "", + tableName: currentSource.tableName || buildCollectorTableName(currentSource.displayName || currentSource.name || "", currentSource.protocol || "modbus-tcp"), + displayName: currentSource.displayName || currentSource.name || "", + writeMode: currentSource.writeMode === "cov" ? "cov" : "interval", + pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60), + }); + }, [currentSource]); + + useEffect(() => { + if (!canManageUsers && adminSection === "users") { + setAdminSection("aliases"); + } + }, [adminSection, canManageUsers]); + const handleLogin = async (credentials) => { setBusy(true); resetFlash(); @@ -426,20 +751,34 @@ export default function App() { setDashboard({ widgets: [] }); setDashboardData({}); setMetadataCache({}); + setTrendResult(null); + setLatestValues([]); + setWorkingPoints([]); + setSelectionDraft(defaultSelectionDraft); localStorage.removeItem("seltd-token"); }; const addPoint = (pointIndex, isp = selectedIsp) => { - if (workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) { + if (!isp || workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) { return; } - setWorkingPoints((current) => current.concat([{ isp: isp, pointIndex: pointIndex }])); + + setWorkingPoints((current) => current.concat([{ isp, pointIndex }])); }; const removePoint = (target) => { setWorkingPoints((current) => current.filter((point) => !(point.isp === target.isp && point.pointIndex === target.pointIndex))); }; + const startNewSelection = () => { + setWorkingPoints([]); + setSelectionDraft(defaultSelectionDraft); + setTrendResult(null); + setLatestValues([]); + setTrendFocus(false); + setPointFilter(""); + }; + const loadSelection = (selection) => { setWorkingPoints(selection.points); setSelectionDraft({ @@ -447,6 +786,7 @@ export default function App() { description: selection.description || "", shared: Boolean(selection.shared), }); + setTrendFocus(false); setActiveView("trends"); }; @@ -463,7 +803,7 @@ export default function App() { const runTrendQuery = async () => { if (workingPoints.length === 0) { - setBootError("Bitte zuerst Datenpunkte auswaehlen."); + setBootError("Bitte zuerst Datenpunkte auswählen."); return; } @@ -474,7 +814,7 @@ export default function App() { ? { from: trendRange.from, to: trendRange.to, maxRows: Number(trendRange.maxRows) } : { presetDays: Number(trendRange.presetDays), maxRows: Number(trendRange.maxRows) }; const [trend, latest] = await Promise.all([ - api.queryTrends(token, { points: workingPoints, range: range }), + api.queryTrends(token, { points: workingPoints, range }), api.latestValues(token, { points: workingPoints }), ]); setTrendResult(trend); @@ -509,7 +849,7 @@ export default function App() { const addWidget = () => { const widget = { id: createWidgetId(), - title: widgetDraft.title || "Widget " + (dashboard.widgets.length + 1), + title: widgetDraft.title || `Widget ${dashboard.widgets.length + 1}`, type: widgetDraft.type, range: { presetDays: Number(widgetDraft.rangePresetDays || 1) }, min: Number(widgetDraft.min), @@ -523,7 +863,7 @@ export default function App() { } if (!widget.selectionId && (!widget.points || widget.points.length === 0)) { - setBootError("Fuer das Widget fehlen Datenpunkte oder eine Auswahl."); + setBootError("Für das Widget fehlen Datenpunkte oder eine Auswahl."); return; } @@ -548,7 +888,33 @@ export default function App() { } }; + const refreshCollectorData = async () => { + const [sourceResult, datapointResult] = await Promise.all([ + api.getCollectorSources(), + api.getCollectorDatapoints(), + ]); + setCollectorSources(sourceResult.sources || []); + setCollectorDatapoints(datapointResult.datapoints || []); + }; + + const updateAliasPoint = (pointIndex, changes) => { + setAliasEditor((current) => ({ + ...current, + points: { + ...current.points, + [pointIndex]: { + ...current.points[pointIndex], + ...changes, + }, + }, + })); + }; + const saveAliasChanges = async () => { + if (!aliasEditor) { + return; + } + resetFlash(); try { const payload = { @@ -570,11 +936,30 @@ export default function App() { } }; + const normalizeAliasMetadata = async () => { + if (!adminIsp) { + return; + } + + resetFlash(); + try { + const result = await api.normalizeAliasMetadata(token, { isp: adminIsp }); + const normalized = result.items?.[0]?.metadata || await api.getAliases(token, adminIsp); + setAliasEditor(normalized); + setMetadataCache((current) => ({ ...current, [adminIsp]: normalized })); + const refreshed = await api.getIsps(token); + setIsps(refreshed.isps); + setSuccessMessage(`Alias-Metadaten für ${adminIsp.toUpperCase()} bereinigt.`); + } catch (error) { + setBootError(error.message); + } + }; + const exportAliasCsv = () => { if (!aliasEditor) { return; } - downloadText(adminIsp + "-aliases.csv", buildAliasCsv(aliasEditor), "text/csv;charset=utf-8;"); + downloadText(`${adminIsp}-aliases.csv`, buildAliasCsv(aliasEditor), "text/csv;charset=utf-8;"); }; const importAliasCsv = async (event) => { @@ -599,10 +984,11 @@ export default function App() { kind: row.kind === "digital" ? "digital" : "analog", min: Number.isFinite(Number(row.min)) ? Number(row.min) : nextPoints[pointIndex].min, max: Number.isFinite(Number(row.max)) ? Number(row.max) : nextPoints[pointIndex].max, + color: row.color || nextPoints[pointIndex].color || "", }; }); setAliasEditor((current) => ({ ...current, points: nextPoints })); - setSuccessMessage("CSV fuer Aliase geladen. Bitte noch speichern."); + setSuccessMessage("CSV für Aliase geladen. Bitte noch speichern."); } catch (error) { setBootError(error.message || "CSV konnte nicht gelesen werden."); } finally { @@ -619,7 +1005,7 @@ export default function App() { resetFlash(); try { const sql = await file.text(); - await api.importSql(token, { filename: file.name, sql: sql }); + await api.importSql(token, { filename: file.name, sql }); const refreshed = await api.getIsps(token); setIsps(refreshed.isps); setSuccessMessage("SQL-Datei importiert."); @@ -630,6 +1016,171 @@ export default function App() { } }; + const createTrendTable = async () => { + if (!newTableDraft.tableName.trim()) { + setBootError("Bitte zuerst einen technischen Tabellennamen eingeben."); + return; + } + + resetFlash(); + try { + const result = await api.createTrendTable(token, newTableDraft); + const refreshed = await api.getIsps(token); + setIsps(refreshed.isps); + setAdminIsp(result.tableName); + setNewTableDraft({ tableName: "", displayName: "" }); + setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`); + } catch (error) { + setBootError(error.message); + } + }; + + const startNewSource = () => { + setIsCreatingSource(true); + setSelectedSourceId(""); + setSourceDraft({ + ...defaultSourceDraft, + port: getDefaultProtocolPort(defaultSourceDraft.protocol), + }); + }; + + const updateSourceProtocol = (nextProtocol) => { + setSourceDraft((current) => { + const oldDefaultPort = getDefaultProtocolPort(current.protocol); + const nextDefaultPort = getDefaultProtocolPort(nextProtocol); + const shouldReplacePort = !current.port || current.port === oldDefaultPort; + + return { + ...current, + protocol: nextProtocol, + port: shouldReplacePort ? nextDefaultPort : current.port, + tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol), + }; + }); + }; + + const saveCollectorSource = async () => { + if (!sourceDraft.name.trim()) { + setBootError("Bitte einen Alias bzw. Anzeigenamen angeben."); + return; + } + + resetFlash(); + try { + const payload = { + ...sourceDraft, + name: sourceDraft.name.trim(), + tableName: sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol), + displayName: sourceDraft.name.trim(), + }; + + const saved = payload.id + ? await api.updateCollectorSource(payload.id, payload) + : await api.createCollectorSource(payload); + + await api.createTrendTable(token, { + tableName: payload.tableName, + displayName: payload.displayName, + }); + + await refreshCollectorData(); + const refreshed = await api.getIsps(token); + setIsps(refreshed.isps); + setIsCreatingSource(false); + setSelectedSourceId(saved.id); + setSuccessMessage("Protokollquelle gespeichert."); + } catch (error) { + setBootError(error.message); + } + }; + + const deleteCollectorSource = async () => { + if (!selectedSourceId) { + return; + } + + resetFlash(); + try { + await api.deleteCollectorSource(selectedSourceId); + await refreshCollectorData(); + setIsCreatingSource(false); + setSelectedSourceId(""); + setSourceDraft(defaultSourceDraft); + setSuccessMessage("Protokollquelle gelöscht."); + } catch (error) { + setBootError(error.message); + } + }; + + const importCollectorFile = async (event, type) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + const activeSourceId = selectedSourceId || sourceDraft.id; + if (!activeSourceId) { + setBootError("Bitte zuerst eine Quelle speichern oder auswählen."); + event.target.value = ""; + return; + } + + resetFlash(); + try { + const content = await file.text(); + const payload = { + sourceId: activeSourceId, + tableName: currentSource?.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol), + }; + + if (type === "modbus") { + await api.importModbusCsv({ ...payload, csv: content, delimiter: ";" }); + } else { + await api.importKnxXml({ ...payload, xml: content }); + } + + await refreshCollectorData(); + setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert."); + } catch (error) { + setBootError(error.message); + } finally { + event.target.value = ""; + } + }; + + const runOpcuaScan = async () => { + resetFlash(); + try { + const result = await api.scanOpcua({ + host: sourceDraft.host, + port: sourceDraft.port || "4840", + }); + setScanResults((current) => ({ ...current, opcua: result })); + setSuccessMessage("OPC-UA-Scan abgeschlossen."); + } catch (error) { + setBootError(error.message); + } + }; + + const runBacnetScan = async () => { + resetFlash(); + try { + const hosts = sourceDraft.host + .split(/[;,\s]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + const result = await api.scanBacnet({ + hosts, + port: sourceDraft.port || "47808", + deviceId: sourceDraft.deviceId, + }); + setScanResults((current) => ({ ...current, bacnet: result })); + setSuccessMessage("BACnet-Scan abgeschlossen."); + } catch (error) { + setBootError(error.message); + } + }; + const createUser = async () => { resetFlash(); try { @@ -649,6 +1200,31 @@ export default function App() { await api.updateUser(token, targetUser.id, changes); const refreshed = await api.getUsers(token); setUsers(refreshed.users); + setSuccessMessage("Benutzer aktualisiert."); + } catch (error) { + setBootError(error.message); + } + }; + + const changePassword = async () => { + resetFlash(); + if (!passwordDraft.currentPassword || !passwordDraft.nextPassword) { + setBootError("Bitte aktuelles und neues Passwort eingeben."); + return; + } + + if (passwordDraft.nextPassword !== passwordDraft.repeatPassword) { + setBootError("Die neuen Passwörter stimmen nicht überein."); + return; + } + + try { + await api.changePassword(token, { + currentPassword: passwordDraft.currentPassword, + nextPassword: passwordDraft.nextPassword, + }); + setPasswordDraft({ currentPassword: "", nextPassword: "", repeatPassword: "" }); + setSuccessMessage("Passwort geändert."); } catch (error) { setBootError(error.message); } @@ -659,63 +1235,87 @@ export default function App() { } if (!sessionReady) { - return
Lade Sitzung...
; + return
Sitzung wird geladen ...
; } return ( -
-