Compare commits

...
9 Commits
Author SHA1 Message Date
jhartworks 2f5f86452f feat: add function to retrieve linked names for Ipsymcon variables 2026-09-18 10:05:19 +02:00
jhartworks 4d8b07fc68 Refactor code structure for improved readability and maintainability 2026-09-16 12:41:24 +02:00
jhartworks bfce688f6a chore: remove sensitive environment configuration file 2026-09-16 12:41:12 +02:00
jhartworks 986a43d5a5 API added 2026-07-24 13:43:47 +02:00
jhartworks 19d27f9de9 Add update script, enhance admin features, and improve styles
- Created a PowerShell script (update.ps1) for automating the process of pulling the latest repository changes, stopping, building, and starting the Docker Compose stack.
- Updated App.jsx to include new admin sections for alarms and integrations, added state management for runner jobs, and enhanced permission checks.
- Extended the API module (api.js) with new endpoints for managing integrations and runner jobs.
- Improved CSS styles for a more compact and responsive layout in the admin interface, focusing on source and datapoint cards.
2026-07-09 15:24:58 +02:00
jhartworks f247d2827d feat(bacnet-collector): add initial implementation of BACnet collector with scanning and reading capabilities
- Created package.json for the bacnet-collector project with necessary dependencies.
- Implemented main server logic in src/index.js to handle BACnet device scanning and reading.
- Added raw discovery functionality in src/raw-discover.js to facilitate device detection over the network.
- Introduced utility functions for handling IP addresses, broadcasting, and BACnet packet construction.
- Implemented JSON response handling for health checks and capabilities endpoints.
2026-07-09 11:13:26 +02:00
jhartworks 7a1fe401f2 visual fixes 2026-07-07 10:37:26 +02:00
jhartworks 2c055fcb63 Merge branch 'main' of https://git.portal.se-cloud.de/Hannes/SE-LTD 2026-07-01 13:58:53 +02:00
jhartworks cceb12a9bd Update admin password in .env and adjust userlevel default in SQL initialization script 2026-07-01 13:55:30 +02:00
36 changed files with 6713 additions and 401 deletions
-9
View File
@@ -1,9 +0,0 @@
JWT_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=System Admin
MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=*
VITE_API_PORT=18080
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env and replace all placeholders before production use.
JWT_SECRET=replace-with-a-long-random-secret
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-strong-admin-password
ADMIN_NAME=System Admin
# Existing controllers may require these legacy credentials.
MARIADB_ROOT_PASSWORD=SE3112
MARIADB_PASSWORD=3112
DB_PASSWORD_FALLBACK=root
REDIS_PASSWORD=replace-with-a-long-random-redis-password
CORS_ORIGIN=http://localhost:5173
VITE_API_PORT=18080
+2 -1
View File
@@ -1,7 +1,8 @@
node_modules node_modules
dist dist
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
.scratch/runtime .scratch/runtime
data/ data/
web/src web/src
.env
+40 -158
View File
@@ -168,8 +168,29 @@ docker ps -a
### 3. Aufrufen ### 3. Aufrufen
Nun stehen dir mehrere Tools zur Verfügung: Nun stehen dir mehrere Tools zur Verfügung:
<<<<<<< HEAD
Frontend | `http://localhost:5173`
--> Das ist die normale Weboberfläche zum anschauen, editieren und anpassen von Trends.
API Healthcheck | `http://localhost:18080/api/health`
--> hier kannst du schauen ob die API läuft
Collector Healthcheck | `http://localhost:18110/health`
--> hier kannst du schauen ob der Collector läuft, was nur wichtig ist, wenn du Datenpunkte per OPC,KNX, Modbus oder Bacnet angebunden hast.
Dockhand | `http://localhost:3000`
--> Dockhand ist Container verwaltungstool, dass es dir ermöglicht, auch grafisch den Zustand deiner Container und deines Stacks zu sehen.
Der Vorteil hier, im Gegensatz zur normalen Docker Desktopoberfläche: Du kannst es auf jedem beliebigen anderen Rechner, der auf dein Netzwerk zugriff hat, öffnen.
cAdvisor | `http://localhost:9090`
--> Cadvisor stellt alle Containerdaten für Monitoring und Analyticprogramme bereit.
So kann bei einer größeren Anlage, das Dockersystem mitr Prometheus oder ähnlichem überwacht werden.
Cadvisor stellt di Daten per html bereit, kannst du dir gern anschauen, ist aber ziemlich unübersichtlich.
=======
| Frontend | `http://localhost:5173` | | Frontend | `http://localhost:5173` |
--> Das ist die normale Weboberfläche zum anschauen, editieren und anpassen von Trends. --> Das ist die normale Weboberfläche zum anschauen, editieren und anpassen von Trends.
>>>>>>> c06fe1fd4df46b203002412d6d8d0362c979166c
| API Healthcheck | `http://localhost:18080/api/health` | | API Healthcheck | `http://localhost:18080/api/health` |
--> hier kannst du schauen ob die API läuft --> hier kannst du schauen ob die API läuft
@@ -189,50 +210,11 @@ Nun stehen dir mehrere Tools zur Verfügung:
```text ```text
Username: admin Username: admin
Passwort: ChangeMe123! Passwort: SE3112
``` ```
Das Passwort sollte nach dem ersten Login geaendert werden.
--- ---
## Lokale Entwicklung
### Frontend lokal starten
```powershell
cd C:\Projects\SeLTD\web
npm install
npm run dev
```
Das Frontend laeuft dann auf:
```text
http://localhost:5173
```
### API lokal starten
```powershell
cd C:\Projects\SeLTD\api
npm install
npm run dev
```
Die API liest ihre Konfiguration aus `.env`. Wenn du die API lokal ohne Docker laufen laesst, muss `DB_HOST` auf den erreichbaren MariaDB-Host zeigen.
### Collector lokal starten
```powershell
cd C:\Projects\SeLTD\collector
npm install
npm start
```
Der Collector braucht Zugriff auf MariaDB und schreibt seine lokale Konfiguration in `collector/data/sources.json`.
---
## Konfiguration ## Konfiguration
@@ -247,25 +229,12 @@ Die wichtigsten Variablen liegen in `.env`.
| `ADMIN_NAME` | `System Admin` | Anzeigename des Admins | | `ADMIN_NAME` | `System Admin` | Anzeigename des Admins |
| `MARIADB_ROOT_PASSWORD` | `SE3112` | MariaDB-Root-Passwort | | `MARIADB_ROOT_PASSWORD` | `SE3112` | MariaDB-Root-Passwort |
| `DB_PASSWORD_FALLBACK` | `root` | Fallback fuer alte Entwicklungsvolumes | | `DB_PASSWORD_FALLBACK` | `root` | Fallback fuer alte Entwicklungsvolumes |
| `CORS_ORIGIN` | `http://localhost:5173` | Erlaubter Browser-Ursprung fuer API-Aufrufe | | `CORS_ORIGIN` | `*` | Erlaubter Browser-Ursprung fuer API-Aufrufe |
| `VITE_API_PORT` | `18080` | API-Port, den das Frontend im Browser nutzt | | `VITE_API_PORT` | `18080` | API-Port, den das Frontend im Browser nutzt |
| `POLL_TICK_MS` | `1000` | Collector-Tick fuer Polling-Pruefung | | `POLL_TICK_MS` | `1000` | Collector-Tick fuer Polling-Pruefung |
### CORS
Wenn das Frontend lokal auf `http://localhost:5173` laeuft, muss in der API stehen:
```env
CORS_ORIGIN=http://localhost:5173
```
Bei mehreren erlaubten Origins kann eine kommaseparierte Liste genutzt werden:
```env
CORS_ORIGIN=http://localhost:5173,https://trend.example.local
```
---
## Login, Benutzer und Rollen ## Login, Benutzer und Rollen
@@ -495,32 +464,7 @@ Redis sollte deshalb ebenfalls gebackupt werden, nicht nur MariaDB.
--- ---
## WAGO- oder SD-Karten-Betrieb
Fuer Systeme, bei denen persistente Daten auf SD-Karte liegen sollen, gibt es ein Override:
```powershell
docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build
```
Dabei werden grosse Daten nach `/media/sd/seltd/...` gelegt:
```text
/media/sd/seltd/mariadb
/media/sd/seltd/redis
/media/sd/seltd/collector
/media/sd/seltd/dockhand
```
Vorher sicherstellen:
```bash
mkdir -p /media/sd/seltd/mariadb /media/sd/seltd/redis /media/sd/seltd/collector /media/sd/seltd/dockhand
```
---
## Backup und Restore
### MariaDB Backup ### MariaDB Backup
@@ -550,22 +494,6 @@ docker run --rm -v seltd_collector_data:/data -v ${PWD}:/backup alpine tar czf /
--- ---
## Deployment-Hinweise
### Nach Code-Aenderungen
Wenn Frontend oder API geaendert wurden:
```powershell
docker compose build --no-cache web api collector
docker compose up -d
```
Wenn nur `.env` geaendert wurde:
```powershell
docker compose up -d
```
### Browsercache ### Browsercache
@@ -575,7 +503,6 @@ Bei alten JavaScript-Fehlern nach einem Fix:
Strg + F5 Strg + F5
``` ```
oder Vite/Docker neu bauen.
### Nginx Proxy Manager ### Nginx Proxy Manager
@@ -614,7 +541,7 @@ curl http://localhost:18080/api/health
Wenn Frontend lokal auf `localhost:5173` laeuft: Wenn Frontend lokal auf `localhost:5173` laeuft:
```env ```env
CORS_ORIGIN=http://localhost:5173 CORS_ORIGIN=*
``` ```
Danach API neu starten: Danach API neu starten:
@@ -635,45 +562,6 @@ docker compose up -d
Danach im Browser `Strg + F5`. Danach im Browser `Strg + F5`.
### Fehler: `ReferenceError: editingCollectorPointId is not defined`
In `web/src/App.jsx` muss folgender State vorhanden sein:
```jsx
const [editingCollectorPointId, setEditingCollectorPointId] = useState("");
```
Wenn die Zeile vorhanden ist, der Fehler aber weiterhin erscheint, laeuft noch altes Build-/Container-Material.
### Fehler: `SyntaxError: missing ) after argument list` in `isp.js`
Bei SQL-Template-Strings muessen Backticks im String escaped werden:
```js
missing.push(`ADD COLUMN \`${columnName}\` TEXT NULL`);
```
### Fehler: `Rollup failed to resolve import "jspdf"`
Abhaengigkeiten im Frontend installieren:
```powershell
cd web
npm install jspdf xlsx recharts
```
Bei Docker danach neu bauen.
### Source-Map-Warnungen im Browser
Warnungen wie diese sind in der Regel nicht kritisch:
```text
Source map error: JSON.parse: unexpected character...
```
Sie kommen oft aus Devtools/React-Devtools oder externen Source-Maps und koennen ignoriert werden, solange keine roten Laufzeitfehler auftreten.
### MariaDB Login oder DB-Verbindung fehlgeschlagen ### MariaDB Login oder DB-Verbindung fehlgeschlagen
Logs ansehen: Logs ansehen:
@@ -766,7 +654,6 @@ Vor produktiver Nutzung:
- [ ] `JWT_SECRET` auf langen zufaelligen Wert setzen - [ ] `JWT_SECRET` auf langen zufaelligen Wert setzen
- [ ] Admin-Passwort aendern - [ ] Admin-Passwort aendern
- [ ] MariaDB-Root-Passwort aendern - [ ] MariaDB-Root-Passwort aendern
- [ ] `CORS_ORIGIN` nicht auf `*` lassen, sondern feste Domain setzen
- [ ] MariaDB-Port `3306` nicht ungeschuetzt ins Internet veroeffentlichen - [ ] MariaDB-Port `3306` nicht ungeschuetzt ins Internet veroeffentlichen
- [ ] Redis-Port `6379` nicht ungeschuetzt ins Internet veroeffentlichen - [ ] Redis-Port `6379` nicht ungeschuetzt ins Internet veroeffentlichen
- [ ] Dockhand absichern oder nur intern bereitstellen - [ ] Dockhand absichern oder nur intern bereitstellen
@@ -785,30 +672,25 @@ Vor produktiver Nutzung:
--- ---
## Kurzfassung fuer Betrieb ---
## Konfiguration und Sicherheit
Vor dem ersten Start `.env.example` nach `.env` kopieren und mindestens `JWT_SECRET`, `ADMIN_PASSWORD` und `REDIS_PASSWORD` durch eigene lange Zufallswerte ersetzen. Die lokale `.env` wird nicht versioniert.
Redis ist ausschließlich innerhalb des Docker-Netzwerks erreichbar und verlangt ein Passwort. Für Diagnosezwecke:
```powershell ```powershell
cd C:\Projects\SeLTD docker compose exec redis redis-cli -a $env:REDIS_PASSWORD ping
copy .env.example .env
notepad .env
docker compose up -d --build
``` ```
Dann aufrufen: Alle Dienste können vor einem Deployment statisch geprüft werden:
```text
http://localhost:5173
```
Login:
```text
admin / ChangeMe123!
```
Bei Code-Aenderungen:
```powershell ```powershell
docker compose build --no-cache web api collector npm --prefix api run check
docker compose up -d npm --prefix collector run check
``` npm --prefix symcon-collector run check
npm --prefix bacnet-collector run check
npm --prefix runner run check
npm --prefix web run check
```
+2 -2
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY src ./src COPY src ./src
+3 -2
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"start": "node src/index.js" "start": "node src/index.js",
"check": "node --check src/index.js && node --check src/db.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -16,4 +17,4 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mysql2": "^3.12.0" "mysql2": "^3.12.0"
} }
} }
+29 -10
View File
@@ -1,4 +1,4 @@
import mysql from "mysql2/promise"; import mysql from "mysql2/promise";
import { config } from "./config.js"; import { config } from "./config.js";
function createPool(password) { function createPool(password) {
@@ -24,7 +24,15 @@ function isAccessDenied(error) {
return error?.code === "ER_ACCESS_DENIED_ERROR"; return error?.code === "ER_ACCESS_DENIED_ERROR";
} }
async function query(sql, values) { function isTransientConnectionError(error) {
return ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "PROTOCOL_CONNECTION_LOST"].includes(error?.code);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function queryWithCredentials(sql, values) {
try { try {
return await activePool.query(sql, values); return await activePool.query(sql, values);
} catch (error) { } catch (error) {
@@ -33,21 +41,18 @@ async function query(sql, values) {
} }
for (const password of candidatePasswords) { for (const password of candidatePasswords) {
if (password === activePassword) { if (password === activePassword) continue;
continue;
}
const fallbackPool = createPool(password); const fallbackPool = createPool(password);
try { try {
const result = await fallbackPool.query(sql, values); const result = await fallbackPool.query(sql, values);
const previousPool = activePool;
activePool = fallbackPool; activePool = fallbackPool;
activePassword = password; activePassword = password;
previousPool.end().catch(() => undefined);
return result; return result;
} catch (fallbackError) { } catch (fallbackError) {
await fallbackPool.end().catch(() => undefined); await fallbackPool.end().catch(() => undefined);
if (!isAccessDenied(fallbackError)) { if (!isAccessDenied(fallbackError)) throw fallbackError;
throw fallbackError;
}
} }
} }
@@ -55,4 +60,18 @@ async function query(sql, values) {
} }
} }
export const pool = { query }; async function query(sql, values) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
return await queryWithCredentials(sql, values);
} catch (error) {
lastError = error;
if (!isTransientConnectionError(error) || attempt === 4) throw error;
await delay(250 * 2 ** attempt);
}
}
throw lastError;
}
export const pool = { query };
+159 -1
View File
@@ -1,4 +1,4 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js"; import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js";
@@ -132,6 +132,9 @@ const PERMISSION_CATALOG = [
{ id: "manage_users", label: "Benutzer verwalten" }, { id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" }, { id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Treiber-Debug anzeigen" }, { 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) { async function getUserPermissions(roleName) {
@@ -198,6 +201,97 @@ async function ensureRolesSchema() {
} catch {} } catch {}
} }
function updateSecret(body, existing, field, clearField) {
if (body[clearField] === true) return "";
const replacement = String(body[field] ?? "").trim();
return replacement || String(existing[field] || "").trim();
}
function sanitizeApiSettings(body = {}, existing = {}) {
return {
mailUrl: String(body.mailUrl ?? existing.mailUrl ?? "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
mailApiKey: updateSecret(body, existing, "mailApiKey", "clearMailApiKey"),
whatsappUrl: String(body.whatsappUrl ?? existing.whatsappUrl ?? "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
whatsappApiKey: updateSecret(body, existing, "whatsappApiKey", "clearWhatsappApiKey"),
voipUrl: String(body.voipUrl ?? existing.voipUrl ?? "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
voipApiKey: updateSecret(body, existing, "voipApiKey", "clearVoipApiKey"),
updatedAt: new Date().toISOString(),
};
}
function publicApiSettings(settings = {}) {
return {
mailUrl: settings.mailUrl || "https://mailapi.se-inno.de",
whatsappUrl: settings.whatsappUrl || "https://whatsappapi.se-inno.de",
voipUrl: settings.voipUrl || "https://voipapi.se-inno.de",
updatedAt: settings.updatedAt || null,
};
}
function normalizeRunnerTime(value) {
const raw = String(value ?? "").trim().replace(/\s*uhr$/i, "").replace(".", ":");
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
if (!match) {
throw new Error("Uhrzeit bitte im deutschen Format HH:MM Uhr angeben.");
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) {
throw new Error("Uhrzeit ist ungültig.");
}
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
function sanitizeRunnerJob(body = {}, existing = {}) {
const time = normalizeRunnerTime(body.time ?? existing.time ?? "06:00");
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 listRunnerQueue() {
const entries = await redis.lrange("runner:run-now", 0, 99);
const jobs = await listRunnerJobs();
const jobsById = new Map(jobs.map((job) => [job.id, job]));
return entries.map((raw, index) => {
try {
const queued = JSON.parse(raw);
const job = jobsById.get(queued.jobId);
return {
position: index + 1,
jobId: queued.jobId || "",
jobName: job?.name || queued.jobId || "Unbekannter Job",
requestedAt: queued.requestedAt || "",
requestedBy: queued.requestedBy || "",
};
} catch {
return { position: index + 1, jobId: "", jobName: "Ungültiger Auftrag", requestedAt: "", requestedBy: "" };
}
});
}
async function resolveSelectionPoints(body, user) { async function resolveSelectionPoints(body, user) {
if (body.selectionId) { if (body.selectionId) {
const selection = await getSelection(redis, body.selectionId); const selection = await getSelection(redis, body.selectionId);
@@ -752,6 +846,65 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res)
res.json({ widgets: results }); 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({
settings: publicApiSettings(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 current = JSON.parse(await redis.get("integration:api") || "{}");
const payload = sanitizeApiSettings(req.body || {}, current);
await redis.set("integration:api", JSON.stringify(payload));
res.json({
settings: publicApiSettings(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(), queue: await listRunnerQueue() });
}));
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.post("/api/runner/jobs/:jobId/test", 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." });
await redis.rpush("runner:run-now", JSON.stringify({ jobId: id, requestedAt: new Date().toISOString(), requestedBy: req.user.sub }));
res.status(202).json({ queued: true, jobId: id });
}));
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) => { app.get("/api/preferences", authenticateToken, wrap(async (req, res) => {
const preferences = await getPreferences(redis, req.user.sub); const preferences = await getPreferences(redis, req.user.sub);
res.json(preferences); res.json(preferences);
@@ -781,3 +934,8 @@ app.listen(config.port, () => {
+9 -3
View File
@@ -13,13 +13,13 @@ export const DEFAULT_ROLE_DEFINITIONS = [
{ {
name: "technician", name: "technician",
label: "Techniker", 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, isSystem: true,
}, },
{ {
name: "admin", name: "admin",
label: "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, isSystem: true,
}, },
]; ];
@@ -31,6 +31,7 @@ export const DEFAULT_UNIT_DEFINITIONS = [
{ symbol: "V", label: "Volt", isSystem: true }, { symbol: "V", label: "Volt", isSystem: true },
{ symbol: "A", label: "Ampere", isSystem: true }, { symbol: "A", label: "Ampere", isSystem: true },
{ symbol: "Pa", label: "Pascal", isSystem: true }, { symbol: "Pa", label: "Pascal", isSystem: true },
{ symbol: "bool", label: "Digital / Bool", isSystem: true },
]; ];
function safeParse(jsonValue) { function safeParse(jsonValue) {
@@ -103,7 +104,7 @@ async function ensureDefaultRoles(redis) {
...(existing || {}), ...(existing || {}),
name: definition.name, name: definition.name,
label: existing?.label || definition.label, 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, isSystem: true,
}); });
await redis.set(key, JSON.stringify(payload)); await redis.set(key, JSON.stringify(payload));
@@ -267,6 +268,7 @@ export async function getPreferences(redis, userId) {
theme: "system", theme: "system",
csvDelimiter: ";", csvDelimiter: ";",
sidebarPinned: true, sidebarPinned: true,
trendLineWidth: 1.8,
}; };
} }
@@ -278,8 +280,12 @@ export async function savePreferences(redis, userId, preferences) {
theme: preferences.theme || current.theme || "system", theme: preferences.theme || current.theme || "system",
csvDelimiter: preferences.csvDelimiter || current.csvDelimiter || ";", csvDelimiter: preferences.csvDelimiter || current.csvDelimiter || ";",
sidebarPinned: typeof preferences.sidebarPinned === "boolean" ? preferences.sidebarPinned : current.sidebarPinned, sidebarPinned: typeof preferences.sidebarPinned === "boolean" ? preferences.sidebarPinned : current.sidebarPinned,
trendLineWidth: Number.isFinite(Number(preferences.trendLineWidth)) ? Number(preferences.trendLineWidth) : Number(current.trendLineWidth || 1.8),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await redis.set(`preferences:${userId}`, JSON.stringify(payload)); await redis.set(`preferences:${userId}`, JSON.stringify(payload));
return payload; return payload;
} }
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
EXPOSE 18111
CMD ["npm", "start"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "seltd-bacnet-collector",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"bacstack": "^0.0.1-beta.14"
}
}
+906
View File
@@ -0,0 +1,906 @@
import { createServer } from "node:http";
import { Buffer } from "node:buffer";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { networkInterfaces } from "node:os";
import dgram from "node:dgram";
import Bacstack from "bacstack";
const port = Number(process.env.PORT || 18111);
const defaultTimeout = Number(process.env.BACNET_SCAN_TIMEOUT || 7000);
const pointLimit = Number(process.env.BACNET_POINT_LIMIT || 2000);
function replyJson(res, statusCode, body) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end(JSON.stringify(body));
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf8");
resolve(text ? JSON.parse(text) : {});
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeText(value) {
return String(value ?? "").trim();
}
function normalizeBacnetMode(value) {
const mode = normalizeText(value || "broadcast").toLowerCase();
return ["broadcast", "bbmd", "foreign-device"].includes(mode) ? mode : "broadcast";
}
function normalizeBacnetTtl(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 120;
}
function getRouting(body) {
return {
mode: normalizeBacnetMode(body?.bacnetMode || body?.options?.bacnetMode),
host: normalizeText(body?.host || body?.options?.bbmdHost || ""),
port: Number(body?.port || body?.options?.bbmdPort || 47808) || 47808,
broadcastAddress: normalizeText(body?.bacnetBroadcastAddress || body?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255",
foreignDeviceTtl: normalizeBacnetTtl(body?.bacnetForeignDeviceTtl || body?.options?.bacnetForeignDeviceTtl),
};
}
function isSubnetMaskLike(address) {
return /^255\.255\.255\.(0|128|192|224|240|248|252|254)$/.test(String(address || ""));
}
function isAutoBroadcast(value) {
return !normalizeText(value) || ["auto", "automatisch"].includes(normalizeText(value).toLowerCase()) || isSubnetMaskLike(value);
}
function parseIpv4(value) {
const address = normalizeText(value).replace(/^::ffff:/, "");
const parts = address.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return parts;
}
function isUsableLanIpv4(value) {
const parts = parseIpv4(value);
if (!parts) return false;
if (parts[0] === 127 || parts[0] === 0 || parts[0] >= 224) return false;
if (parts[0] === 172 && parts[1] >= 17 && parts[1] <= 31) return false; // Docker bridge defaults.
return true;
}
function ipv4ToNumber(parts) {
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
}
function numberToIpv4(value) {
return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
}
function broadcastFromAddress(address, netmask = "255.255.255.0") {
const ipParts = parseIpv4(address);
const maskParts = parseIpv4(netmask) || [255, 255, 255, 0];
if (!ipParts || !isUsableLanIpv4(address)) return "";
const ip = ipv4ToNumber(ipParts);
const mask = ipv4ToNumber(maskParts);
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
}
function getInterfaceBroadcasts() {
return Object.values(networkInterfaces())
.flat()
.filter((item) => item && item.family === "IPv4" && !item.internal && isUsableLanIpv4(item.address))
.map((item) => broadcastFromAddress(item.address, item.netmask))
.filter(Boolean);
}
function getBroadcastTargets(routing, body = {}) {
const configured = normalizeText(routing.broadcastAddress || "auto");
const targets = [];
if (!isAutoBroadcast(configured)) {
targets.push(configured);
}
const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets);
for (const subnet of configuredSubnets) {
const broadcast = cidrBroadcast(subnet);
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));
}
if (!targets.length) {
targets.push("255.255.255.255");
}
return Array.from(new Set(targets));
}
function expandCidrHosts(cidr) {
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
if (!match) return [];
const ipParts = parseIpv4(match[1]);
const prefix = Number(match[2]);
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) return [];
if (prefix === 32) return [numberToIpv4(ipv4ToNumber(ipParts))];
const ip = ipv4ToNumber(ipParts);
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
const network = (ip & mask) >>> 0;
const broadcast = (network | (~mask >>> 0)) >>> 0;
const limit = Math.min(broadcast - network - 1, 4094);
return Array.from({ length: Math.max(0, limit) }, (_, index) => numberToIpv4(network + index + 1));
}
function cidrBroadcast(cidr) {
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
if (!match) return "";
const ipParts = parseIpv4(match[1]);
const prefix = Number(match[2]);
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return "";
const ip = ipv4ToNumber(ipParts);
const mask = (0xffffffff << (32 - prefix)) >>> 0;
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
}
function expandDirectedBroadcast(address) {
const parts = String(address || "").split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return [];
}
if (parts[3] !== 255) {
return [];
}
const prefix = parts.slice(0, 3).join(".");
return Array.from({ length: 254 }, (_, index) => prefix + "." + (index + 1));
}
function parseHostList(value) {
if (Array.isArray(value)) {
return value.flatMap((item) => parseHostList(item));
}
return String(value || "")
.split(/[;,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildUnicastScanHosts(body, routing) {
const hosts = [
...parseHostList(body.scanHosts),
...parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets).flatMap((subnet) => subnet.includes("/") ? expandCidrHosts(subnet) : (parseIpv4(subnet) ? [subnet] : [])),
...parseHostList(process.env.BACNET_SCAN_HOSTS),
];
if (routing.host && routing.mode !== "broadcast") {
hosts.push(routing.host);
}
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
if (broadcastAddress !== "255.255.255.255") {
hosts.push(...expandDirectedBroadcast(broadcastAddress));
}
}
return Array.from(new Set(hosts)).slice(0, 512);
}
function sendUnicastWhoIs(client, host, deviceId) {
const packet = captureWhoIsPacket(client, deviceId);
packet[1] = 0x0a;
getTransport(client).send(packet, packet.length, String(host || "").replace(/:\d+$/, ""));
}
function createClient(body, timeout) {
const routing = getRouting(body);
const client = new Bacstack({
apduTimeout: timeout,
port: routing.port,
broadcastAddress: routing.broadcastAddress,
});
return { client, routing };
}
function closeClient(client) {
try {
client.close?.();
} catch {}
try {
client._transport?.close?.();
} catch {}
}
function getTransport(client) {
const transport = client?._transport;
if (!transport || typeof transport.send !== "function" || !transport._server) {
throw new Error("BACnet-Transport konnte nicht initialisiert werden.");
}
return transport;
}
function waitForSocket(client, timeout = 1200) {
const transport = getTransport(client);
try {
const address = transport._server.address?.();
if (address && typeof address.port === "number") {
return Promise.resolve();
}
} catch {}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (error) => {
if (settled) return;
settled = true;
transport._server.off("listening", onListening);
transport._server.off("error", onError);
clearTimeout(timer);
if (error) {
reject(error);
} else {
resolve();
}
};
const onListening = () => finish();
const onError = (error) => finish(error);
const timer = setTimeout(() => finish(), timeout);
transport._server.once("listening", onListening);
transport._server.once("error", onError);
});
}
function captureWhoIsPacket(client, deviceId) {
const transport = getTransport(client);
const originalSend = transport.send.bind(transport);
let packet = null;
transport.send = (buffer, offset) => {
packet = Buffer.from(buffer.slice(0, offset));
};
try {
const numericDeviceId = Number(deviceId);
if (Number.isFinite(numericDeviceId)) {
client.whoIs({ lowLimit: numericDeviceId, highLimit: numericDeviceId });
} else {
client.whoIs();
}
} finally {
transport.send = originalSend;
}
if (!packet || packet.length < 4 || packet[0] !== 0x81) {
throw new Error("BACnet Who-Is konnte nicht aufgebaut werden.");
}
return packet;
}
function bvlcResultText(resultCode) {
const known = {
0x0000: "Erfolgreich",
0x0030: "Register-Foreign-Device NAK",
0x0060: "Distribute-Broadcast-To-Network NAK",
};
return known[resultCode] || `BVLC-Fehler ${resultCode}`;
}
async function sendControlPacket(client, receiver, packet, matcher, timeout = 3000) {
const transport = getTransport(client);
await waitForSocket(client);
return await new Promise((resolve, reject) => {
let settled = false;
const socket = transport._server;
const cleanup = () => {
socket.off("message", onMessage);
clearTimeout(timer);
};
const finish = (error, result) => {
if (settled) return;
settled = true;
cleanup();
if (error) {
reject(error instanceof Error ? error : new Error(String(error)));
} else {
resolve(result);
}
};
const onMessage = (message, rinfo) => {
try {
if (matcher(message, rinfo)) {
finish(null, { message, rinfo });
}
} catch (error) {
finish(error);
}
};
const timer = setTimeout(() => finish(new Error("BACnet-Steuertelegramm hat nicht rechtzeitig geantwortet.")), timeout);
socket.on("message", onMessage);
try {
transport.send(packet, packet.length, receiver);
} catch (error) {
finish(error);
}
});
}
async function registerForeignDevice(client, routing) {
if (routing.mode !== "foreign-device") {
return;
}
if (!routing.host) {
throw new Error("Für BACnet Foreign Device bitte BBMD Host/IP angeben.");
}
const packet = Buffer.alloc(6);
packet[0] = 0x81;
packet[1] = 0x05;
packet.writeUInt16BE(6, 2);
packet.writeUInt16BE(routing.foreignDeviceTtl, 4);
const { message } = await sendControlPacket(
client,
routing.host,
packet,
(candidate, rinfo) => candidate?.length >= 6 && candidate[0] === 0x81 && candidate[1] === 0x00 && Number(rinfo?.port) === routing.port,
3000,
);
const resultCode = message.readUInt16BE(4);
if (resultCode !== 0x0000) {
throw new Error(`Foreign-Device-Register wurde abgelehnt: ${bvlcResultText(resultCode)}.`);
}
}
function sendWhoIs(client, routing, deviceId) {
if (routing.mode === "bbmd" || routing.mode === "foreign-device") {
if (!routing.host) {
throw new Error("Für BACnet über BBMD bitte Host/IP angeben.");
}
const packet = captureWhoIsPacket(client, deviceId);
packet[1] = 0x09;
getTransport(client).send(packet, packet.length, routing.host);
return;
}
const numericDeviceId = Number(deviceId);
if (Number.isFinite(numericDeviceId)) {
client.whoIs({ lowLimit: numericDeviceId, highLimit: numericDeviceId });
} else {
client.whoIs();
}
}
function readProperty(client, host, objectId, propertyId) {
return new Promise((resolve) => {
client.readProperty(host, objectId, propertyId, (error, value) => {
resolve(error ? null : value);
});
});
}
function objectLabel(objectId) {
const typeMap = {
0: "analogInput",
1: "analogOutput",
2: "analogValue",
3: "binaryInput",
4: "binaryOutput",
5: "binaryValue",
8: "device",
13: "multiStateInput",
14: "multiStateOutput",
19: "multiStateValue",
};
return `${typeMap[objectId.type] || `object${objectId.type}`}${objectId.instance}`;
}
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) };
const objectList = await readProperty(client, host, deviceObject, 76);
const values = objectList?.values || [];
const points = [];
for (const entry of values.slice(0, pointLimit)) {
const objectId = entry?.value || entry;
if (!objectId || objectId.type === 8 || !Number.isFinite(Number(objectId.instance))) {
continue;
}
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;
points.push({
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,
address: String(objectId.instance),
dataType: objectLabel(objectId).replace(/[0-9]+$/, ""),
name,
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,
});
}
return points;
}
function encodeBacnetContextUnsigned(tagNumber, value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return Buffer.alloc(0);
}
if (numeric <= 0xff) {
return Buffer.from([(tagNumber << 4) | 1, numeric]);
}
if (numeric <= 0xffff) {
const buffer = Buffer.alloc(3);
buffer[0] = (tagNumber << 4) | 2;
buffer.writeUInt16BE(numeric, 1);
return buffer;
}
const buffer = Buffer.alloc(5);
buffer[0] = (tagNumber << 4) | 4;
buffer.writeUInt32BE(numeric, 1);
return buffer;
}
function buildRawWhoIsPacket(deviceId, bvlcFunction) {
const numericDeviceId = Number(deviceId);
const payload = [Buffer.from([0x01, 0x00, 0x10, 0x08])];
if (Number.isFinite(numericDeviceId)) {
payload.push(encodeBacnetContextUnsigned(0, numericDeviceId));
payload.push(encodeBacnetContextUnsigned(1, numericDeviceId));
}
const body = Buffer.concat(payload);
const packet = Buffer.alloc(4 + body.length);
packet[0] = 0x81;
packet[1] = bvlcFunction;
packet.writeUInt16BE(packet.length, 2);
body.copy(packet, 4);
return packet;
}
function parseRawIAm(message) {
if (!message || message.length < 12 || message[0] !== 0x81) {
return null;
}
const start = message.indexOf(0xc4);
if (start < 0 || start + 4 >= message.length) {
return null;
}
const objectId = message.readUInt32BE(start + 1);
const objectType = objectId >>> 22;
const deviceId = objectId & 0x3fffff;
if (objectType !== 8) {
return null;
}
return { deviceId };
}
function runRawDiscoveryHelper(body, routing) {
const timeout = Number(body.timeout || defaultTimeout) + 3000;
const helperPath = fileURLToPath(new URL("./raw-discover.js", import.meta.url));
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [helperPath], {
env: {
...process.env,
RAW_BACNET_BODY: JSON.stringify({ body, routing }),
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("BACnet Raw-Discovery-Helper Timeout"));
}, Math.max(2500, timeout));
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(stderr || `BACnet Raw-Discovery-Helper beendet mit Code ${code}.`));
return;
}
try {
resolve(JSON.parse(stdout || "{}"));
} catch (error) {
reject(new Error(`BACnet Raw-Discovery-Helper lieferte kein gültiges JSON: ${error.message}`));
}
});
});
}
async function rawBacnetDiscover(body, routing) {
if (routing.mode !== "broadcast") {
return [];
}
const hosts = buildUnicastScanHosts(body, routing);
const helperResult = await runRawDiscoveryHelper(body, routing).catch((error) => ({
helperError: error?.message || String(error),
devices: [],
rawTrace: [],
rawDebug: { hosts, sends: [], packets: [], helperError: error?.message || String(error) },
}));
const helperDevices = Array.isArray(helperResult.devices) ? helperResult.devices : [];
if (helperDevices.length) {
helperDevices.rawTrace = helperResult.rawTrace || [];
helperDevices.rawDebug = { ...(helperResult.rawDebug || {}), helper: "process" };
return helperDevices;
}
const devices = [];
const rawTrace = helperResult.rawTrace || [];
const rawDebug = { hosts, sends: [], packets: [], helper: "fallback", helperError: helperResult.helperError || "" };
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
const addDevice = (deviceId, host, rinfo) => {
rawTrace.push({ deviceId: Number(deviceId), host, remote: rinfo ? rinfo.address + ":" + rinfo.port : "" });
if (!Number.isFinite(Number(deviceId)) || !host) {
return;
}
if (!devices.some((item) => item.deviceId === Number(deviceId) && item.address === host)) {
devices.push({
address: host,
host,
port: routing.port,
reachable: true,
deviceId: Number(deviceId),
name: "BACnet Gerät " + Number(deviceId),
vendorId: null,
maxApdu: null,
segmentation: null,
pointCount: 0,
points: [],
rawRemote: rinfo ? rinfo.address + ":" + rinfo.port : "",
});
}
};
await new Promise((resolve, reject) => {
socket.once("error", reject);
socket.bind(routing.port, "0.0.0.0", () => {
socket.setBroadcast(true);
resolve();
});
});
socket.on("message", (message, rinfo) => {
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
const parsed = parseRawIAm(message);
if (parsed) {
addDevice(parsed.deviceId, rinfo.address, rinfo);
}
});
try {
const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b);
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
socket.send(broadcastPacket, routing.port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" }));
await sleep(350);
}
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20);
const packet = buildRawWhoIsPacket(body.deviceId, 0x0a);
for (const host of hosts) {
const before = devices.length;
socket.removeAllListeners("message");
socket.on("message", (message, rinfo) => {
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
const parsed = parseRawIAm(message);
if (parsed) {
addDevice(parsed.deviceId, host, rinfo);
}
});
socket.send(packet, routing.port, host, (error) => rawDebug.sends.push({ host, error: error?.message || "" }));
await sleep(Math.max(15, perHostDelay));
if (body.deviceId && devices.length > before && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
break;
}
}
await sleep(150);
} finally {
socket.close();
}
devices.rawTrace = rawTrace;
devices.rawDebug = rawDebug;
return devices;
}
function isLikelyDockerGatewayAddress(address) {
return /^172\.(1[7-9]|2\d|3[01])\.0\.1$/.test(String(address || ""));
}
function preferRealBacnetAddresses(devices) {
const list = Array.isArray(devices) ? devices : [];
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);
const includeObjects = body.includeObjects === true || String(body.includeObjects || "").toLowerCase() === "true";
const objectReadTimeout = Number(body.objectReadTimeout || process.env.BACNET_OBJECT_READ_TIMEOUT || 1500);
const routing = getRouting(body);
const rawDevices = await rawBacnetDiscover(body, routing);
const { client } = createClient(body, includeObjects ? Math.min(timeout, objectReadTimeout) : timeout);
const devices = [...rawDevices];
const rawTrace = rawDevices.rawTrace || [];
const rawDebug = rawDevices.rawDebug || null;
let currentUnicastTarget = "";
const addDevice = (device, forcedAddress = "") => {
const address = normalizeText(forcedAddress || device?.address || "");
const deviceId = Number(device?.deviceId);
if (!address || !Number.isFinite(deviceId)) {
return;
}
const existing = devices.find((item) => item.deviceId === deviceId);
if (existing && forcedAddress) {
existing.address = address;
existing.host = address;
return;
}
if (!devices.some((item) => item.deviceId === deviceId && item.address === address)) {
devices.push({
address,
host: address,
port: routing.port,
reachable: true,
deviceId,
name: "BACnet Gerät " + deviceId,
vendorId: device?.vendorId ?? null,
maxApdu: device?.maxApdu ?? null,
segmentation: device?.segmentation ?? null,
pointCount: 0,
points: [],
});
}
};
client.on("iAm", (device) => addDevice(device, currentUnicastTarget));
try {
if ((routing.mode === "bbmd" || routing.mode === "foreign-device") && !routing.host) {
throw new Error("Für BACnet über BBMD bitte Host/IP angeben.");
}
await waitForSocket(client);
await registerForeignDevice(client, routing);
sendWhoIs(client, routing, body.deviceId);
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)));
if (needsUnicastMapping) {
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20);
for (const host of hosts) {
currentUnicastTarget = host;
sendUnicastWhoIs(client, host, body.deviceId);
await sleep(Math.max(15, perHostDelay));
if (body.deviceId && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
break;
}
}
currentUnicastTarget = "";
await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200));
}
let visibleDevices = preferRealBacnetAddresses(devices);
if (includeObjects) {
devices.splice(0, devices.length, ...visibleDevices);
for (const device of devices) {
const deviceObject = { type: 8, instance: Number(device.deviceId) };
const deviceName = await readProperty(client, device.address, deviceObject, 77);
const resolvedName = normalizeText(extractValue(deviceName));
if (resolvedName) {
device.name = resolvedName;
}
device.points = await scanObjects(client, device, routing.port);
device.pointCount = device.points.length;
}
}
visibleDevices = preferRealBacnetAddresses(devices);
return {
driver: "bacstack",
routing,
devices: visibleDevices,
rawTrace: body.debug === true ? rawTrace : undefined,
rawDebug: body.debug === true ? rawDebug : undefined,
points: visibleDevices.flatMap((device) => device.points || []),
scannedAt: new Date().toISOString(),
};
} finally {
closeClient(client);
}
}
function parseObjectId(datapoint) {
const typeMap = {
analogInput: 0,
analogOutput: 1,
analogValue: 2,
binaryInput: 3,
binaryOutput: 4,
binaryValue: 5,
multiStateInput: 13,
multiStateOutput: 14,
multiStateValue: 19,
};
if (Number.isFinite(Number(datapoint.objectType))) {
return { type: Number(datapoint.objectType), instance: Number(datapoint.objectInstance || datapoint.address || 0) };
}
const normalizedType = String(datapoint.dataType || "").replace(/[s_-]+/g, "");
const matchedType = Object.entries(typeMap).find(([name]) => name.toLowerCase() === normalizedType.toLowerCase())?.[1];
const shorthand = String(datapoint.address || datapoint.nodeId || "").match(/^(ai|ao|av|bi|bo|bv|msi|mso|msv)(\\d+)$/i);
const shorthandTypes = { ai: 0, ao: 1, av: 2, bi: 3, bo: 4, bv: 5, msi: 13, mso: 14, msv: 19 };
return {
type: matchedType ?? (shorthand ? shorthandTypes[shorthand[1].toLowerCase()] : 0),
instance: Number(datapoint.objectInstance || (shorthand ? shorthand[2] : datapoint.address) || datapoint.pointIndex || 0),
};
}
async function readCurrentValue(source, datapoint) {
const { client, routing } = createClient(source, Number(source.timeout || 5000));
try {
await waitForSocket(client);
await registerForeignDevice(client, routing);
const host = normalizeText(datapoint.host || source.host || "");
if (!host) {
throw new Error("BACnet Host fehlt.");
}
const propertyId = Number(datapoint.propertyId || 85);
const objectId = parseObjectId(datapoint);
const propertyValue = await new Promise((resolve, reject) => {
client.readProperty(host, objectId, propertyId, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
const raw = extractValue(propertyValue);
if (typeof raw === "boolean") {
return raw ? 1 : 0;
}
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : String(raw ?? "");
} finally {
closeClient(client);
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (req.method === "OPTIONS") {
replyJson(res, 204, {});
return;
}
if (req.method === "GET" && url.pathname === "/health") {
replyJson(res, 200, { ok: true, service: "bacnet-collector", driver: "bacstack", timestamp: new Date().toISOString() });
return;
}
if (req.method === "GET" && url.pathname === "/capabilities") {
replyJson(res, 200, {
protocols: ["bacnet-ip"],
scans: ["bacnet-ip"],
driver: "bacstack",
modes: ["broadcast", "bbmd", "foreign-device"],
});
return;
}
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
try {
const body = await readBody(req);
const payload = await scanNetwork(body);
replyJson(res, 200, payload);
} catch (error) {
replyJson(res, 400, { error: error.message || "BACnet-Scan fehlgeschlagen." });
}
return;
}
if (req.method === "POST" && url.pathname === "/read/bacnet") {
try {
const body = await readBody(req);
const value = await readCurrentValue(body.source || {}, body.datapoint || {});
replyJson(res, 200, { value, readAt: new Date().toISOString() });
} catch (error) {
replyJson(res, 400, { error: error.message || "BACnet-Livewert konnte nicht gelesen werden." });
}
return;
}
replyJson(res, 404, { error: "Nicht gefunden." });
});
server.listen(port, () => {
console.log(`SE Local Trenddata BACnet collector listening on port ${port}`);
});
+296
View File
@@ -0,0 +1,296 @@
import dgram from "node:dgram";
import { networkInterfaces } from "node:os";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeText(value) {
return String(value ?? "").trim();
}
function parseHostList(value) {
if (Array.isArray(value)) {
return value.flatMap((item) => parseHostList(item));
}
return String(value || "")
.split(/[,\s;]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function isSubnetMaskLike(address) {
return /^255\.255\.255\.(0|128|192|224|240|248|252|254)$/.test(String(address || ""));
}
function isAutoBroadcast(value) {
return !normalizeText(value) || ["auto", "automatisch"].includes(normalizeText(value).toLowerCase()) || isSubnetMaskLike(value);
}
function parseIpv4(value) {
const address = normalizeText(value).replace(/^::ffff:/, "");
const parts = address.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return parts;
}
function isUsableLanIpv4(value) {
const parts = parseIpv4(value);
if (!parts) return false;
if (parts[0] === 127 || parts[0] === 0 || parts[0] >= 224) return false;
if (parts[0] === 172 && parts[1] >= 17 && parts[1] <= 31) return false; // Docker bridge defaults.
return true;
}
function ipv4ToNumber(parts) {
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
}
function numberToIpv4(value) {
return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join(".");
}
function broadcastFromAddress(address, netmask = "255.255.255.0") {
const ipParts = parseIpv4(address);
const maskParts = parseIpv4(netmask) || [255, 255, 255, 0];
if (!ipParts || !isUsableLanIpv4(address)) return "";
const ip = ipv4ToNumber(ipParts);
const mask = ipv4ToNumber(maskParts);
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
}
function getInterfaceBroadcasts() {
return Object.values(networkInterfaces())
.flat()
.filter((item) => item && item.family === "IPv4" && !item.internal && isUsableLanIpv4(item.address))
.map((item) => broadcastFromAddress(item.address, item.netmask))
.filter(Boolean);
}
function getBroadcastTargets(routing, body = {}) {
const configured = normalizeText(routing.broadcastAddress || "auto");
const targets = [];
if (!isAutoBroadcast(configured)) {
targets.push(configured);
}
const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets);
for (const subnet of configuredSubnets) {
const broadcast = cidrBroadcast(subnet);
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));
}
if (!targets.length) {
targets.push("255.255.255.255");
}
return Array.from(new Set(targets));
}
function expandCidrHosts(cidr) {
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
if (!match) return [];
const ipParts = parseIpv4(match[1]);
const prefix = Number(match[2]);
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) return [];
if (prefix === 32) return [numberToIpv4(ipv4ToNumber(ipParts))];
const ip = ipv4ToNumber(ipParts);
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
const network = (ip & mask) >>> 0;
const broadcast = (network | (~mask >>> 0)) >>> 0;
const limit = Math.min(broadcast - network - 1, 4094);
return Array.from({ length: Math.max(0, limit) }, (_, index) => numberToIpv4(network + index + 1));
}
function cidrBroadcast(cidr) {
const match = String(cidr || "").trim().match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/);
if (!match) return "";
const ipParts = parseIpv4(match[1]);
const prefix = Number(match[2]);
if (!ipParts || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return "";
const ip = ipv4ToNumber(ipParts);
const mask = (0xffffffff << (32 - prefix)) >>> 0;
return numberToIpv4((ip | (~mask >>> 0)) >>> 0);
}
function expandDirectedBroadcast(address) {
const parts = String(address || "").split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return [];
}
if (parts[3] !== 255) {
return [];
}
const prefix = parts.slice(0, 3).join(".");
return Array.from({ length: 254 }, (_, index) => prefix + "." + (index + 1));
}
function buildUnicastScanHosts(body, routing) {
const hosts = [
...parseHostList(body.scanHosts),
...parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets).flatMap((subnet) => subnet.includes("/") ? expandCidrHosts(subnet) : (parseIpv4(subnet) ? [subnet] : [])),
...parseHostList(process.env.BACNET_SCAN_HOSTS),
];
if (routing.host && routing.mode !== "broadcast") {
hosts.push(routing.host);
}
for (const broadcastAddress of getBroadcastTargets(routing, body)) {
if (broadcastAddress !== "255.255.255.255") {
hosts.push(...expandDirectedBroadcast(broadcastAddress));
}
}
return Array.from(new Set(hosts)).slice(0, 512);
}
function encodeBacnetContextUnsigned(tagNumber, value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return Buffer.alloc(0);
}
if (numeric <= 0xff) {
return Buffer.from([(tagNumber << 4) | 0x09, numeric]);
}
if (numeric <= 0xffff) {
const buffer = Buffer.alloc(3);
buffer[0] = (tagNumber << 4) | 0x0a;
buffer.writeUInt16BE(numeric, 1);
return buffer;
}
const buffer = Buffer.alloc(5);
buffer[0] = (tagNumber << 4) | 0x0c;
buffer.writeUInt32BE(numeric, 1);
return buffer;
}
function buildRawWhoIsPacket(deviceId, bvlcFunction) {
const numericDeviceId = Number(deviceId);
const payload = [Buffer.from([0x01, 0x00, 0x10, 0x08])];
if (Number.isFinite(numericDeviceId)) {
payload.push(encodeBacnetContextUnsigned(0, numericDeviceId));
payload.push(encodeBacnetContextUnsigned(1, numericDeviceId));
}
const body = Buffer.concat(payload);
const packet = Buffer.alloc(4 + body.length);
packet[0] = 0x81;
packet[1] = bvlcFunction;
packet.writeUInt16BE(packet.length, 2);
body.copy(packet, 4);
return packet;
}
function parseRawIAm(message) {
if (!message || message.length < 12 || message[0] !== 0x81) {
return null;
}
const start = message.indexOf(0xc4);
if (start < 0 || start + 4 >= message.length) {
return null;
}
const objectId = message.readUInt32BE(start + 1);
const objectType = objectId >>> 22;
const deviceId = objectId & 0x3fffff;
if (objectType !== 8) {
return null;
}
return { deviceId };
}
async function discover() {
const input = JSON.parse(process.env.RAW_BACNET_BODY || "{}");
const body = input.body || {};
const routing = input.routing || {};
const port = Number(routing.port || 47808) || 47808;
const hosts = buildUnicastScanHosts(body, routing);
const devices = [];
const rawTrace = [];
const rawDebug = { hosts, sends: [], packets: [] };
let currentUnicastHost = "";
if (routing.mode !== "broadcast") {
return { devices, rawTrace, rawDebug };
}
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
const addDevice = (deviceId, host, rinfo) => {
rawTrace.push({ deviceId: Number(deviceId), host, remote: rinfo ? rinfo.address + ":" + rinfo.port : "" });
if (!Number.isFinite(Number(deviceId)) || !host) {
return;
}
if (!devices.some((item) => item.deviceId === Number(deviceId) && item.address === host)) {
devices.push({
address: host,
host,
port,
reachable: true,
deviceId: Number(deviceId),
name: "BACnet Gerät " + Number(deviceId),
vendorId: null,
maxApdu: null,
segmentation: null,
pointCount: 0,
points: [],
rawRemote: rinfo ? rinfo.address + ":" + rinfo.port : "",
});
}
};
await new Promise((resolve, reject) => {
socket.once("error", reject);
socket.bind(port, "0.0.0.0", () => {
socket.setBroadcast(true);
resolve();
});
});
socket.on("message", (message, rinfo) => {
rawDebug.packets.push({ remote: rinfo.address + ":" + rinfo.port, length: message.length, hex: message.toString("hex") });
const parsed = parseRawIAm(message);
if (parsed) {
addDevice(parsed.deviceId, currentUnicastHost || rinfo.address, rinfo);
}
});
try {
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(Number(body.broadcastDelayMs || process.env.BACNET_BROADCAST_DELAY_MS || 700));
}
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;
currentUnicastHost = host;
socket.send(packet, port, host, (error) => rawDebug.sends.push({ host, error: error?.message || "" }));
await sleep(Math.max(50, perHostDelay));
if (body.deviceId && devices.length > before && devices.some((device) => String(device.deviceId) === String(body.deviceId))) {
break;
}
}
currentUnicastHost = "";
await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200));
} finally {
socket.close();
}
return { devices, rawTrace, rawDebug };
}
discover()
.then((result) => {
process.stdout.write(JSON.stringify(result));
})
.catch((error) => {
process.stderr.write(error?.stack || error?.message || String(error));
process.exitCode = 1;
});
+2 -2
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY src ./src COPY src ./src
RUN mkdir -p /app/data RUN mkdir -p /app/data
+2433
View File
File diff suppressed because it is too large Load Diff
+17 -13
View File
@@ -1,15 +1,19 @@
{ {
"name": "seltd-collector", "name": "seltd-collector",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/index.js" "start": "node src/index.js",
}, "check": "node --check src/index.js"
"dependencies": { },
"mysql2": "^3.12.0", "dependencies": {
"node-opcua": "latest", "mysql2": "^3.12.0",
"node-bacnet": "latest", "node-opcua": "2.184.4",
"knx": "latest" "node-bacnet": "0.2.4",
} "knx": "2.5.4"
},
"overrides": {
"binary-parser": "2.3.0"
}
} }
+755 -78
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
services:
mariadb:
volumes:
- /media/sd/seltd/mariadb:/var/lib/mysql
- ./docker/mariadb/init:/docker-entrypoint-initdb.d:ro
redis:
volumes:
- /media/sd/seltd/redis:/data
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
collector:
volumes:
- /media/sd/seltd/collector:/app/data
dockhand:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /media/sd/seltd/dockhand:/app/data
+94 -7
View File
@@ -24,14 +24,14 @@ services:
image: redis:7.4-alpine image: redis:7.4-alpine
container_name: seltd-redis container_name: seltd-redis
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "/usr/local/etc/redis/redis.conf"] environment:
ports: REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}
- "6379:6379" command: ["sh", "-c", "exec redis-server /usr/local/etc/redis/redis.conf --requirepass \"$$REDIS_PASSWORD\""]
volumes: volumes:
- redis_data:/data - redis_data:/data
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -49,7 +49,7 @@ services:
DB_USER: root DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root} DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root}
REDIS_URL: redis://redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
JWT_SECRET: ${JWT_SECRET:-change-me} JWT_SECRET: ${JWT_SECRET:-change-me}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
@@ -63,6 +63,11 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
web: web:
build: build:
@@ -74,7 +79,13 @@ services:
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:
- api api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
collector: collector:
build: build:
@@ -89,20 +100,92 @@ services:
DB_USER: root DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
POLL_TICK_MS: ${POLL_TICK_MS:-1000} POLL_TICK_MS: ${POLL_TICK_MS:-1000}
BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://172.17.0.1:18111}
ports: ports:
- "18110:18110" - "18110:18110"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- collector_data:/app/data - collector_data:/app/data
depends_on: depends_on:
mariadb: mariadb:
condition: service_healthy condition: service_healthy
bacnet-collector:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:18110/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
symcon-collector:
build:
context: ./symcon-collector
container_name: seltd-symcon-collector
restart: unless-stopped
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: wago
DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
SOURCES_FILE: /app/collector-data/sources.json
SYMCON_POLL_TICK_MS: ${SYMCON_POLL_TICK_MS:-1000}
SYMCON_MAX_CONCURRENCY: ${SYMCON_MAX_CONCURRENCY:-4}
volumes:
- collector_data:/app/collector-data:ro
depends_on:
mariadb:
condition: service_healthy
collector:
condition: service_started
bacnet-collector:
build:
context: ./bacnet-collector
container_name: seltd-bacnet-collector
restart: unless-stopped
environment:
PORT: 18111
BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-12000}
BACNET_POINT_LIMIT: ${BACNET_POINT_LIMIT:-2000}
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120}
network_mode: "host"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:18111/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
runner:
build:
context: ./runner
container_name: seltd-runner
restart: unless-stopped
environment:
REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
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:-1000}
RUNNER_TIMEZONE: ${RUNNER_TIMEZONE:-Europe/Berlin}
volumes:
- runner_exports:/exports
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
dockhand: dockhand:
image: fnsys/dockhand:latest image: fnsys/dockhand:latest
container_name: seltd-dockhand container_name: seltd-dockhand
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3008:3000"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dockhand_data:/app/data - dockhand_data:/app/data
@@ -129,5 +212,9 @@ volumes:
mariadb_data: mariadb_data:
redis_data: redis_data:
collector_data: collector_data:
runner_exports:
dockhand_data: dockhand_data:
+2 -2
View File
@@ -22,7 +22,7 @@ BEGIN
'` (', '` (',
'`id` INT(11) NOT NULL AUTO_INCREMENT,', '`id` INT(11) NOT NULL AUTO_INCREMENT,',
'`datum` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', '`datum` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,',
'`userlevel` INT(1) NOT NULL', '`userlevel` INT(1) DEFAULT NULL',
value_columns, value_columns,
', PRIMARY KEY (`id`)', ', PRIMARY KEY (`id`)',
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;' ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;'
@@ -64,5 +64,5 @@ FLUSH PRIVILEGES;
CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112'; CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON `wago`.* TO 'wago'@'%'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON * . * TO 'wago'@'%' IDENTIFIED BY '3112';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
+25
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
RUN mkdir -p /exports
CMD ["npm", "start"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "seltd-runner",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"ioredis": "^5.6.1",
"mysql2": "^3.12.0"
}
}
+250
View File
@@ -0,0 +1,250 @@
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 = Math.max(500, Number(process.env.RUNNER_TICK_MS || 1000));
const timeZone = process.env.RUNNER_TIMEZONE || "Europe/Berlin";
function safeParse(value, fallback = null) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function formatExportValue(value) {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value ? "1" : "0";
const text = String(value).trim();
if (!text) return null;
const numeric = Number(text);
// German decimals prevent Excel from treating values such as 24.2 as dates.
if (Number.isFinite(numeric)) {
return new Intl.NumberFormat("de-DE", { useGrouping: false, maximumFractionDigits: 10 }).format(numeric);
}
return text;
}
function csvCell(value, delimiter) {
const text = String(value ?? "");
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
}
function localDateParts(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
}
function localDateKey(date = new Date()) {
const parts = localDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function localTime(date = new Date()) {
const parts = localDateParts(date);
return `${parts.hour}:${parts.minute}`;
}
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 points = meta.points || {};
// Legacy metadata stores values by index, newer sources use an array.
const point = Array.isArray(points)
? points.find((item) => Number(item.pointIndex) === Number(pointIndex))
: points[String(pointIndex)] || points[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) {
const value = formatExportValue(item.value);
if (value === null) continue;
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, 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) {
const value = formatExportValue(item.value);
if (value === null) continue;
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, 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);
// Excel uses the BOM to reliably recognize UTF-8 when opening CSV files directly.
await writeFile(filePath, `\uFEFF${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);
});
}
function parseMailRecipients(value) {
return [...new Set(String(value || "").split(/[;,]/).map((entry) => entry.trim()).filter(Boolean))];
}
async function maybeMail(job, filePath) {
if (!job.mailEnabled || !job.mailTo) return;
const settings = safeParse(await redis.get("integration:api"), {});
if (!settings.mailApiKey) return;
const recipients = parseMailRecipients(job.mailTo);
if (!recipients.length) return;
for (const address of recipients) {
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
api_key: settings.mailApiKey,
address,
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 runQueuedJobs() {
for (let index = 0; index < 20; index += 1) {
const queued = safeParse(await redis.lpop("runner:run-now"));
if (!queued?.jobId) return;
const job = safeParse(await redis.get(`runner:job:${queued.jobId}`));
if (!job) continue;
try {
await runJob(job);
console.log(`Runner-Test ausgeführt: ${job.name || job.id}`);
} catch (error) {
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() }));
console.error(`Runner-Test fehlgeschlagen (${job.name || job.id}):`, error.message);
}
}
}
async function tick() {
await runQueuedJobs();
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 (${timeZone}, ${tickMs} ms)`);
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
tick().catch((error) => console.error(error));
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
CMD ["npm", "start"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "seltd-symcon-collector",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"check": "node --check src/index.js"
},
"dependencies": {
"mysql2": "^3.12.0"
}
}
+267
View File
@@ -0,0 +1,267 @@
import { existsSync, readFileSync } from "node:fs";
import mysql from "mysql2/promise";
const sourcesFile = process.env.SOURCES_FILE || "/app/collector-data/sources.json";
const pollTickMs = Math.max(500, Number(process.env.SYMCON_POLL_TICK_MS || 1000));
const maxConcurrency = Math.max(1, Math.min(16, Number(process.env.SYMCON_MAX_CONCURRENCY || 4)));
const sourcePollState = new Map();
const valueCache = new Map();
let polling = false;
let lastKnownGoodStore = { sources: [], datapoints: [] };
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: Math.max(2, maxConcurrency + 1),
});
function normalizeText(value) {
return String(value ?? "").trim();
}
function normalizeInterval(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.round(number) : 60;
}
function sanitizeTrendTable(value) {
const table = String(value || "").toLowerCase();
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) {
throw new Error("Ungültiger Trendtabellenname.");
}
return table;
}
async function ensureWorkerStateTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS \`source_worker_state\` (
\`source_id\` VARCHAR(191) NOT NULL,
\`worker\` VARCHAR(64) NOT NULL,
\`last_poll_at\` TIMESTAMP NULL,
\`last_poll_status\` VARCHAR(255) NOT NULL DEFAULT '',
\`last_poll_error\` TEXT NULL,
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (\`source_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function updateWorkerState(source, status, error = "") {
await db.query(
`
INSERT INTO \`source_worker_state\` (\`source_id\`, \`worker\`, \`last_poll_at\`, \`last_poll_status\`, \`last_poll_error\`)
VALUES (?, 'seltd-symcon-collector', CURRENT_TIMESTAMP, ?, ?)
ON DUPLICATE KEY UPDATE
\`worker\` = VALUES(\`worker\`),
\`last_poll_at\` = CURRENT_TIMESTAMP,
\`last_poll_status\` = VALUES(\`last_poll_status\`),
\`last_poll_error\` = VALUES(\`last_poll_error\`)
`,
[String(source.id), String(status || ""), String(error || "")],
);
}
function readStore() {
if (!existsSync(sourcesFile)) {
return structuredClone(lastKnownGoodStore);
}
try {
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
lastKnownGoodStore = {
sources: Array.isArray(store.sources) ? store.sources : [],
datapoints: Array.isArray(store.datapoints) ? store.datapoints : [],
};
return structuredClone(lastKnownGoodStore);
} catch (error) {
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
return structuredClone(lastKnownGoodStore);
}
}
function shouldPoll(source, now) {
const lastPoll = sourcePollState.get(source.id) || 0;
return now - lastPoll >= normalizeInterval(source.pollIntervalSeconds) * 1000;
}
function matchesLogCondition(condition, value) {
const text = String(condition || "").trim();
if (!text) return true;
const numeric = Number(value);
const match = text.match(/^(?:value\s*)?(>=|<=|>|<|==|!=)\s*(-?\d+(?:[.,]\d+)?)$/i);
if (!match || !Number.isFinite(numeric)) return true;
const target = Number(match[2].replace(",", "."));
if (match[1] === ">") return numeric > target;
if (match[1] === ">=") return numeric >= target;
if (match[1] === "<") return numeric < target;
if (match[1] === "<=") return numeric <= target;
if (match[1] === "==") return numeric === target;
if (match[1] === "!=") return numeric !== target;
return true;
}
function buildEndpoint(source) {
const rawHost = normalizeText(source.host);
if (!rawHost) throw new Error("IP-Symcon Host oder URL fehlt.");
const input = /^https?:\/\//i.test(rawHost) ? rawHost : `http://${rawHost}`;
const url = new URL(input);
if (!url.port) url.port = String(Number(source.port || 3777) || 3777);
const path = url.pathname.replace(/\/+$/, "");
url.pathname = !path || path === "/" ? "/hook/api" : (/\/hook\/api$/i.test(path) ? path : `${path}/hook/api`);
url.search = "";
url.hash = "";
return url.toString();
}
async function requestApi(source, payload) {
const apiKey = normalizeText(source.options?.ipsymconApiKey || source.ipsymconApiKey);
if (!apiKey) throw new Error("IP-Symcon API-Schlüssel fehlt.");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(buildEndpoint(source), {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ api_key: apiKey, ...payload }),
signal: controller.signal,
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok !== true) {
throw new Error(data?.error?.message || data?.error || `IP-Symcon API HTTP ${response.status}`);
}
return data;
} finally {
clearTimeout(timeout);
}
}
async function readValue(source, datapoint) {
const variableId = Number(datapoint.nodeId || datapoint.address);
if (!Number.isInteger(variableId) || variableId <= 0) {
throw new Error(`Ungültige Variablen-ID bei ${datapoint.alias || datapoint.name}.`);
}
const response = await requestApi(source, { function: "getvalue", id: variableId });
const raw = response.value ?? response.variable?.value;
if (typeof raw === "boolean") return raw ? 1 : 0;
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : raw === null || raw === undefined ? null : String(raw);
}
async function mapLimit(items, limit, task) {
const result = [];
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex++];
result.push(await task(item));
}
});
await Promise.all(workers);
return result;
}
async function ensureNarrowTrendTable(tableName) {
const table = sanitizeTrendTable(tableName);
const [columns] = await db.query(
"SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
[table],
);
const names = new Set(columns.map((entry) => String(entry.name).toLowerCase()));
if (names.size && Array.from(names).some((name) => /^wert\d+$/.test(name))) {
throw new Error(`${table} ist eine Legacy-Tabelle und keine IP-Symcon-Trendtabelle.`);
}
if (!names.size) {
await db.query(`
CREATE TABLE \`${table}\` (
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`userlevel\` INT NOT NULL DEFAULT 0,
\`source_id\` VARCHAR(191) NOT NULL,
\`point_index\` INT NOT NULL,
\`alias\` VARCHAR(255) NOT NULL,
\`value\` TEXT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_${table}_datum\` (\`datum\`),
KEY \`idx_${table}_point_datum\` (\`point_index\`, \`datum\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
return table;
}
async function writeReadings(source, readings) {
if (!readings.length) return 0;
const table = await ensureNarrowTrendTable(source.tableName);
const placeholders = readings.map(() => "(?, ?, ?, ?, ?)").join(", ");
const values = readings.flatMap((reading) => [0, String(source.id), Number(reading.pointIndex), String(reading.alias), String(reading.value)]);
await db.query(`INSERT INTO \`${table}\` (\`userlevel\`, \`source_id\`, \`point_index\`, \`alias\`, \`value\`) VALUES ${placeholders}`, values);
return readings.length;
}
async function pollSource(source, points) {
const errors = [];
const results = await mapLimit(points, maxConcurrency, async (datapoint) => {
try {
return { datapoint, value: await readValue(source, datapoint) };
} catch (error) {
errors.push(`${datapoint.alias || datapoint.address}: ${error.message}`);
return null;
}
});
const readings = [];
for (const result of results) {
if (!result) continue;
const { datapoint, value } = result;
const key = `${source.id}:${datapoint.id}`;
const serialized = String(value);
const writeMode = datapoint.writeMode || source.writeMode || "interval";
if (matchesLogCondition(datapoint.condition, value) && (writeMode !== "cov" || valueCache.get(key) !== serialized)) {
readings.push({ pointIndex: datapoint.pointIndex, alias: datapoint.alias || datapoint.name || `Wert${datapoint.pointIndex}`, value });
}
valueCache.set(key, serialized);
}
const written = await writeReadings(source, readings);
const suffix = errors.length ? `, ${errors.length} Fehler` : "";
const status = errors.length ? `teilweise ok: ${written} Wert(e) geschrieben${suffix}` : `ok: ${written} Wert(e) geschrieben`;
await updateWorkerState(source, status, errors.slice(0, 5).join(" | "));
console.log(`[${source.name || source.id}] ${written} Wert(e) geschrieben${suffix}`);
if (errors.length) console.warn(`[${source.name || source.id}]`, errors.slice(0, 5).join(" | "));
}
async function pollDueSources() {
if (polling) return;
polling = true;
try {
const store = readStore();
const now = Date.now();
const sources = store.sources.filter((source) => source.protocol === "ipsymcon" && shouldPoll(source, now));
for (const source of sources) {
const points = store.datapoints.filter((point) => point.sourceId === source.id && point.protocol === "ipsymcon" && point.enabled === true);
sourcePollState.set(source.id, now);
if (!points.length) {
await updateWorkerState(source, "idle: keine Datenpunkte");
continue;
}
try {
await pollSource(source, points);
} catch (error) {
await updateWorkerState(source, "error", error.message || "Polling fehlgeschlagen.").catch(() => undefined);
console.error(`[${source.name || source.id}] Polling fehlgeschlagen:`, error.message);
}
}
} finally {
polling = false;
}
}
setInterval(() => pollDueSources().catch((error) => console.error("IP-Symcon Polling fehlgeschlagen:", error.message)), pollTickMs);
ensureWorkerStateTable()
.then(() => pollDueSources())
.catch((error) => console.error("IP-Symcon Start-Polling fehlgeschlagen:", error.message));
console.log(`SE LTD Symcon Collector gestartet (Intervall-Prüfung ${pollTickMs} ms, Parallelität ${maxConcurrency}).`);
+33
View File
@@ -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
+2 -3
View File
@@ -1,13 +1,12 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
EXPOSE 5173 EXPOSE 5173
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]
+3 -2
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173",
"check": "vite build"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
@@ -20,4 +21,4 @@
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"vite": "^6.0.7" "vite": "^6.0.7"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+759 -61
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,4 +1,4 @@
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") { function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
const explicit = String(explicitEnv || "").trim(); const explicit = String(explicitEnv || "").trim();
if (explicit) { if (explicit) {
return explicit.replace(/\/$/, "") + suffix; return explicit.replace(/\/$/, "") + suffix;
@@ -80,6 +80,13 @@ export const api = {
loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }), loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }),
getPreferences: (token) => apiRequest("/preferences", { token }), getPreferences: (token) => apiRequest("/preferences", { token }),
savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }), 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 }),
testRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}/test`, { method: "POST", token }),
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
getCollectorCapabilities: () => collectorRequest("/capabilities"), getCollectorCapabilities: () => collectorRequest("/capabilities"),
getCollectorSources: () => collectorRequest("/sources"), getCollectorSources: () => collectorRequest("/sources"),
getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"), getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"),
@@ -94,4 +101,5 @@ export const api = {
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }), importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }), scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }), scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
scanIpsymcon: (body) => collectorRequest("/scan/ipsymcon", { method: "POST", body }),
}; };
+33 -11
View File
@@ -72,9 +72,12 @@ export default function TrendChart({
allowExports = true, allowExports = true,
allowFocusMode = true, allowFocusMode = true,
compact = false, compact = false,
lineWidth = 1.8,
}) { }) {
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const chartHostRef = useRef(null); const chartHostRef = useRef(null);
const dragBoxRef = useRef(null);
const domainRef = useRef({ x: [0, 1], y: [0, 1] });
const [selectedKeys, setSelectedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]);
const [dragBox, setDragBox] = useState(null); const [dragBox, setDragBox] = useState(null);
const [zoomDomain, setZoomDomain] = useState(null); const [zoomDomain, setZoomDomain] = useState(null);
@@ -116,17 +119,23 @@ export default function TrendChart({
const xDomain = zoomDomain?.x || baseXDomain; const xDomain = zoomDomain?.x || baseXDomain;
const yDomain = zoomDomain?.y || baseYDomain; const yDomain = zoomDomain?.y || baseYDomain;
const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length; const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length;
const normalizedLineWidth = Number.isFinite(Number(lineWidth)) ? Number(lineWidth) : 1.8;
useEffect(() => {
domainRef.current = { x: xDomain, y: yDomain };
}, [xDomain, yDomain]);
const mapDragToDomain = (clientX, clientY) => { const mapDragToDomain = (clientX, clientY) => {
const rect = wrapperRef.current?.getBoundingClientRect(); const rect = chartHostRef.current?.getBoundingClientRect() || wrapperRef.current?.getBoundingClientRect();
if (!rect) { if (!rect) {
return null; return null;
} }
const activeDomain = domainRef.current;
const xRatio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const xRatio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const yRatio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); const yRatio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const xValue = xDomain[0] + (xDomain[1] - xDomain[0]) * xRatio; const xValue = activeDomain.x[0] + (activeDomain.x[1] - activeDomain.x[0]) * xRatio;
const yValue = yDomain[1] - (yDomain[1] - yDomain[0]) * yRatio; const yValue = activeDomain.y[1] - (activeDomain.y[1] - activeDomain.y[0]) * yRatio;
return { return {
xValue, xValue,
@@ -145,7 +154,9 @@ export default function TrendChart({
if (!mapped) { if (!mapped) {
return; return;
} }
setDragBox({ start: mapped, end: mapped }); const nextDragBox = { start: mapped, end: mapped };
dragBoxRef.current = nextDragBox;
setDragBox(nextDragBox);
}; };
const updateDragBox = (clientX, clientY) => { const updateDragBox = (clientX, clientY) => {
@@ -153,7 +164,14 @@ export default function TrendChart({
if (!mapped) { if (!mapped) {
return; return;
} }
setDragBox((current) => current ? { ...current, end: mapped } : current); setDragBox((current) => {
if (!current) {
return current;
}
const nextDragBox = { ...current, end: mapped };
dragBoxRef.current = nextDragBox;
return nextDragBox;
});
}; };
const finalizeDragBox = (box) => { const finalizeDragBox = (box) => {
@@ -163,15 +181,18 @@ export default function TrendChart({
const deltaX = Math.abs(box.end.xPixel - box.start.xPixel); const deltaX = Math.abs(box.end.xPixel - box.start.xPixel);
const deltaY = Math.abs(box.end.yPixel - box.start.yPixel); const deltaY = Math.abs(box.end.yPixel - box.start.yPixel);
if (deltaX < 12 || deltaY < 12) { if (deltaX < 12 && deltaY < 12) {
dragBoxRef.current = null;
setDragBox(null); setDragBox(null);
return; return;
} }
const currentDomain = domainRef.current;
setZoomDomain({ setZoomDomain({
x: [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)], x: deltaX < 12 ? currentDomain.x : [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)],
y: [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)], y: deltaY < 12 ? currentDomain.y : [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)],
}); });
dragBoxRef.current = null;
setDragBox(null); setDragBox(null);
}; };
@@ -185,7 +206,7 @@ export default function TrendChart({
}; };
const handleWindowMouseUp = () => { const handleWindowMouseUp = () => {
finalizeDragBox(dragBox); finalizeDragBox(dragBoxRef.current);
}; };
window.addEventListener("mousemove", handleWindowMouseMove); window.addEventListener("mousemove", handleWindowMouseMove);
@@ -444,10 +465,11 @@ export default function TrendChart({
<Line <Line
key={entry.key} key={entry.key}
yAxisId={entry.kind === "digital" ? "digital" : "analog"} yAxisId={entry.kind === "digital" ? "digital" : "analog"}
type={entry.kind === "digital" ? "stepAfter" : "monotone"} type={entry.kind === "digital" ? "stepAfter" : "linear"}
dataKey={entry.key} dataKey={entry.key}
dot={false} dot={false}
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8} connectNulls
strokeWidth={selectedKeys.includes(entry.key) ? normalizedLineWidth + 0.55 : normalizedLineWidth}
stroke={entry.color} stroke={entry.color}
strokeOpacity={isolated ? 0.1 : 1} strokeOpacity={isolated ? 0.1 : 1}
isAnimationActive={false} isAnimationActive={false}
+501 -16
View File
@@ -1,8 +1,9 @@
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f4f6fb; --bg: #f4f6fb;
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%); --bg-accent: linear-gradient(180deg, rgba(248, 251, 255, 0.24) 0%, rgba(238, 243, 251, 0.32) 100%);
--surface: rgba(255, 255, 255, 0.96); --page-image: url("/bg-whitemode.png");
--surface: rgba(255, 255, 255, 0.91);
--surface-strong: #ffffff; --surface-strong: #ffffff;
--text: #18212f; --text: #18212f;
--muted: #667085; --muted: #667085;
@@ -23,8 +24,9 @@
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--bg: #0e1624; --bg: #0e1624;
--bg-accent: linear-gradient(180deg, #101827 0%, #0b1320 100%); --bg-accent: linear-gradient(180deg, rgba(16, 24, 39, 0.2) 0%, rgba(11, 19, 32, 0.3) 100%);
--surface: rgba(16, 24, 38, 0.96); --page-image: url("/bg-darkmode.png");
--surface: rgba(16, 24, 38, 0.92);
--surface-strong: #121b2a; --surface-strong: #121b2a;
--text: #edf2f7; --text: #edf2f7;
--muted: #94a3b8; --muted: #94a3b8;
@@ -52,7 +54,11 @@ body,
body { body {
margin: 0; margin: 0;
background: var(--bg-accent); background-color: var(--bg);
background-image: var(--bg-accent), var(--page-image);
background-position: center, center;
background-size: cover, cover;
background-attachment: fixed, fixed;
color: var(--text); color: var(--text);
font-size: 12px; font-size: 12px;
} }
@@ -525,27 +531,42 @@ span {
.user-row, .user-row,
.stat-card { .stat-card {
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.58rem; padding: 0.42rem 0.5rem;
} }
.point-copy, .point-copy,
.user-copy { .user-copy {
display: grid; display: grid;
gap: 0.06rem; gap: 0.06rem;
min-width: 0;
}
.point-item {
align-items: flex-start;
gap: 0.42rem;
}
.point-item .point-copy strong,
.point-item .point-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.selection-chips { .selection-chips {
min-height: 0; min-height: 0;
gap: 0.34rem; gap: 0.28rem;
} }
.chip { .chip {
display: grid; display: grid;
gap: 0.12rem; gap: 0.1rem;
border-left: 3px solid var(--brand-2); border-left: 2px solid var(--brand-2);
min-width: 94px; min-width: 0;
padding: 0.34rem 0.42rem; max-width: 100%;
border-radius: 12px; padding: 0.26rem 0.36rem;
border-radius: 10px;
flex: 1 1 150px;
} }
.chip strong, .chip strong,
@@ -556,8 +577,21 @@ span {
font-size: 12px; font-size: 12px;
} }
.chip strong,
.chip span { .chip span {
font-size: 11px; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip strong {
font-size: 10px;
line-height: 1.15;
}
.chip span {
font-size: 10px;
line-height: 1.15;
} }
.chart-panel { .chart-panel {
@@ -572,6 +606,8 @@ span {
width: 100%; width: 100%;
min-height: 320px; min-height: 320px;
user-select: none; user-select: none;
cursor: crosshair;
touch-action: none;
} }
.chart-wrapper.dragging { .chart-wrapper.dragging {
@@ -641,7 +677,8 @@ span {
.latest-grid { .latest-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
gap: 0.42rem;
} }
.trend-layout { .trend-layout {
@@ -674,6 +711,48 @@ span {
border-left: 4px solid var(--brand-2); border-left: 4px solid var(--brand-2);
} }
.latest-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.latest-card > div:first-child {
min-width: 0;
}
.latest-card small,
.latest-card strong,
.latest-card .value-stamp {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.latest-card small {
font-size: 9px;
}
.latest-card strong {
font-size: 11px;
line-height: 1.18;
}
.latest-card .stat-value {
justify-content: flex-end;
min-width: max-content;
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
.latest-card .stat-value span {
font-size: 10px;
}
.stat-value { .stat-value {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -791,6 +870,12 @@ span {
border: 1px solid rgba(37, 99, 235, 0.22); border: 1px solid rgba(37, 99, 235, 0.22);
} }
.warning-note {
margin: 0;
color: #c2410c;
font-size: 11px;
}
.checkbox-field { .checkbox-field {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -889,9 +974,14 @@ span {
.protocol-grid, .protocol-grid,
.inline-fields, .inline-fields,
.inline-fields.multi-line, .inline-fields.multi-line,
.trend-layout { .trend-layout,
.latest-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.latest-card .stat-value {
justify-content: flex-start;
}
} }
@@ -1065,11 +1155,88 @@ span {
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22); box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
} }
.bacnet-device-list {
display: grid;
gap: 0.35rem;
max-height: 220px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.35rem;
}
.bacnet-device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
padding: 0.42rem 0.5rem;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.bacnet-device-row:hover,
.bacnet-device-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.bacnet-device-row span {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.bacnet-device-row small {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bacnet-device-row em {
color: var(--muted);
font-style: normal;
font-size: 0.72rem;
}
.source-modal { .source-modal {
display: grid; display: grid;
gap: 0.65rem; gap: 0.65rem;
} }
.modal-status {
margin: 0;
padding: 0.55rem 0.7rem;
font-size: 0.78rem;
}
.bacnet-device-panel {
display: grid;
gap: 0.45rem;
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.55rem;
background: color-mix(in srgb, var(--surface-raised) 68%, transparent);
}
.bacnet-device-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.78rem;
}
.bacnet-device-title small {
color: var(--muted);
}
@media (max-width: 980px) { @media (max-width: 980px) {
.source-admin-top, .source-admin-top,
.source-split, .source-split,
@@ -1092,3 +1259,321 @@ span {
width: 100%; 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;
}
}
.runner-status-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.65rem;
margin-top: 0.75rem;
}
.runner-status-panel {
min-width: 0;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--panel) 92%, var(--accent) 8%);
overflow: hidden;
}
.runner-status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.46rem 0.58rem;
border-bottom: 1px solid var(--border);
}
.runner-status-header h4 {
margin: 0;
font-size: 0.78rem;
}
.runner-status-header > span {
display: inline-grid;
place-items: center;
min-width: 1.35rem;
height: 1.2rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.68rem;
font-weight: 700;
}
.runner-job-list,
.runner-queue-table {
display: grid;
gap: 0;
max-height: 230px;
overflow: auto;
}
.runner-job-row,
.runner-queue-row,
.runner-queue-head {
display: grid;
align-items: center;
gap: 0.5rem;
padding: 0.42rem 0.58rem;
}
.runner-job-row {
grid-template-columns: minmax(0, 1fr) auto;
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
}
.runner-job-row:last-child,
.runner-queue-row:last-child {
border-bottom: 0;
}
.runner-job-copy {
display: grid;
gap: 0.08rem;
min-width: 0;
}
.runner-job-copy strong,
.runner-job-copy span,
.runner-job-copy small,
.runner-queue-row strong,
.runner-queue-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runner-job-copy strong,
.runner-queue-row strong {
font-size: 0.75rem;
}
.runner-job-copy span,
.runner-job-copy small,
.runner-queue-row small {
color: var(--muted);
font-size: 0.67rem;
}
.runner-job-actions {
justify-content: flex-end;
}
.runner-queue-head,
.runner-queue-row {
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
}
.runner-queue-head {
color: var(--muted);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.runner-queue-row {
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
}
.runner-queue-row > span {
color: var(--accent);
font-size: 0.7rem;
font-weight: 700;
}
@media (max-width: 900px) {
.runner-status-grid {
grid-template-columns: 1fr;
}
.runner-job-row {
grid-template-columns: 1fr;
}
.runner-job-actions {
justify-content: flex-start;
}
}
.saved-selection-card {
min-height: 36px;
justify-content: flex-start;
padding: 0.38rem 0.55rem;
}
.saved-selection-copy {
display: flex;
align-items: center;
gap: 0.42rem;
min-width: 0;
}
.saved-selection-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.saved-selection-copy .selection-count {
flex: 0 0 auto;
padding: 0.08rem 0.32rem;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-size: 0.65rem;
font-weight: 700;
line-height: 1.2;
}