diff --git a/api/src/index.js b/api/src/index.js index 7368eab..1dcfc04 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -132,6 +132,9 @@ const PERMISSION_CATALOG = [ { id: "manage_users", label: "Benutzer verwalten" }, { id: "manage_roles", label: "Rollen verwalten" }, { id: "view_debug", label: "Treiber-Debug anzeigen" }, + { id: "manage_alarms", label: "Alarme verwalten" }, + { id: "manage_integrations", label: "API-Verbindungen verwalten" }, + { id: "manage_runner", label: "CSV-Runner verwalten" }, ]; async function getUserPermissions(roleName) { @@ -198,6 +201,51 @@ async function ensureRolesSchema() { } catch {} } + +function sanitizeApiSettings(body = {}) { + return { + mailUrl: String(body.mailUrl || "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de", + mailApiKey: String(body.mailApiKey || "").trim(), + whatsappUrl: String(body.whatsappUrl || "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de", + whatsappApiKey: String(body.whatsappApiKey || "").trim(), + voipUrl: String(body.voipUrl || "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de", + voipApiKey: String(body.voipApiKey || "").trim(), + updatedAt: new Date().toISOString(), + }; +} + +function sanitizeRunnerJob(body = {}, existing = {}) { + const time = String(body.time ?? existing.time ?? "06:00").trim(); + if (!/^\d{2}:\d{2}$/.test(time)) { + throw new Error("Uhrzeit bitte im Format HH:MM angeben."); + } + return { + ...existing, + id: existing.id || body.id || `job-${Date.now()}`, + name: String(body.name ?? existing.name ?? "CSV Export").trim() || "CSV Export", + selectionId: String(body.selectionId ?? existing.selectionId ?? "").trim(), + enabled: typeof body.enabled === "boolean" ? body.enabled : Boolean(existing.enabled ?? true), + time, + rangeHours: Number(body.rangeHours ?? existing.rangeHours ?? 24) || 24, + delimiter: String(body.delimiter ?? existing.delimiter ?? ";").replace("\\t", "\t") || ";", + mailEnabled: typeof body.mailEnabled === "boolean" ? body.mailEnabled : Boolean(existing.mailEnabled), + mailTo: String(body.mailTo ?? existing.mailTo ?? "").trim(), + mailSubject: String(body.mailSubject ?? existing.mailSubject ?? "SE-LTD CSV Export").trim(), + mailMessage: String(body.mailMessage ?? existing.mailMessage ?? "Automatischer CSV Export aus SE Local Trenddata.").trim(), + updatedAt: new Date().toISOString(), + createdAt: existing.createdAt || new Date().toISOString(), + }; +} + +async function listRunnerJobs() { + const ids = await redis.smembers("runner:job:ids"); + const jobs = await Promise.all(ids.map(async (id) => { + const raw = await redis.get(`runner:job:${id}`); + const result = await redis.get(`runner:last-result:${id}`); + return raw ? { ...JSON.parse(raw), lastResult: result ? JSON.parse(result) : null } : null; + })); + return jobs.filter(Boolean).sort((left, right) => String(left.time).localeCompare(String(right.time))); +} async function resolveSelectionPoints(body, user) { if (body.selectionId) { const selection = await getSelection(redis, body.selectionId); @@ -752,6 +800,51 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res) res.json({ widgets: results }); })); + +app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => { + const current = JSON.parse(await redis.get("integration:api") || "{}"); + res.json({ + ...sanitizeApiSettings(current), + mailConfigured: Boolean(current.mailApiKey), + whatsappConfigured: Boolean(current.whatsappApiKey), + voipConfigured: Boolean(current.voipApiKey), + }); +})); + +app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => { + const payload = sanitizeApiSettings(req.body || {}); + await redis.set("integration:api", JSON.stringify(payload)); + res.json({ ...payload, mailConfigured: Boolean(payload.mailApiKey), whatsappConfigured: Boolean(payload.whatsappApiKey), voipConfigured: Boolean(payload.voipApiKey) }); +})); + +app.get("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (_req, res) => { + res.json({ jobs: await listRunnerJobs() }); +})); + +app.post("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => { + const job = sanitizeRunnerJob(req.body || {}); + if (!job.selectionId) return res.status(400).json({ error: "Selection ist erforderlich." }); + await redis.set(`runner:job:${job.id}`, JSON.stringify(job)); + await redis.sadd("runner:job:ids", job.id); + res.status(201).json(job); +})); + +app.patch("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => { + const id = req.params.jobId; + const existing = JSON.parse(await redis.get(`runner:job:${id}`) || "null"); + if (!existing) return res.status(404).json({ error: "Runner-Job nicht gefunden." }); + const job = sanitizeRunnerJob(req.body || {}, existing); + await redis.set(`runner:job:${id}`, JSON.stringify(job)); + await redis.sadd("runner:job:ids", id); + res.json(job); +})); + +app.delete("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => { + const id = req.params.jobId; + await redis.del(`runner:job:${id}`, `runner:last:${id}`, `runner:last-result:${id}`); + await redis.srem("runner:job:ids", id); + res.json({ ok: true }); +})); app.get("/api/preferences", authenticateToken, wrap(async (req, res) => { const preferences = await getPreferences(redis, req.user.sub); res.json(preferences); @@ -781,3 +874,8 @@ app.listen(config.port, () => { + + + + + diff --git a/api/src/lib/store.js b/api/src/lib/store.js index 66905c1..54a6524 100644 --- a/api/src/lib/store.js +++ b/api/src/lib/store.js @@ -13,13 +13,13 @@ export const DEFAULT_ROLE_DEFINITIONS = [ { name: "technician", label: "Techniker", - permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug"], + permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug", "manage_alarms"], isSystem: true, }, { name: "admin", label: "Admin", - permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug"], + permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug", "manage_integrations", "manage_runner", "manage_alarms"], isSystem: true, }, ]; @@ -104,7 +104,7 @@ async function ensureDefaultRoles(redis) { ...(existing || {}), name: definition.name, label: existing?.label || definition.label, - permissions: Array.isArray(existing?.permissions) && existing.permissions.length ? existing.permissions : definition.permissions, + permissions: Array.from(new Set([...(Array.isArray(existing?.permissions) ? existing.permissions : []), ...definition.permissions])), isSystem: true, }); await redis.set(key, JSON.stringify(payload)); @@ -286,3 +286,6 @@ export async function savePreferences(redis, userId, preferences) { await redis.set(`preferences:${userId}`, JSON.stringify(payload)); return payload; } + + + diff --git a/bacnet-collector/src/index.js b/bacnet-collector/src/index.js index 43c4344..46092a5 100644 --- a/bacnet-collector/src/index.js +++ b/bacnet-collector/src/index.js @@ -120,16 +120,19 @@ function getBroadcastTargets(routing, body = {}) { if (!isAutoBroadcast(configured)) { targets.push(configured); } - for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) { + const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets); + for (const subnet of configuredSubnets) { const broadcast = cidrBroadcast(subnet); if (broadcast) targets.push(broadcast); } - for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { - const broadcast = broadcastFromAddress(candidate); - if (broadcast) targets.push(broadcast); + if (!configuredSubnets.length) { + for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { + const broadcast = broadcastFromAddress(candidate); + if (broadcast) targets.push(broadcast); + } + targets.push(...getInterfaceBroadcasts()); + targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS)); } - targets.push(...getInterfaceBroadcasts()); - targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS)); if (!targets.length) { targets.push("255.255.255.255"); } @@ -413,6 +416,20 @@ function extractValue(propertyValue) { return propertyValue?.values?.[0]?.value ?? propertyValue?.values?.[0] ?? null; } +function bacnetUnitSymbol(value) { + const unit = Number(value); + const map = { + 3: "A", + 5: "V", + 53: "Pa", + 62: "°C", + 63: "K", + 95: "", + 98: "%", + }; + return Object.prototype.hasOwnProperty.call(map, unit) ? map[unit] : ""; +} + async function scanObjects(client, device, portNumber) { const host = device.address; const deviceObject = { type: 8, instance: Number(device.deviceId) }; @@ -429,6 +446,7 @@ async function scanObjects(client, device, portNumber) { const objectName = await readProperty(client, host, objectId, 77); const description = await readProperty(client, host, objectId, 28); const presentValue = await readProperty(client, host, objectId, 85); + const units = await readProperty(client, host, objectId, 117); const raw = extractValue(presentValue); const name = normalizeText(extractValue(objectName)) || objectLabel(objectId); const alias = normalizeText(extractValue(description)) || name; @@ -438,6 +456,7 @@ async function scanObjects(client, device, portNumber) { host, port: portNumber, deviceId: device.deviceId, + deviceName: device.name || "BACnet Gerät " + device.deviceId, objectType: objectId.type, objectInstance: objectId.instance, propertyId: 85, @@ -447,6 +466,7 @@ async function scanObjects(client, device, portNumber) { description: alias, alias, kind: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "digital" : "analog", + unit: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "bool" : bacnetUnitSymbol(extractValue(units)), currentValue: raw, hasValue: raw !== null, }); @@ -646,15 +666,25 @@ function isLikelyDockerGatewayAddress(address) { function preferRealBacnetAddresses(devices) { const list = Array.isArray(devices) ? devices : []; - const idsWithRealAddress = new Set( - list - .filter((device) => !isLikelyDockerGatewayAddress(device.address || device.host)) - .map((device) => String(device.deviceId)), - ); - return list.filter((device) => { - const address = device.address || device.host; - return !(idsWithRealAddress.has(String(device.deviceId)) && isLikelyDockerGatewayAddress(address)); - }); + const byDeviceId = new Map(); + for (const device of list) { + const id = String(device?.deviceId || ""); + if (!id) continue; + const address = device.address || device.host || ""; + const current = byDeviceId.get(id); + if (!current) { + byDeviceId.set(id, device); + continue; + } + const currentAddress = current.address || current.host || ""; + const deviceIsReal = !isLikelyDockerGatewayAddress(address); + const currentIsReal = !isLikelyDockerGatewayAddress(currentAddress); + const hasMorePoints = Number(device.pointCount || device.points?.length || 0) > Number(current.pointCount || current.points?.length || 0); + if ((deviceIsReal && !currentIsReal) || (deviceIsReal === currentIsReal && hasMorePoints)) { + byDeviceId.set(id, { ...current, ...device, points: device.points?.length ? device.points : current.points }); + } + } + return Array.from(byDeviceId.values()); } async function scanNetwork(body) { const timeout = Number(body.timeout || defaultTimeout); @@ -706,7 +736,7 @@ async function scanNetwork(body) { await waitForSocket(client); await registerForeignDevice(client, routing); sendWhoIs(client, routing, body.deviceId); - await sleep(Math.min(timeout, 1800)); + await sleep(Math.min(timeout, Number(body.initialWaitMs || process.env.BACNET_INITIAL_WAIT_MS || 4500))); const hosts = routing.mode === "broadcast" ? buildUnicastScanHosts(body, routing) : []; const needsUnicastMapping = routing.mode === "broadcast" && hosts.length && (!devices.length || devices.some((device) => !hosts.includes(device.address))); @@ -721,7 +751,7 @@ async function scanNetwork(body) { } } currentUnicastTarget = ""; - await sleep(250); + await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200)); } let visibleDevices = preferRealBacnetAddresses(devices); @@ -864,3 +894,13 @@ server.listen(port, () => { }); + + + + + + + + + + diff --git a/bacnet-collector/src/raw-discover.js b/bacnet-collector/src/raw-discover.js index 98e0585..9d09fe2 100644 --- a/bacnet-collector/src/raw-discover.js +++ b/bacnet-collector/src/raw-discover.js @@ -75,16 +75,19 @@ function getBroadcastTargets(routing, body = {}) { if (!isAutoBroadcast(configured)) { targets.push(configured); } - for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) { + const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets); + for (const subnet of configuredSubnets) { const broadcast = cidrBroadcast(subnet); if (broadcast) targets.push(broadcast); } - for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { - const broadcast = broadcastFromAddress(candidate); - if (broadcast) targets.push(broadcast); + if (!configuredSubnets.length) { + for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { + const broadcast = broadcastFromAddress(candidate); + if (broadcast) targets.push(broadcast); + } + targets.push(...getInterfaceBroadcasts()); + targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS)); } - targets.push(...getInterfaceBroadcasts()); - targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS)); if (!targets.length) { targets.push("255.255.255.255"); } @@ -257,10 +260,10 @@ async function discover() { const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b); for (const broadcastAddress of getBroadcastTargets(routing, body)) { socket.send(broadcastPacket, port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" })); - await sleep(350); + await sleep(Number(body.broadcastDelayMs || process.env.BACNET_BROADCAST_DELAY_MS || 700)); } - const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20); + const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 120); const packet = buildRawWhoIsPacket(body.deviceId, 0x0a); for (const host of hosts) { const before = devices.length; @@ -272,7 +275,7 @@ async function discover() { } } currentUnicastHost = ""; - await sleep(250); + await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200)); } finally { socket.close(); } @@ -289,3 +292,5 @@ discover() process.exitCode = 1; }); + + diff --git a/collector/src/index.js b/collector/src/index.js index dee4e60..3ae26f0 100644 --- a/collector/src/index.js +++ b/collector/src/index.js @@ -278,6 +278,9 @@ function parseModbusCsv(payload) { dataType: get("datatype", "holding-register"), kind: normalizePointKind(get("kind", get("type", "analog"))), enabled: payload.enabled === true, + alarmEnabled: payload.alarmEnabled === true, + alarmCondition: normalizeText(payload.alarmCondition), + alarmGroupId: normalizeText(payload.alarmGroupId), writeMode: normalizeWriteMode(get("writemode", payload.writeMode || "")), condition: get("condition", payload.condition || ""), scale: Number(get("scale", 1)) || 1, @@ -328,6 +331,9 @@ function parseKnxXml(payload) { dataType: dpt || "group-address", kind: dpt.startsWith("1.") ? "digital" : "analog", enabled: payload.enabled === true, + alarmEnabled: payload.alarmEnabled === true, + alarmCondition: normalizeText(payload.alarmCondition), + alarmGroupId: normalizeText(payload.alarmGroupId), writeMode: normalizeWriteMode(payload.writeMode || ""), condition: payload.condition || "", host: payload.host || "", @@ -364,6 +370,9 @@ function createManualDatapoint(store, payload) { unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled !== false, + alarmEnabled: payload.alarmEnabled === true, + alarmCondition: normalizeText(payload.alarmCondition), + alarmGroupId: normalizeText(payload.alarmGroupId), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), condition: normalizeText(payload.condition), dataType: normalizeText(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")), @@ -398,6 +407,9 @@ function createScanDatapoint(source, payload, index) { unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled === true, + alarmEnabled: payload.alarmEnabled === true, + alarmCondition: normalizeText(payload.alarmCondition), + alarmGroupId: normalizeText(payload.alarmGroupId), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), condition: normalizeText(payload.condition), dataType: normalizeText(payload.dataType), @@ -446,7 +458,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) { pointIndex: existing.pointIndex, alias: !existingAlias || existingLooksLikeScanDefault ? scanned.alias : existing.alias, name: scanned.name || existing.name, - unit: normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind), + unit: existing.enabled === true ? normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind) : normalizePointUnit(scanned.unit || existing.unit, scanned.kind || existing.kind), kind: existing.kind || scanned.kind, enabled: existing.enabled === true, writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode), @@ -1066,6 +1078,11 @@ function sendBacnetWhoIs(client, routing, deviceId) { throw new Error("Who-Is konnte nicht ausgel?st werden."); } +function bacnetUnitSymbol(value) { + const unit = Number(value); + const map = { 3: "A", 5: "V", 53: "Pa", 62: "°C", 63: "K", 95: "", 98: "%" }; + return Object.prototype.hasOwnProperty.call(map, unit) ? map[unit] : ""; +} function readBacnetProperty(client, host, objectId, propertyId) { return new Promise((resolve) => { client.readProperty(host, objectId, propertyId, (error, value) => { @@ -1092,6 +1109,7 @@ async function scanBacnetObjects(client, device, portNumber) { const objectName = await readBacnetProperty(client, host, objectId, 77); const description = await readBacnetProperty(client, host, objectId, 28); const presentValue = await readBacnetProperty(client, host, objectId, 85); + const units = await readBacnetProperty(client, host, objectId, 117); const raw = presentValue?.values?.[0]?.value ?? null; const label = bacnetObjectLabel(objectId); const name = normalizeText(objectName?.values?.[0]?.value) || label; @@ -1100,6 +1118,8 @@ async function scanBacnetObjects(client, device, portNumber) { id: host + "-" + objectId.type + "-" + objectId.instance, host, port: portNumber, + deviceId: device.deviceId, + deviceName: device.name || "BACnet Gerät " + device.deviceId, objectType: objectId.type, objectInstance: objectId.instance, propertyId: 85, @@ -1109,6 +1129,7 @@ async function scanBacnetObjects(client, device, portNumber) { description: alias, alias, kind: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "digital" : "analog", + unit: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "bool" : bacnetUnitSymbol(units?.values?.[0]?.value), currentValue: raw, hasValue: raw !== null, }); @@ -1947,9 +1968,11 @@ const server = createServer(async (req, res) => { updatedAt: store.updatedAt, sourceCount: store.sources.length, datapointCount: store.datapoints.length, + alarmDatapointCount: store.datapoints.filter((item) => item.alarmEnabled === true).length, sources: store.sources.map((source) => ({ ...source, datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length, + alarmDatapointCount: store.datapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length, })), latestScans: { opcua: store.scans.opcua[0] || null, @@ -2046,6 +2069,9 @@ const server = createServer(async (req, res) => { store.datapoints[index] = { ...store.datapoints[index], enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled, + alarmEnabled: typeof body.alarmEnabled === "boolean" ? body.alarmEnabled : store.datapoints[index].alarmEnabled === true, + alarmCondition: typeof body.alarmCondition === "string" ? body.alarmCondition.trim() : store.datapoints[index].alarmCondition, + alarmGroupId: typeof body.alarmGroupId === "string" ? body.alarmGroupId.trim() : store.datapoints[index].alarmGroupId, writeMode: typeof body.writeMode === "string" ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode, condition: typeof body.condition === "string" ? body.condition.trim() : store.datapoints[index].condition, pointIndex: body.pointIndex ? normalizePointIndex(body.pointIndex, store.datapoints[index].pointIndex) : store.datapoints[index].pointIndex, @@ -2222,3 +2248,13 @@ server.listen(port, () => { + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index ff6c8eb..5f26070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,9 +89,11 @@ DB_USER: root DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} POLL_TICK_MS: ${POLL_TICK_MS:-1000} - BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://bacnet-collector:18111} + BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://172.17.0.1:18111} ports: - "18110:18110" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - collector_data:/app/data depends_on: @@ -107,14 +109,33 @@ restart: unless-stopped environment: PORT: 18111 - BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-7000} + BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-12000} BACNET_POINT_LIMIT: ${BACNET_POINT_LIMIT:-2000} BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-} - network_mode: "host" - ports: - - "18111:18111" - - "47808:47808/udp" + BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120} + network_mode: "host" + runner: + build: + context: ./runner + container_name: seltd-runner + restart: unless-stopped + environment: + REDIS_URL: redis://redis:6379 + DB_HOST: mariadb + DB_PORT: 3306 + DB_NAME: wago + DB_USER: root + DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} + EXPORT_DIR: /exports + RUNNER_TICK_MS: ${RUNNER_TICK_MS:-30000} + volumes: + - runner_exports:/exports + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy dockhand: image: fnsys/dockhand:latest container_name: seltd-dockhand @@ -147,7 +168,12 @@ volumes: mariadb_data: redis_data: collector_data: + runner_exports: dockhand_data: + + + + diff --git a/rebuild.ps1 b/rebuild.ps1 new file mode 100644 index 0000000..d575021 --- /dev/null +++ b/rebuild.ps1 @@ -0,0 +1,25 @@ +Write-Host "Stopping Docker Compose stack..." -ForegroundColor Yellow +docker compose down + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose down failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Building Docker Compose stack without cache..." -ForegroundColor Yellow +docker compose build --no-cache + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose build failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Starting Docker Compose stack..." -ForegroundColor Yellow +docker compose up -d --force-recreate + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose up failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Docker Compose stack successfully rebuilt and started." -ForegroundColor Green \ No newline at end of file diff --git a/runner/Dockerfile b/runner/Dockerfile new file mode 100644 index 0000000..92231e4 --- /dev/null +++ b/runner/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY src ./src +RUN mkdir -p /exports +CMD ["npm", "start"] diff --git a/runner/package.json b/runner/package.json new file mode 100644 index 0000000..e286053 --- /dev/null +++ b/runner/package.json @@ -0,0 +1,13 @@ +{ + "name": "seltd-runner", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "ioredis": "^5.6.1", + "mysql2": "^3.12.0" + } +} diff --git a/runner/src/index.js b/runner/src/index.js new file mode 100644 index 0000000..ffb6a92 --- /dev/null +++ b/runner/src/index.js @@ -0,0 +1,187 @@ +import dns from "node:dns"; +import { createReadStream, mkdirSync, statSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import https from "node:https"; +import path from "node:path"; +import Redis from "ioredis"; +import mysql from "mysql2/promise"; + +const redis = new Redis(process.env.REDIS_URL || "redis://redis:6379", { maxRetriesPerRequest: 2 }); +const db = mysql.createPool({ + host: process.env.DB_HOST || "mariadb", + port: Number(process.env.DB_PORT || 3306), + database: process.env.DB_NAME || "wago", + user: process.env.DB_USER || "root", + password: process.env.DB_PASSWORD || "SE3112", + charset: "utf8mb4", + waitForConnections: true, + connectionLimit: 3, +}); +const exportDir = process.env.EXPORT_DIR || "/exports"; +const tickMs = Number(process.env.RUNNER_TICK_MS || 30000); + +function safeParse(value, fallback = null) { + try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } +} + +function csvCell(value, delimiter) { + const text = String(value ?? ""); + return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function localDateKey(date = new Date()) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function localTime(date = new Date()) { + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function sanitizeTableName(value) { + const table = String(value || "").toLowerCase(); + if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) throw new Error(`Ungueltige Tabelle: ${value}`); + return table; +} + +async function isNarrowTable(table) { + const [rows] = await db.query( + "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'point_index' LIMIT 1", + [table], + ); + return rows.length > 0; +} + +async function getPointMeta(isp, pointIndex) { + const raw = await redis.get(`meta:isp:${isp}`); + const meta = safeParse(raw, {}); + const point = (meta.points || []).find((item) => Number(item.pointIndex) === Number(pointIndex)); + return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" }; +} + +async function exportSelection(job, selection) { + const delimiter = job.delimiter || ";"; + const hours = Number(job.rangeHours || 24); + const since = new Date(Date.now() - Math.max(1, hours) * 3600000); + const rows = [["Zeit", "Tabelle", "Wertnummer", "Alias", "Wert", "Einheit"]]; + + for (const point of selection.points || []) { + const table = sanitizeTableName(point.isp); + const pointIndex = Number(point.pointIndex); + const meta = await getPointMeta(table, pointIndex); + if (await isNarrowTable(table)) { + const [data] = await db.query( + `SELECT datum, alias, value FROM \`${table}\` WHERE point_index = ? AND datum >= ? ORDER BY datum ASC`, + [pointIndex, since], + ); + for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, item.value, meta.unit]); + } else { + const column = `Wert${pointIndex}`; + const [data] = await db.query( + `SELECT datum, \`${column}\` AS value FROM \`${table}\` WHERE datum >= ? ORDER BY datum ASC`, + [since], + ); + for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, item.value, meta.unit]); + } + } + + mkdirSync(exportDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const fileName = `${selection.name || selection.id || "selection"}-${stamp}.csv`.replace(/[^a-zA-Z0-9_.-]+/g, "_"); + const filePath = path.join(exportDir, fileName); + await writeFile(filePath, rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n"), "utf8"); + return filePath; +} + +async function postMultipartIpv4(url, fields, attachmentPath = "") { + const boundary = `----seltd-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const chunks = []; + const pushField = (name, value) => { + chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value ?? ""}\r\n`)); + }; + Object.entries(fields).forEach(([key, value]) => pushField(key, value)); + if (attachmentPath) { + const fileName = path.basename(attachmentPath); + chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName}"\r\nContent-Type: text/csv\r\n\r\n`)); + chunks.push(createReadStream(attachmentPath)); + chunks.push(Buffer.from("\r\n")); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + + const target = new URL(url); + const length = chunks.reduce((sum, chunk) => sum + (typeof chunk.path === "string" ? statSync(chunk.path).size : chunk.length), 0); + await new Promise((resolve, reject) => { + const req = https.request({ + method: "POST", + hostname: target.hostname, + path: `${target.pathname}${target.search}`, + port: target.port || 443, + lookup: (hostname, options, cb) => dns.lookup(hostname, { ...options, family: 4 }, cb), + timeout: 15000, + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": length }, + }, (res) => { + let body = ""; + res.on("data", (chunk) => { body += chunk.toString("utf8"); }); + res.on("end", () => res.statusCode >= 200 && res.statusCode < 300 ? resolve(body) : reject(new Error(`HTTP ${res.statusCode}: ${body}`))); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("API timeout"))); + const writeNext = (index) => { + if (index >= chunks.length) return req.end(); + const chunk = chunks[index]; + if (typeof chunk.pipe === "function") { + chunk.on("end", () => writeNext(index + 1)); + chunk.on("error", reject); + chunk.pipe(req, { end: false }); + } else { + req.write(chunk); + writeNext(index + 1); + } + }; + writeNext(0); + }); +} + +async function maybeMail(job, filePath) { + if (!job.mailEnabled || !job.mailTo) return; + const settings = safeParse(await redis.get("integration:api"), {}); + if (!settings.mailApiKey) return; + await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", { + api_key: settings.mailApiKey, + address: job.mailTo, + subject: job.mailSubject || "SE-LTD CSV Export", + message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.", + }, filePath); +} + +async function runJob(job) { + const selection = safeParse(await redis.get(`selection:${job.selectionId}`)); + if (!selection) throw new Error(`Selection nicht gefunden: ${job.selectionId}`); + const filePath = await exportSelection(job, selection); + await maybeMail(job, filePath); + await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() })); +} + +async function tick() { + const ids = await redis.smembers("runner:job:ids"); + const now = new Date(); + const time = localTime(now); + const dateKey = localDateKey(now); + for (const id of ids) { + const job = safeParse(await redis.get(`runner:job:${id}`)); + if (!job?.enabled || job.time !== time) continue; + const lastKey = `runner:last:${id}`; + if ((await redis.get(lastKey)) === dateKey) continue; + await redis.set(lastKey, dateKey); + runJob(job).catch(async (error) => { + await redis.set(`runner:last-result:${id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() })); + console.error(error); + }); + } +} + +console.log("SE Local Trenddata runner started"); +setInterval(() => tick().catch((error) => console.error(error)), tickMs); +tick().catch((error) => console.error(error)); diff --git a/update.ps1 b/update.ps1 new file mode 100644 index 0000000..5b0846e --- /dev/null +++ b/update.ps1 @@ -0,0 +1,33 @@ +Write-Host "Pulling latest Repo from Gittea..." -ForegroundColor Blue +git pull + +if ($LASTEXITCODE -ne 0) { + Write-Host "git pull failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Stopping Docker Compose stack..." -ForegroundColor Yellow +docker compose down + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose down failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Building Docker Compose stack without cache..." -ForegroundColor Yellow +docker compose build --no-cache + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose build failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Starting Docker Compose stack..." -ForegroundColor Yellow +docker compose up -d --force-recreate + +if ($LASTEXITCODE -ne 0) { + Write-Host "docker compose up failed." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Docker Compose stack successfully rebuilt and started." -ForegroundColor Green \ No newline at end of file diff --git a/web/src/App.jsx b/web/src/App.jsx index 9d99cf3..2ad77df 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,4 +1,4 @@ - + import { useEffect, useMemo, useState } from "react"; import { api } from "./api.js"; import Gauge from "./components/Gauge.jsx"; @@ -15,8 +15,11 @@ const adminSections = [ { id: "aliases", label: "Aliase" }, { id: "units", label: "Einheiten" }, { id: "sources", label: "Quellen" }, + { id: "alarms", label: "Alarme" }, { id: "users", label: "Benutzer" }, { id: "roles", label: "Rollen" }, + { id: "integrations", label: "API" }, + { id: "runner", label: "Runner" }, { id: "debug", label: "Debug" }, ]; @@ -86,6 +89,9 @@ const PERMISSION_OPTIONS = [ { id: "manage_users", label: "Benutzer verwalten" }, { id: "manage_roles", label: "Rollen verwalten" }, { id: "view_debug", label: "Debug anzeigen" }, + { id: "manage_alarms", label: "Alarme verwalten" }, + { id: "manage_integrations", label: "API-Verbindungen verwalten" }, + { id: "manage_runner", label: "CSV-Runner verwalten" }, ]; const pointColors = [ @@ -724,9 +730,13 @@ export default function App() { const [sourceModalOpen, setSourceModalOpen] = useState(false); const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" }); const [collectorPointFilter, setCollectorPointFilter] = useState(""); + const [alarmPointFilter, setAlarmPointFilter] = useState(""); const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft()); const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null }); const [debugSnapshot, setDebugSnapshot] = useState(null); + const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }); + const [runnerJobs, setRunnerJobs] = useState([]); + const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." }); const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]); const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null; @@ -738,8 +748,11 @@ export default function App() { const canManageUsers = isAuthenticated && hasPermission(user, "manage_users"); const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles"); const canViewDebug = isAuthenticated && hasPermission(user, "view_debug"); - const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug; - const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases"; + const canManageAlarms = isAuthenticated && hasPermission(user, "manage_alarms"); + const canManageIntegrations = isAuthenticated && hasPermission(user, "manage_integrations"); + const canManageRunner = isAuthenticated && hasPermission(user, "manage_runner"); + const canAccessAdmin = canEditAliases || canManageSources || canManageAlarms || canManageUsers || canManageRoles || canManageIntegrations || canManageRunner || canViewDebug; + const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageAlarms ? "alarms" : canManageUsers ? "users" : canManageRoles ? "roles" : canManageIntegrations ? "integrations" : canManageRunner ? "runner" : canViewDebug ? "debug" : "aliases"; const sidebarCollapsed = !sidebarPinned && !sidebarHover; const currentView = views.find((view) => view.id === activeView) || views[0]; const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list"; @@ -765,6 +778,8 @@ export default function App() { const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]); const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]); const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]); + const alarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled === true), [collectorSourcePoints]); + const availableAlarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled !== true), [collectorSourcePoints]); const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol; const upsertIspEntry = (entry) => { @@ -841,6 +856,18 @@ export default function App() { item.currentValue, ])), [availableCollectorPoints, collectorPointFilter] + ); const filteredAvailableAlarmPoints = useMemo( + () => availableAlarmCollectorPoints.filter((item) => matchesSearch(alarmPointFilter, [ + item.alias, + item.name, + item.address, + item.nodeId, + item.dataType, + item.protocol, + item.pointIndex, + item.currentValue, + ])), + [alarmPointFilter, availableAlarmCollectorPoints] ); const resetFlash = () => { @@ -1082,6 +1109,27 @@ export default function App() { .then((result) => setUnits(result.units || DEFAULT_UNIT_OPTIONS)) .catch((error) => setBootError(error.message)); }, [canEditAliases, canManageSources, token]); + useEffect(() => { + if (!token || !canManageIntegrations) { + setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }); + return; + } + + api.getIntegrations(token) + .then((result) => setIntegrationSettings(result.settings || { mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" })) + .catch((error) => setBootError(error.message)); + }, [canManageIntegrations, token]); + + useEffect(() => { + if (!token || !canManageRunner) { + setRunnerJobs([]); + return; + } + + api.getRunnerJobs(token) + .then((result) => setRunnerJobs(result.jobs || [])) + .catch((error) => setBootError(error.message)); + }, [canManageRunner, token]); useEffect(() => { const refreshIsps = () => { @@ -1105,7 +1153,7 @@ export default function App() { }, [token]); useEffect(() => { - if (!token || !(canEditAliases || canManageSources || canViewDebug)) { + if (!token || !(canEditAliases || canManageSources || canManageAlarms || canViewDebug)) { setCollectorCapabilities(null); setCollectorSources([]); setCollectorDatapoints([]); @@ -1117,7 +1165,7 @@ export default function App() { .catch((error) => { setCollectorCapabilities(null); setCollectorError(error.message); }); refreshCollectorData().catch((error) => setCollectorError(error.message)); - }, [canEditAliases, canManageSources, canViewDebug, token]); + }, [canEditAliases, canManageSources, canManageAlarms, canViewDebug, token]); useEffect(() => { if (!currentSource) { @@ -1668,6 +1716,13 @@ export default function App() { 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(), + options: { + ...(currentSource?.options || {}), + bacnetMode: sourceDraft.bacnetMode, + bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress, + bacnetScanSubnets: sourceDraft.bacnetScanSubnets, + bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl, + }, }; const saved = payload.id @@ -1829,6 +1884,7 @@ export default function App() { const runOpcuaScan = async () => { const activeSourceId = selectedSourceId || sourceDraft.id; + const activeSource = collectorSources.find((source) => source.id === activeSourceId) || null; if (!activeSourceId) { setBootError("Bitte zuerst eine Quelle auswählen oder anlegen."); return; @@ -1837,8 +1893,8 @@ export default function App() { resetFlash(); try { const result = await api.scanOpcua({ - host: sourceDraft.host, - port: sourceDraft.port || "4840", + host: activeSource?.host || sourceDraft.host, + port: activeSource?.port || sourceDraft.port || "4840", sourceId: activeSourceId, }); setScanResults((current) => ({ ...current, opcua: result })); @@ -1895,6 +1951,10 @@ export default function App() { bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto", clientHost: typeof window !== "undefined" ? window.location.hostname : "", bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120", + timeout: options.timeout || 12000, + initialWaitMs: options.initialWaitMs || 6000, + finalWaitMs: options.finalWaitMs || 1800, + unicastDelayMs: options.unicastDelayMs || 140, }); setScanResults((current) => ({ ...current, bacnet: result })); await refreshCollectorData(); @@ -1976,6 +2036,79 @@ export default function App() { } }; + const saveIntegrationSettings = async () => { + resetFlash(); + try { + const result = await api.saveIntegrations(token, integrationSettings); + setIntegrationSettings(result.settings || integrationSettings); + setSuccessMessage("API-Verbindungen gespeichert."); + } catch (error) { + setBootError(error.message); + } + }; + + const resetRunnerDraft = () => { + setRunnerDraft({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." }); + }; + + const editRunnerJob = (job) => { + setRunnerDraft({ + id: job.id, + name: job.name || "", + selectionId: job.selectionId || "", + enabled: job.enabled !== false, + time: job.time || "06:00", + rangeHours: String(job.rangeHours || 24), + delimiter: job.delimiter || ";", + mailEnabled: job.mailEnabled === true, + mailTo: job.mailTo || "", + mailSubject: job.mailSubject || "Trenddaten", + mailMessage: job.mailMessage || "Automatischer Export aus SE Local Trenddata.", + }); + }; + + const saveRunnerJob = async () => { + resetFlash(); + try { + if (!runnerDraft.selectionId) { + setBootError("Bitte eine gespeicherte Auswahl für den Export wählen."); + return; + } + const payload = { + ...runnerDraft, + rangeHours: Number(runnerDraft.rangeHours) || 24, + }; + if (runnerDraft.id) { + await api.updateRunnerJob(token, runnerDraft.id, payload); + } else { + await api.createRunnerJob(token, payload); + } + const refreshed = await api.getRunnerJobs(token); + setRunnerJobs(refreshed.jobs || []); + resetRunnerDraft(); + setSuccessMessage("Runner-Job gespeichert."); + } catch (error) { + setBootError(error.message); + } + }; + + const deleteRunnerJob = async (job) => { + resetFlash(); + if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) { + return; + } + try { + await api.deleteRunnerJob(token, job.id); + const refreshed = await api.getRunnerJobs(token); + setRunnerJobs(refreshed.jobs || []); + if (runnerDraft.id === job.id) { + resetRunnerDraft(); + } + setSuccessMessage("Runner-Job gelöscht."); + } catch (error) { + setBootError(error.message); + } + }; const updateUser = async (targetUser, changes) => { resetFlash(); try { @@ -2448,12 +2581,21 @@ export default function App() { if (section.id === "sources") { return canManageSources || canEditAliases; } + if (section.id === "alarms") { + return canManageAlarms; + } if (section.id === "users") { return canManageUsers; } if (section.id === "roles") { return canManageRoles; } + if (section.id === "integrations") { + return canManageIntegrations; + } + if (section.id === "runner") { + return canManageRunner; + } if (section.id === "debug") { return canViewDebug; } @@ -2679,16 +2821,7 @@ export default function App() {
- {sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP Subnetze" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"} - {sourceToolProtocol === "bacnet-ip" ? ( - <> - - Wird gespeichert und vom BACnet-Scan der Quelle für Device- und Objektsuche verwendet. - - ) : null} + {sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"} {sourceToolProtocol === "opc-ua" ? <>
{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."} : null} {sourceToolProtocol === "modbus-tcp" ? Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart. : null} {sourceToolProtocol === "knx-ip" ? KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links. : null} @@ -2821,6 +2954,12 @@ export default function App() { setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" /> ) : null} + {sourceDraft.protocol === "bacnet-ip" ? ( + + ) : null} {sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? (
) : null} + {adminSection === "alarms" && canManageAlarms ? ( +
+
+
+

Alarme

+

Datenpunkte aus vorhandenen Quellen gezielt in die Alarmüberwachung übernehmen.

+
+
+ {currentSource?.protocol === "opc-ua" ? : null} + {currentSource?.protocol === "bacnet-ip" ? : null} +
+
+ +
+
+
+
+

Quellen

+

Quelle auswählen, danach links Punkte ins Alarming schieben.

+
+
+
+ {collectorSources.map((source) => { + const isActive = source.id === selectedSourceId; + const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length; + const alarmCount = collectorDatapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length; + return ( + + ); + })} + {!collectorSources.length ?

Noch keine Quelle angelegt.

: null} +
+
+ +
+
+
+

Aktive Quelle

+

{currentSource ? `${normalizeDisplayText(currentSource.name)} · ${currentSource.protocol}` : "Bitte links eine Quelle auswählen."}

+
+
+ setAlarmPointFilter(event.target.value)} + /> + Alarming ist unabhängig vom Trendlogging. Ein Punkt kann alarmiert werden, ohne in die Trendtabelle geschrieben zu werden. +
+
+ +
+
+
+
+

Verfügbare Datenpunkte

+

Gescannt, importiert oder manuell angelegt. Nur per Klick landet ein Punkt im Alarming.

+
+
+
+ {filteredAvailableAlarmPoints.length ? filteredAvailableAlarmPoints.slice(0, 2000).map((item) => ( +
+
+ {normalizeDisplayText(item.alias || item.name)} + {`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`} + {item.currentValue === null || item.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(item.currentValue)} +
+ {item.kind === "digital" ? "digital" : normalizeDisplayUnit(item.unit || "")} +
+ + +
+
+ )) :

{currentSource ? "Keine verfügbaren Punkte für diese Quelle. Starte ggf. einen Scan oder Import." : "Bitte zuerst eine Quelle auswählen."}

} +
+
+ +
+
+
+

Wird alarmiert

+

Diese Punkte sind für die spätere Alarmüberwachung markiert.

+
+
+
+ {alarmCollectorPoints.length ? alarmCollectorPoints.slice(0, 2000).map((item) => ( +
+
+ {normalizeDisplayText(item.alias || item.name)} + {`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`} + {item.alarmCondition ? normalizeDisplayText(item.alarmCondition) : "noch keine Bedingung gesetzt"} +
+ {formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""} +
+ + +
+
+ )) :

Für diese Quelle ist noch kein Datenpunkt im Alarming.

} +
+
+
+
+ ) : null} {adminSection === "users" && canManageUsers ? (
@@ -3107,6 +3364,125 @@ export default function App() {
) : null} + {adminSection === "integrations" && canManageIntegrations ? ( +
+
+
+

API-Verbindungen

+

Keys für Mail, WhatsApp und Telefon. Requests werden serverseitig per IPv4 ausgeführt.

+
+
+
+ + + +
+ +
+ ) : null} + + {adminSection === "runner" && canManageRunner ? ( +
+
+
+

CSV-Runner

+

Gespeicherte Auswahlen automatisch als CSV ins Dateisystem exportieren und optional per Mail verschicken.

+
+ +
+ +
+ + + + + + + + + + +
+
+ {runnerDraft.id ? : null} + +
+ +
+ {runnerJobs.map((job) => { + const selection = selections.find((item) => item.id === job.selectionId); + return ( +
+
+ {normalizeDisplayText(job.name, "Runner-Job")} + {`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} · ${job.enabled === false ? "pausiert" : "aktiv"}`} + {job.lastRunAt ? `letzter Lauf ${formatTimestampLabel(job.lastRunAt)}` : "noch nicht gelaufen"} +
+
+ + +
+
+ ); + })} + {!runnerJobs.length ?

Noch kein automatischer Export angelegt.

: null} +
+
+ ) : null} {adminSection === "debug" && canViewDebug ? (
@@ -3233,6 +3609,21 @@ export default function App() { + + + + + + + + + + + + + + + diff --git a/web/src/api.js b/web/src/api.js index b4943f3..13f939f 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -80,6 +80,12 @@ export const api = { loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }), getPreferences: (token) => apiRequest("/preferences", { token }), savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }), + getIntegrations: (token) => apiRequest("/integrations", { token }), + saveIntegrations: (token, body) => apiRequest("/integrations", { method: "PUT", token, body }), + getRunnerJobs: (token) => apiRequest("/runner/jobs", { token }), + createRunnerJob: (token, body) => apiRequest("/runner/jobs", { method: "POST", token, body }), + updateRunnerJob: (token, id, body) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "PATCH", token, body }), + deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }), getCollectorCapabilities: () => collectorRequest("/capabilities"), getCollectorSources: () => collectorRequest("/sources"), getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"), @@ -95,3 +101,4 @@ export const api = { scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }), scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }), }; + diff --git a/web/src/styles.css b/web/src/styles.css index d6a3f34..f02a890 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1253,3 +1253,155 @@ span { width: 100%; } } + +/* Compact source and datapoint cards for dense Full-HD trend/admin views. */ +.source-tile { + min-height: 0; + padding: 0.36rem 0.48rem; + align-items: center; +} + +.source-tile-copy { + gap: 0.08rem; + line-height: 1.18; +} + +.source-tile-copy strong, +.source-tile-copy span, +.source-tile-copy small, +.datapoint-main strong, +.datapoint-main span, +.datapoint-main small { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.source-tile-actions { + gap: 0.18rem; +} + +.source-tile-actions .compact-button { + min-height: 1.55rem; + padding: 0.24rem 0.48rem; +} + +.scan-result-list, +.datapoint-table { + display: grid; + gap: 0.22rem; +} + +.datapoint-row { + align-items: center; + gap: 0.42rem; + min-height: 0; + padding: 0.34rem 0.46rem; +} + +.datapoint-main { + min-width: 0; + line-height: 1.2; +} + +.datapoint-main strong { + font-size: 0.74rem; +} + +.datapoint-main span, +.datapoint-main small, +.datapoint-value { + font-size: 0.68rem; +} + +.datapoint-value { + white-space: nowrap; +} + +.source-overview-panel .selection-list { + gap: 0.28rem; +} + +/* Stable dense datapoint lists: compact, but never overlapping. */ +.source-split .scan-result-list, +.source-split .datapoint-table { + gap: 0.3rem; + max-height: min(58vh, 620px); + min-height: 300px; + overflow-y: auto; + padding-right: 0.18rem; +} + +.source-split .datapoint-row { + grid-template-columns: minmax(0, 1fr) 3.2rem minmax(10.5rem, max-content); + min-height: 42px; + padding: 0.4rem 0.5rem; + gap: 0.55rem; + align-items: center; + border-radius: 9px; +} + +.source-split .datapoint-main { + min-width: 0; + line-height: 1.18; + gap: 0.08rem; +} + +.source-split .datapoint-main strong, +.source-split .datapoint-main span, +.source-split .datapoint-main small { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-split .datapoint-main strong { + font-size: 0.76rem; + line-height: 1.18; +} + +.source-split .datapoint-main span, +.source-split .datapoint-main small { + font-size: 0.68rem; + line-height: 1.15; +} + +.source-split .datapoint-value { + justify-self: end; + min-width: 2.2rem; + font-size: 0.68rem; + text-align: right; +} + +.source-split .datapoint-row > .button-row { + display: flex; + flex-wrap: nowrap; + justify-content: flex-end; + gap: 0.25rem; + min-width: 0; +} + +.source-split .datapoint-row > .button-row .compact-button { + min-height: 24px; + padding: 0.24rem 0.46rem; + font-size: 0.68rem; + white-space: nowrap; +} + +.source-split .datapoint-row.inactive { + opacity: 0.86; +} + +@media (max-width: 1180px) { + .source-split .datapoint-row { + grid-template-columns: minmax(0, 1fr) 2.6rem; + } + + .source-split .datapoint-row > .button-row { + grid-column: 1 / -1; + justify-content: flex-start; + } +}