Compare commits
7
Commits
2c055fcb63
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5f86452f | ||
|
|
4d8b07fc68 | ||
|
|
bfce688f6a | ||
|
|
986a43d5a5 | ||
|
|
19d27f9de9 | ||
|
|
f247d2827d | ||
|
|
7a1fe401f2 |
@@ -1,9 +0,0 @@
|
|||||||
JWT_SECRET=change-me
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_EMAIL=admin@example.com
|
|
||||||
ADMIN_PASSWORD=SE3112
|
|
||||||
ADMIN_NAME=System Admin
|
|
||||||
MARIADB_ROOT_PASSWORD=SE3112
|
|
||||||
DB_PASSWORD_FALLBACK=root
|
|
||||||
CORS_ORIGIN=*
|
|
||||||
VITE_API_PORT=18080
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# SE Local Trenddata
|
# SE Local Trenddata
|
||||||
|
|
||||||
SE Local Trenddata ist eine webbasierte Trend-, Dashboard- und Quellenverwaltung fuer lokale Anlagen- und WAGO-Daten. Das Projekt besteht aus einem React-Frontend, einer Node.js/Express-API, MariaDB, Redis und einem Collector-Service fuer Protokollquellen wie Modbus TCP, OPC UA, BACnet IP und KNX IP.
|
SE Local Trenddata ist eine webbasierte Trend-, Dashboard- und Quellenverwaltung fuer lokale Anlagen- und WAGO-Daten. Das Projekt besteht aus einem React-Frontend, einer Node.js/Express-API, MariaDB, Redis und einem Collector-Service fuer Protokollquellen wie Modbus TCP, OPC UA, BACnet IP und KNX IP.
|
||||||
|
|
||||||
@@ -671,3 +671,26 @@ Vor produktiver Nutzung:
|
|||||||
- Fuer vollstaendige Protokollintegration muessen je nach Feldbus noch Details wie Adressierung, Datentyp, Byteorder und Authentifizierung sauber projektspezifisch gepflegt werden.
|
- Fuer vollstaendige Protokollintegration muessen je nach Feldbus noch Details wie Adressierung, Datentyp, Byteorder und Authentifizierung sauber projektspezifisch gepflegt werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
docker compose exec redis redis-cli -a $env:REDIS_PASSWORD ping
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Dienste können vor einem Deployment statisch geprüft werden:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm --prefix api run check
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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, () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 18111
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+2433
File diff suppressed because it is too large
Load Diff
+17
-13
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+25
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user