From f0cb94ce4e55175a0f563c1fbfe1105b8503f39c Mon Sep 17 00:00:00 2001 From: jhartworks Date: Tue, 23 Jun 2026 14:48:36 +0200 Subject: [PATCH] commit --- .gitignore | 1 - api/src/index.js | 165 +++++++++++-- api/src/lib/store.js | 113 +++++++++ collector/src/index.js | 122 +++++++++- web/index.html | 8 +- web/src/App.jsx | 392 ++++++++++++++++++++++++++++-- web/src/api.js | 5 + web/src/components/TrendChart.jsx | 71 ++++-- 8 files changed, 796 insertions(+), 81 deletions(-) diff --git a/.gitignore b/.gitignore index 07a1a32..f44ecb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules dist -.env .DS_Store npm-debug.log* .scratch/runtime diff --git a/api/src/index.js b/api/src/index.js index e270c23..cf72b06 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -20,10 +20,13 @@ import { deleteSelection, getDashboard, getPreferences, + getRoleMap, getSelection, + listRoles, listSelections, saveDashboard, savePreferences, + saveRole, saveSelection, } from "./lib/store.js"; import { redis } from "./redis.js"; @@ -111,6 +114,70 @@ function normalizeSqlDump(sql) { .trim(); } +const PERMISSION_CATALOG = [ + { id: "view_dashboard", label: "Dashboard anzeigen" }, + { id: "view_trends", label: "Trendansicht anzeigen" }, + { id: "edit_aliases", label: "Aliase bearbeiten" }, + { id: "manage_sources", label: "Quellen verwalten" }, + { id: "manage_users", label: "Benutzer verwalten" }, + { id: "manage_roles", label: "Rollen verwalten" }, + { id: "view_debug", label: "Treiber-Debug anzeigen" }, +]; + +async function getUserPermissions(roleName) { + const roleMap = await getRoleMap(redis); + return roleMap.get(String(roleName || "").trim())?.permissions || []; +} + +async function serializeUserWithPermissions(row) { + const user = serializeUser(row); + return { + ...user, + permissions: await getUserPermissions(user.role), + }; +} + +function requirePermission(...permissions) { + return async (req, res, next) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required." }); + } + + const granted = new Set(await getUserPermissions(req.user.role)); + if (!permissions.some((permission) => granted.has(permission))) { + return res.status(403).json({ error: "Insufficient permissions." }); + } + + next(); + } catch (error) { + next(error); + } + }; +} + +function normalizeRoleName(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "viewer"; +} + +async function resolveExistingRole(roleName, fallback = "viewer") { + const roleMap = await getRoleMap(redis); + const normalized = normalizeRoleName(roleName); + return roleMap.has(normalized) ? normalized : fallback; +} + +async function ensureRolesSchema() { + await listRoles(redis); + try { + await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'"); + } catch {} +} + async function resolveSelectionPoints(body, user) { if (body.selectionId) { const selection = await getSelection(redis, body.selectionId); @@ -150,7 +217,7 @@ async function ensureUserSchema() { name VARCHAR(120) NOT NULL, email VARCHAR(160) NULL, password_hash VARCHAR(255) NOT NULL, - role ENUM('viewer', 'technician', 'admin') NOT NULL DEFAULT 'viewer', + role VARCHAR(80) NOT NULL DEFAULT 'viewer', active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -193,6 +260,10 @@ async function ensureUserSchema() { try { await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)"); } catch {} + + try { + await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'"); + } catch {} } async function ensureAdminUser() { @@ -255,7 +326,7 @@ app.post("/api/auth/login", wrap(async (req, res) => { return res.status(401).json({ error: "Ungültige Zugangsdaten." }); } - const serializedUser = serializeUser(user); + const serializedUser = await serializeUserWithPermissions(user); return res.json({ token: createToken(serializedUser), @@ -296,7 +367,7 @@ app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => { return res.status(404).json({ error: "Benutzer nicht gefunden." }); } - return res.json({ user: serializeUser(rows[0]) }); + return res.json({ user: await serializeUserWithPermissions(rows[0]) }); })); app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { @@ -323,7 +394,7 @@ app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => { app.put( "/api/isps/:isp/aliases", authenticateToken, - requireRoles("technician", "admin"), + requirePermission("edit_aliases"), wrap(async (req, res) => { const { displayName, description, points } = req.body || {}; const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => { @@ -359,7 +430,7 @@ app.put( }) ); -app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(async (req, res) => { +app.post("/api/trend-tables", authenticateToken, requirePermission("manage_sources"), 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}`); @@ -373,7 +444,7 @@ app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(asy app.post( "/api/admin/normalize-alias-metadata", authenticateToken, - requireRoles("technician", "admin"), + requirePermission("edit_aliases"), wrap(async (req, res) => { const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null; @@ -398,7 +469,7 @@ app.post( app.post( "/api/admin/import-sql", authenticateToken, - requireRoles("admin"), + requirePermission("manage_sources"), wrap(async (req, res) => { const sql = normalizeSqlDump(req.body.sql); @@ -412,22 +483,26 @@ app.post( }) ); -app.get("/api/users", authenticateToken, requireRoles("admin"), wrap(async (_req, res) => { +app.get("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (_req, res) => { const [rows] = await pool.query( "SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username" ); res.json({ users: rows.map(serializeUser) }); })); -app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req, res) => { +app.post("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => { const username = String(req.body.username || "").trim().toLowerCase(); const name = String(req.body.name || "").trim(); const email = String(req.body.email || "").trim().toLowerCase() || null; - const role = ["viewer", "technician", "admin"].includes(req.body.role) ? req.body.role : "viewer"; const password = String(req.body.password || ""); + const role = await resolveExistingRole(req.body.role, "viewer"); - if (!username || !name || password.length < 8) { - return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." }); + if (!username || !name) { + return res.status(400).json({ error: "Username und Name sind erforderlich." }); + } + + if (password.length < 8) { + return res.status(400).json({ error: "Das Passwort muss mindestens 8 Zeichen lang sein." }); } const passwordHash = await bcrypt.hash(password, 10); @@ -444,7 +519,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req res.status(201).json({ user: serializeUser(rows[0]) }); })); -app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async (req, res) => { +app.patch("/api/users/:id", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => { const userId = Number(req.params.id); const payload = req.body || {}; const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]); @@ -457,7 +532,11 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async 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 role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role; + const nextRole = payload.role ? await resolveExistingRole(payload.role, current.role) : current.role; + if (userId === req.user.sub && payload.role && nextRole !== current.role) { + return res.status(403).json({ error: "Die eigene Rolle kann nicht geändert werden." }); + } + 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; @@ -467,19 +546,54 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ? WHERE id = ? `, - [username, name, email, role, active, passwordHash, userId] + [username, name, email, nextRole, active, passwordHash, userId] ); const [updatedRows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]); res.json({ user: serializeUser(updatedRows[0]) }); })); -app.get("/api/selections", authenticateToken, wrap(async (req, res) => { +app.get("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (_req, res) => { + const roles = await listRoles(redis); + res.json({ roles, permissions: PERMISSION_CATALOG }); +})); + +app.post("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => { + const payload = req.body || {}; + const name = normalizeRoleName(payload.name); + if (!name) { + return res.status(400).json({ error: "Rollenname fehlt." }); + } + + const role = await saveRole(redis, { + name, + label: String(payload.label || name).trim() || name, + permissions: Array.isArray(payload.permissions) ? payload.permissions : [], + }); + res.status(201).json({ role }); +})); + +app.patch("/api/roles/:name", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => { + const roleMap = await getRoleMap(redis); + const existing = roleMap.get(normalizeRoleName(req.params.name)); + if (!existing) { + return res.status(404).json({ error: "Rolle nicht gefunden." }); + } + + const role = await saveRole(redis, { + ...existing, + label: String(req.body.label || existing.label).trim() || existing.label, + permissions: Array.isArray(req.body.permissions) ? req.body.permissions : existing.permissions, + }, existing.name); + res.json({ role }); +})); + +app.get("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const selections = await listSelections(redis, req.user); res.json({ selections }); })); -app.post("/api/selections", authenticateToken, wrap(async (req, res) => { +app.post("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const points = assertPoints(req.body.points || []); const selection = await saveSelection(redis, { ownerId: req.user.sub, @@ -493,7 +607,7 @@ app.post("/api/selections", authenticateToken, wrap(async (req, res) => { res.status(201).json({ selection }); })); -app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => { +app.put("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const existing = await getSelection(redis, req.params.selectionId); if (!existing) { @@ -520,7 +634,7 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) res.json({ selection }); })); -app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => { +app.delete("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const existing = await getSelection(redis, req.params.selectionId); if (!existing) { @@ -535,29 +649,29 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r res.status(204).send(); })); -app.post("/api/trends/query", authenticateToken, wrap(async (req, res) => { +app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const points = await resolveSelectionPoints(req.body, req.user); const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range)); res.json(trendData); })); -app.post("/api/points/latest", authenticateToken, wrap(async (req, res) => { +app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { const points = await resolveSelectionPoints(req.body, req.user); const values = await fetchLatestValues(pool, redis, points); res.json({ values }); })); -app.get("/api/dashboard", authenticateToken, wrap(async (req, res) => { +app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { const dashboard = await getDashboard(redis, req.user.sub); res.json(dashboard); })); -app.put("/api/dashboard", authenticateToken, wrap(async (req, res) => { +app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { const dashboard = await saveDashboard(redis, req.user.sub, req.body || {}); res.json(dashboard); })); -app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => { +app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : []; const results = []; @@ -593,9 +707,12 @@ app.use((error, _req, res, _next) => { await ensureUtf8Database(); await ensureUserSchema(); +await ensureRolesSchema(); await ensureAdminUser(); app.listen(config.port, () => { console.log(`SE Local Trenddata API listening on port ${config.port}`); }); + + diff --git a/api/src/lib/store.js b/api/src/lib/store.js index 7b8fe37..90858ed 100644 --- a/api/src/lib/store.js +++ b/api/src/lib/store.js @@ -1,10 +1,123 @@ const SELECTION_SET_KEY = "selection:ids"; const SELECTION_SEQ_KEY = "selection:seq"; +const ROLE_SET_KEY = "role:names"; + +export const DEFAULT_ROLE_DEFINITIONS = [ + { + name: "viewer", + label: "Viewer", + permissions: ["view_dashboard", "view_trends"], + isSystem: true, + }, + { + name: "technician", + label: "Techniker", + permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug"], + isSystem: true, + }, + { + name: "admin", + label: "Admin", + permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug"], + isSystem: true, + }, +]; function safeParse(jsonValue) { return jsonValue ? JSON.parse(jsonValue) : null; } +function normalizeRoleName(value) { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + + return normalized || "viewer"; +} + +function normalizePermissions(input) { + return [...new Set((Array.isArray(input) ? input : []) + .map((entry) => String(entry || "").trim()) + .filter(Boolean))].sort(); +} + +function normalizeRoleRecord(role) { + const systemRole = DEFAULT_ROLE_DEFINITIONS.find((entry) => entry.name === normalizeRoleName(role?.name)); + return { + name: normalizeRoleName(role?.name), + label: String(role?.label || systemRole?.label || role?.name || "Rolle").trim() || systemRole?.label || "Rolle", + permissions: normalizePermissions(role?.permissions || systemRole?.permissions || []), + isSystem: Boolean(role?.isSystem ?? systemRole?.isSystem), + updatedAt: role?.updatedAt || new Date().toISOString(), + }; +} + +async function ensureDefaultRoles(redis) { + for (const definition of DEFAULT_ROLE_DEFINITIONS) { + const key = `role:${definition.name}`; + const existing = safeParse(await redis.get(key)); + const payload = normalizeRoleRecord({ + ...definition, + ...(existing || {}), + name: definition.name, + label: existing?.label || definition.label, + permissions: Array.isArray(existing?.permissions) && existing.permissions.length ? existing.permissions : definition.permissions, + isSystem: true, + }); + await redis.set(key, JSON.stringify(payload)); + await redis.sadd(ROLE_SET_KEY, definition.name); + } +} + +export async function listRoles(redis) { + await ensureDefaultRoles(redis); + const names = await redis.smembers(ROLE_SET_KEY); + const items = await Promise.all(names.map((name) => redis.get(`role:${name}`))); + + return items + .map((item) => safeParse(item)) + .filter(Boolean) + .map((role) => normalizeRoleRecord(role)) + .sort((left, right) => left.label.localeCompare(right.label, "de-DE")); +} + +export async function getRoleMap(redis) { + const roles = await listRoles(redis); + return new Map(roles.map((role) => [role.name, role])); +} + +export async function saveRole(redis, role, previousName = "") { + await ensureDefaultRoles(redis); + + const existingName = normalizeRoleName(previousName || role?.name); + const payload = normalizeRoleRecord(role); + const current = safeParse(await redis.get(`role:${existingName}`)); + + if (current?.isSystem && existingName !== payload.name) { + throw new Error("Systemrollen können nicht umbenannt werden."); + } + + const merged = normalizeRoleRecord({ + ...(current || {}), + ...payload, + name: current?.isSystem ? existingName : payload.name, + isSystem: Boolean(current?.isSystem || payload.isSystem), + updatedAt: new Date().toISOString(), + }); + + if (existingName && existingName !== merged.name) { + await redis.del(`role:${existingName}`); + await redis.srem(ROLE_SET_KEY, existingName); + } + + await redis.set(`role:${merged.name}`, JSON.stringify(merged)); + await redis.sadd(ROLE_SET_KEY, merged.name); + return merged; +} + export async function listSelections(redis, user) { const ids = await redis.smembers(SELECTION_SET_KEY); const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`))); diff --git a/collector/src/index.js b/collector/src/index.js index d103125..d8eedaa 100644 --- a/collector/src/index.js +++ b/collector/src/index.js @@ -1,4 +1,4 @@ -import { createServer } from "node:http"; +import { createServer } from "node:http"; import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -102,6 +102,27 @@ function normalizePollInterval(value) { return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60; } +function normalizePointKind(value) { + return String(value || "").toLowerCase() === "digital" ? "digital" : "analog"; +} + +function normalizeUnit(value) { + const raw = String(value ?? "").trim(); + if (!raw) { + return "°C"; + } + + return raw + .replace(/°/g, "°") + .replace(/^\?C$/i, "°C") + .replace(/^°\s*C$/i, "°C"); +} + +function normalizePointIndex(value, fallback) { + const numeric = Number(value); + return Number.isInteger(numeric) && numeric >= 1 && numeric <= 200 ? numeric : fallback; +} + function ensureSource(store, body) { const now = new Date().toISOString(); const source = { @@ -120,6 +141,9 @@ function ensureSource(store, body) { writeMode: normalizeWriteMode(body.writeMode), pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds), }, + lastPollAt: body.lastPollAt || null, + lastPollStatus: body.lastPollStatus || "idle", + lastPollError: body.lastPollError || "", createdAt: body.createdAt || now, updatedAt: now, }; @@ -155,16 +179,19 @@ function parseModbusCsv(payload) { return columnIndex >= 0 ? row[columnIndex] || fallback : fallback; }; + const address = get("address", get("register", String(index + 1))); + const pointIndex = normalizePointIndex(get("pointindex", index + 1), index + 1); 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, + address, + pointIndex, name: get("name", `Register ${index + 1}`), alias: get("alias", get("name", `Register ${index + 1}`)), - unit: get("unit"), + unit: normalizeUnit(get("unit")), dataType: get("datatype", "holding-register"), + kind: normalizePointKind(get("kind", get("type", "analog"))), scale: Number(get("scale", 1)) || 1, host: payload.host || "", tableName: payload.tableName || "", @@ -209,8 +236,9 @@ function parseKnxXml(payload) { pointIndex: index + 1, name, alias: name, - unit: "", + unit: normalizeUnit(""), dataType: dpt || "group-address", + kind: dpt.startsWith("1.") ? "digital" : "analog", host: payload.host || "", tableName: payload.tableName || "", createdAt: new Date().toISOString(), @@ -222,6 +250,44 @@ function parseKnxXml(payload) { return datapoints; } +function createManualDatapoint(store, payload) { + const source = store.sources.find((item) => item.id === payload.sourceId); + if (!source) { + throw new Error("Quelle nicht gefunden."); + } + + const existing = store.datapoints.filter((item) => item.sourceId === source.id); + const fallbackIndex = existing.length + 1; + const pointIndex = normalizePointIndex(payload.pointIndex, fallbackIndex); + const protocol = normalizeProtocol(payload.protocol || source.protocol); + const alias = String(payload.alias || payload.name || `Punkt ${pointIndex}`).trim() || `Punkt ${pointIndex}`; + const name = String(payload.name || payload.alias || alias).trim() || alias; + + const datapoint = { + id: payload.id || `dp-${protocol}-${Date.now()}-${pointIndex}`, + sourceId: source.id, + protocol, + pointIndex, + name, + alias, + unit: normalizeUnit(payload.unit), + kind: normalizePointKind(payload.kind), + dataType: String(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")).trim(), + address: String(payload.address || payload.register || "").trim(), + nodeId: String(payload.nodeId || "").trim(), + scale: Number(payload.scale || 1) || 1, + host: source.host || "", + tableName: source.tableName || "", + createdAt: new Date().toISOString(), + }; + + if (!datapoint.address && protocol !== "opc-ua") { + datapoint.address = String(pointIndex); + } + + return datapoint; +} + function tryTcpConnect(host, portNumber, timeout = 1200) { return new Promise((resolve) => { if (!host || !portNumber) { @@ -265,7 +331,8 @@ const server = createServer(async (req, res) => { 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.", + manualDatapoints: ["modbus-tcp", "knx-ip"], + note: "Importe, manuelle Datenpunkte und Diagnose sind aktiv. Live-Polling und echte Feldbus-Kommunikation sind noch im nächsten Ausbauschritt.", }); return; } @@ -283,6 +350,28 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "GET" && url.pathname === "/debug") { + const store = readStore(); + replyJson(res, 200, { + updatedAt: store.updatedAt, + sourceCount: store.sources.length, + datapointCount: store.datapoints.length, + sources: store.sources.map((source) => ({ + ...source, + datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length, + })), + latestScans: { + opcua: store.scans.opcua[0] || null, + bacnet: store.scans.bacnet[0] || null, + }, + driverState: { + pollingImplemented: false, + note: "Der Collector speichert Quellen, Importe und Scan-Ergebnisse bereits. Echtes zyklisches Polling auf Bus-Ebene ist aktuell noch nicht implementiert.", + }, + }); + return; + } + if (req.method === "POST" && url.pathname === "/sources") { try { const body = await readBody(req); @@ -329,6 +418,23 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && url.pathname === "/datapoints/manual") { + try { + const body = await readBody(req); + const next = touchStore((store) => { + const datapoint = createManualDatapoint(store, body); + store.datapoints = store.datapoints.filter((item) => !(item.sourceId === datapoint.sourceId && item.pointIndex === datapoint.pointIndex)); + store.datapoints.push(datapoint); + return store; + }); + const created = next.datapoints.find((item) => item.sourceId === body.sourceId && item.pointIndex === normalizePointIndex(body.pointIndex, 1)); + replyJson(res, 201, { ok: true, datapoint: created || null, updatedAt: next.updatedAt }); + } catch (error) { + replyJson(res, 400, { error: error.message || "Datenpunkt konnte nicht angelegt werden." }); + } + return; + } + if (req.method === "POST" && url.pathname === "/imports/modbus-csv") { try { const body = await readBody(req); @@ -369,6 +475,10 @@ const server = createServer(async (req, res) => { try { const body = await readBody(req); const host = body.host || ""; + if (!host) { + replyJson(res, 400, { error: "Für den OPC-UA-Scan fehlt Host oder IP." }); + return; + } const portNumber = Number(body.port || 4840); const reachable = await tryTcpConnect(host, portNumber); const result = { diff --git a/web/index.html b/web/index.html index 8737284..77d7b89 100644 --- a/web/index.html +++ b/web/index.html @@ -1,13 +1,13 @@ - + - SE Trendportal + SE-LTD +
- - + diff --git a/web/src/App.jsx b/web/src/App.jsx index a73ba71..5cd9600 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -463,12 +463,16 @@ export default function App() { const [trendResult, setTrendResult] = useState(null); const [latestValues, setLatestValues] = useState([]); const [selections, setSelections] = useState([]); + const [selectedSelectionId, setSelectedSelectionId] = useState(""); const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft); const [dashboard, setDashboard] = useState({ widgets: [] }); const [dashboardData, setDashboardData] = useState({}); const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft); const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [rolePermissions, setRolePermissions] = useState(PERMISSION_OPTIONS); const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" }); + const [roleDraft, setRoleDraft] = useState({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] }); const [themeMode, setThemeMode] = useState("system"); const [csvDelimiter, setCsvDelimiter] = useState(";"); const [sidebarPinned, setSidebarPinned] = useState(true); @@ -484,15 +488,24 @@ export default function App() { const [selectedSourceId, setSelectedSourceId] = useState(""); const [isCreatingSource, setIsCreatingSource] = useState(false); const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); + const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" }); const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null }); + const [debugSnapshot, setDebugSnapshot] = useState(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 canEditAliases = hasPermission(user, "edit_aliases"); + const canManageUsers = hasPermission(user, "manage_users"); + const canManageRoles = hasPermission(user, "manage_roles"); + const canViewDebug = hasPermission(user, "view_debug"); 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 roleOptions = roles.length ? roles : [ + { name: "viewer", label: "Viewer" }, + { name: "technician", label: "Techniker" }, + { name: "admin", label: "Admin" }, + ]; const currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]); const collectorSourcePoints = useMemo(() => ( selectedSourceId @@ -677,7 +690,7 @@ export default function App() { }, [dashboard, token]); useEffect(() => { - if (!token || user?.role !== "admin") { + if (!token || !canManageUsers) { setUsers([]); return; } @@ -685,10 +698,24 @@ export default function App() { api.getUsers(token) .then((result) => setUsers(result.users)) .catch((error) => setBootError(error.message)); - }, [token, user]); + }, [canManageUsers, token]); useEffect(() => { - if (!token || !canEditAliases) { + if (!token || !canManageRoles) { + setRoles([]); + return; + } + + api.getRoles(token) + .then((result) => { + setRoles(result.roles || []); + setRolePermissions(result.permissions || PERMISSION_OPTIONS); + }) + .catch((error) => setBootError(error.message)); + }, [canManageRoles, token]); + + useEffect(() => { + if (!token || !(canEditAliases || canManageSources || canViewDebug)) { setCollectorCapabilities(null); setCollectorSources([]); setCollectorDatapoints([]); @@ -700,7 +727,7 @@ export default function App() { .catch((error) => setBootError(error.message)); refreshCollectorData().catch((error) => setBootError(error.message)); - }, [canEditAliases, token]); + }, [canEditAliases, canManageSources, canViewDebug, token]); useEffect(() => { if (!currentSource) { @@ -719,13 +746,45 @@ export default function App() { writeMode: currentSource.writeMode === "cov" ? "cov" : "interval", pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60), }); + setManualDatapointDraft((current) => ({ + ...current, + dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register", + })); }, [currentSource]); useEffect(() => { if (!canManageUsers && adminSection === "users") { setAdminSection("aliases"); + return; } - }, [adminSection, canManageUsers]); + + if (!canManageRoles && adminSection === "roles") { + setAdminSection("aliases"); + return; + } + + if (!canViewDebug && adminSection === "debug") { + setAdminSection("aliases"); + } + }, [activeView, adminSection, canAccessAdmin, canManageRoles, canManageUsers, canViewDashboard, canViewDebug, canViewTrends]); + + useEffect(() => { + if (!selectedSelectionId) { + return; + } + + if (!selections.some((selection) => selection.id === selectedSelectionId)) { + setSelectedSelectionId(""); + } + }, [selectedSelectionId, selections]); + + useEffect(() => { + if (!token || !canViewDebug || adminSection !== "debug") { + return; + } + + refreshCollectorDebug().catch((error) => setBootError(error.message)); + }, [adminSection, canViewDebug, token]); const handleLogin = async (credentials) => { setBusy(true); @@ -771,6 +830,7 @@ export default function App() { }; const startNewSelection = () => { + setSelectedSelectionId(""); setWorkingPoints([]); setSelectionDraft(defaultSelectionDraft); setTrendResult(null); @@ -780,6 +840,7 @@ export default function App() { }; const loadSelection = (selection) => { + setSelectedSelectionId(selection.id); setWorkingPoints(selection.points); setSelectionDraft({ name: selection.name, @@ -840,6 +901,10 @@ export default function App() { }); const refreshed = await api.getSelections(token); setSelections(refreshed.selections); + const savedSelection = refreshed.selections.find((selection) => selection.name === (selectionDraft.name || "Neue Auswahl")) || refreshed.selections[0]; + if (savedSelection) { + setSelectedSelectionId(savedSelection.id); + } setSuccessMessage("Auswahl gespeichert."); } catch (error) { setBootError(error.message); @@ -897,6 +962,11 @@ export default function App() { setCollectorDatapoints(datapointResult.datapoints || []); }; + const refreshCollectorDebug = async () => { + const result = await api.getCollectorDebug(); + setDebugSnapshot(result); + }; + const updateAliasPoint = (pointIndex, changes) => { setAliasEditor((current) => ({ ...current, @@ -1038,6 +1108,7 @@ export default function App() { const startNewSource = () => { setIsCreatingSource(true); setSelectedSourceId(""); + setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" }); setSourceDraft({ ...defaultSourceDraft, port: getDefaultProtocolPort(defaultSourceDraft.protocol), @@ -1084,6 +1155,7 @@ export default function App() { }); await refreshCollectorData(); + await refreshCollectorDebug().catch(() => undefined); const refreshed = await api.getIsps(token); setIsps(refreshed.isps); setIsCreatingSource(false); @@ -1103,6 +1175,7 @@ export default function App() { try { await api.deleteCollectorSource(selectedSourceId); await refreshCollectorData(); + await refreshCollectorDebug().catch(() => undefined); setIsCreatingSource(false); setSelectedSourceId(""); setSourceDraft(defaultSourceDraft); @@ -1112,6 +1185,44 @@ export default function App() { } }; + const downloadModbusTemplate = () => { + const sample = [ + "pointIndex;address;name;alias;unit;datatype;kind;scale", + "1;40001;Vorlauf;Vorlauf;°C;holding-register;analog;1", + "2;40002;Freigabe;Freigabe;;coil;digital;1", + ].join("\n"); + downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;"); + }; + + const saveManualCollectorDatapoint = async () => { + const activeSourceId = selectedSourceId || sourceDraft.id; + if (!activeSourceId) { + setBootError("Bitte zuerst eine Quelle speichern oder auswählen."); + return; + } + + resetFlash(); + try { + await api.createCollectorDatapoint({ + sourceId: activeSourceId, + protocol: sourceDraft.protocol, + alias: manualDatapointDraft.alias, + name: manualDatapointDraft.alias, + address: manualDatapointDraft.address, + pointIndex: manualDatapointDraft.pointIndex, + unit: manualDatapointDraft.unit || "°C", + dataType: manualDatapointDraft.dataType, + kind: manualDatapointDraft.kind, + }); + await refreshCollectorData(); + await refreshCollectorDebug().catch(() => undefined); + setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: sourceDraft.protocol === "knx-ip" ? "group-address" : "holding-register", kind: "analog" }); + setSuccessMessage("Datenpunkt angelegt."); + } catch (error) { + setBootError(error.message); + } + }; + const importCollectorFile = async (event, type) => { const file = event.target.files?.[0]; if (!file) { @@ -1140,6 +1251,7 @@ export default function App() { } await refreshCollectorData(); + await refreshCollectorDebug().catch(() => undefined); setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert."); } catch (error) { setBootError(error.message); @@ -1156,6 +1268,7 @@ export default function App() { port: sourceDraft.port || "4840", }); setScanResults((current) => ({ ...current, opcua: result })); + await refreshCollectorDebug().catch(() => undefined); setSuccessMessage("OPC-UA-Scan abgeschlossen."); } catch (error) { setBootError(error.message); @@ -1175,6 +1288,7 @@ export default function App() { deviceId: sourceDraft.deviceId, }); setScanResults((current) => ({ ...current, bacnet: result })); + await refreshCollectorDebug().catch(() => undefined); setSuccessMessage("BACnet-Scan abgeschlossen."); } catch (error) { setBootError(error.message); @@ -1184,8 +1298,12 @@ export default function App() { const createUser = async () => { resetFlash(); try { - await api.createUser(token, userDraft); - setUserDraft({ name: "", username: "", password: "", role: "viewer" }); + await api.createUser(token, { + ...userDraft, + name: userDraft.name.trim(), + username: userDraft.username.trim(), + }); + setUserDraft({ name: "", username: "", password: "", role: roleOptions.find((role) => role.name === "viewer")?.name || "viewer" }); const refreshed = await api.getUsers(token); setUsers(refreshed.users); setSuccessMessage("Benutzer angelegt."); @@ -1194,6 +1312,35 @@ export default function App() { } }; + const saveRoleDefinition = async () => { + resetFlash(); + try { + const payload = { + name: roleDraft.name.trim(), + label: roleDraft.label.trim() || roleDraft.name.trim(), + permissions: roleDraft.permissions, + }; + if (!payload.name) { + setBootError("Bitte zuerst einen Rollennamen vergeben."); + return; + } + + const existing = roles.find((role) => role.name === payload.name); + if (existing) { + await api.updateRole(token, payload.name, payload); + } else { + await api.createRole(token, payload); + } + + const refreshed = await api.getRoles(token); + setRoles(refreshed.roles || []); + setRolePermissions(refreshed.permissions || PERMISSION_OPTIONS); + setSuccessMessage("Rolle gespeichert."); + } catch (error) { + setBootError(error.message); + } + }; + const updateUser = async (targetUser, changes) => { resetFlash(); try { @@ -1262,7 +1409,18 @@ export default function App() {