Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
+28
-9
@@ -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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
export const pool = { query };
|
||||||
+26
-11
@@ -202,18 +202,33 @@ async function ensureRolesSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function sanitizeApiSettings(body = {}) {
|
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 {
|
return {
|
||||||
mailUrl: String(body.mailUrl || "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
|
mailUrl: String(body.mailUrl ?? existing.mailUrl ?? "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
|
||||||
mailApiKey: String(body.mailApiKey || "").trim(),
|
mailApiKey: updateSecret(body, existing, "mailApiKey", "clearMailApiKey"),
|
||||||
whatsappUrl: String(body.whatsappUrl || "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
|
whatsappUrl: String(body.whatsappUrl ?? existing.whatsappUrl ?? "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
|
||||||
whatsappApiKey: String(body.whatsappApiKey || "").trim(),
|
whatsappApiKey: updateSecret(body, existing, "whatsappApiKey", "clearWhatsappApiKey"),
|
||||||
voipUrl: String(body.voipUrl || "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
|
voipUrl: String(body.voipUrl ?? existing.voipUrl ?? "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
|
||||||
voipApiKey: String(body.voipApiKey || "").trim(),
|
voipApiKey: updateSecret(body, existing, "voipApiKey", "clearVoipApiKey"),
|
||||||
updatedAt: new Date().toISOString(),
|
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) {
|
function normalizeRunnerTime(value) {
|
||||||
const raw = String(value ?? "").trim().replace(/\s*uhr$/i, "").replace(".", ":");
|
const raw = String(value ?? "").trim().replace(/\s*uhr$/i, "").replace(".", ":");
|
||||||
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
|
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
@@ -834,9 +849,8 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res)
|
|||||||
|
|
||||||
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
|
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
|
||||||
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
||||||
const settings = sanitizeApiSettings(current);
|
|
||||||
res.json({
|
res.json({
|
||||||
settings,
|
settings: publicApiSettings(current),
|
||||||
mailConfigured: Boolean(current.mailApiKey),
|
mailConfigured: Boolean(current.mailApiKey),
|
||||||
whatsappConfigured: Boolean(current.whatsappApiKey),
|
whatsappConfigured: Boolean(current.whatsappApiKey),
|
||||||
voipConfigured: Boolean(current.voipApiKey),
|
voipConfigured: Boolean(current.voipApiKey),
|
||||||
@@ -844,10 +858,11 @@ app.get("/api/integrations", authenticateToken, requirePermission("manage_integr
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
|
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
|
||||||
const payload = sanitizeApiSettings(req.body || {});
|
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
||||||
|
const payload = sanitizeApiSettings(req.body || {}, current);
|
||||||
await redis.set("integration:api", JSON.stringify(payload));
|
await redis.set("integration:api", JSON.stringify(payload));
|
||||||
res.json({
|
res.json({
|
||||||
settings: payload,
|
settings: publicApiSettings(payload),
|
||||||
mailConfigured: Boolean(payload.mailApiKey),
|
mailConfigured: Boolean(payload.mailApiKey),
|
||||||
whatsappConfigured: Boolean(payload.whatsappApiKey),
|
whatsappConfigured: Boolean(payload.whatsappApiKey),
|
||||||
voipConfigured: Boolean(payload.voipApiKey),
|
voipConfigured: Boolean(payload.voipApiKey),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"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": {
|
"dependencies": {
|
||||||
"bacstack": "^0.0.1-beta.14"
|
"bacstack": "^0.0.1-beta.14"
|
||||||
|
|||||||
@@ -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
@@ -4,12 +4,16 @@
|
|||||||
"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": {
|
"dependencies": {
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"node-opcua": "latest",
|
"node-opcua": "2.184.4",
|
||||||
"node-bacnet": "latest",
|
"node-bacnet": "0.2.4",
|
||||||
"knx": "latest"
|
"knx": "2.5.4"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"binary-parser": "2.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-11
@@ -1,5 +1,5 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
@@ -15,6 +15,7 @@ const bacnetCollectorUrl = String(process.env.BACNET_COLLECTOR_URL || "").replac
|
|||||||
const LOCAL_POINT_LIMIT = 2000;
|
const LOCAL_POINT_LIMIT = 2000;
|
||||||
let transactionId = 1;
|
let transactionId = 1;
|
||||||
let polling = false;
|
let polling = false;
|
||||||
|
let lastKnownGoodStore = null;
|
||||||
|
|
||||||
const db = mysql.createPool({
|
const db = mysql.createPool({
|
||||||
host: process.env.DB_HOST || "localhost",
|
host: process.env.DB_HOST || "localhost",
|
||||||
@@ -38,13 +39,7 @@ function baseStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStore() {
|
function normalizeStore(parsed) {
|
||||||
if (!existsSync(storeFile)) {
|
|
||||||
return baseStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(readFileSync(storeFile, "utf8"));
|
|
||||||
return {
|
return {
|
||||||
...baseStore(),
|
...baseStore(),
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -52,13 +47,33 @@ function readStore() {
|
|||||||
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
||||||
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
|
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
|
||||||
};
|
};
|
||||||
} catch {
|
}
|
||||||
return baseStore();
|
|
||||||
|
function readStore() {
|
||||||
|
if (!existsSync(storeFile)) {
|
||||||
|
return structuredClone(lastKnownGoodStore || baseStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = normalizeStore(JSON.parse(readFileSync(storeFile, "utf8")));
|
||||||
|
lastKnownGoodStore = store;
|
||||||
|
return structuredClone(store);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
|
||||||
|
return structuredClone(lastKnownGoodStore || baseStore());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStore(payload) {
|
function writeStore(payload) {
|
||||||
writeFileSync(storeFile, JSON.stringify(payload, null, 2), "utf8");
|
const temporaryFile = `${storeFile}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
try {
|
||||||
|
writeFileSync(temporaryFile, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
renameSync(temporaryFile, storeFile);
|
||||||
|
lastKnownGoodStore = normalizeStore(payload);
|
||||||
|
} catch (error) {
|
||||||
|
try { unlinkSync(temporaryFile); } catch {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function touchStore(mutator) {
|
function touchStore(mutator) {
|
||||||
|
|||||||
+28
-10
@@ -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:
|
||||||
@@ -101,6 +112,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
bacnet-collector:
|
bacnet-collector:
|
||||||
condition: service_started
|
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:
|
symcon-collector:
|
||||||
build:
|
build:
|
||||||
@@ -136,6 +152,11 @@ services:
|
|||||||
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
|
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
|
||||||
BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120}
|
BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120}
|
||||||
network_mode: "host"
|
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:
|
runner:
|
||||||
build:
|
build:
|
||||||
@@ -143,7 +164,7 @@ services:
|
|||||||
container_name: seltd-runner
|
container_name: seltd-runner
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
|
||||||
DB_HOST: mariadb
|
DB_HOST: mariadb
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_NAME: wago
|
DB_NAME: wago
|
||||||
@@ -197,6 +218,3 @@ volumes:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|||||||
+3
-2
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "seltd-runner",
|
"name": "seltd-runner",
|
||||||
"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": {
|
"dependencies": {
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"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": {
|
"dependencies": {
|
||||||
"mysql2": "^3.12.0"
|
"mysql2": "^3.12.0"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const maxConcurrency = Math.max(1, Math.min(16, Number(process.env.SYMCON_MAX_CO
|
|||||||
const sourcePollState = new Map();
|
const sourcePollState = new Map();
|
||||||
const valueCache = new Map();
|
const valueCache = new Map();
|
||||||
let polling = false;
|
let polling = false;
|
||||||
|
let lastKnownGoodStore = { sources: [], datapoints: [] };
|
||||||
|
|
||||||
const db = mysql.createPool({
|
const db = mysql.createPool({
|
||||||
host: process.env.DB_HOST || "mariadb",
|
host: process.env.DB_HOST || "mariadb",
|
||||||
@@ -67,17 +68,18 @@ async function updateWorkerState(source, status, error = "") {
|
|||||||
|
|
||||||
function readStore() {
|
function readStore() {
|
||||||
if (!existsSync(sourcesFile)) {
|
if (!existsSync(sourcesFile)) {
|
||||||
return { sources: [], datapoints: [] };
|
return structuredClone(lastKnownGoodStore);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
|
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
|
||||||
return {
|
lastKnownGoodStore = {
|
||||||
sources: Array.isArray(store.sources) ? store.sources : [],
|
sources: Array.isArray(store.sources) ? store.sources : [],
|
||||||
datapoints: Array.isArray(store.datapoints) ? store.datapoints : [],
|
datapoints: Array.isArray(store.datapoints) ? store.datapoints : [],
|
||||||
};
|
};
|
||||||
|
return structuredClone(lastKnownGoodStore);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Quellenkonfiguration konnte nicht gelesen werden:", error.message);
|
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
|
||||||
return { sources: [], datapoints: [] };
|
return structuredClone(lastKnownGoodStore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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"]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
+38
-11
@@ -741,7 +741,8 @@ export default function App() {
|
|||||||
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
|
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
|
||||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null, ipsymcon: null });
|
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null, ipsymcon: null });
|
||||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||||
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
const [integrationSettings, setIntegrationSettings] = useState({ mailUrl: "https://mailapi.se-inno.de", whatsappUrl: "https://whatsappapi.se-inno.de", voipUrl: "https://voipapi.se-inno.de", mailApiKey: "", whatsappApiKey: "", voipApiKey: "" });
|
||||||
|
const [integrationStatus, setIntegrationStatus] = useState({ mailConfigured: false, whatsappConfigured: false, voipConfigured: false });
|
||||||
const [runnerJobs, setRunnerJobs] = useState([]);
|
const [runnerJobs, setRunnerJobs] = useState([]);
|
||||||
const [runnerQueue, setRunnerQueue] = useState([]);
|
const [runnerQueue, setRunnerQueue] = useState([]);
|
||||||
const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
||||||
@@ -1137,12 +1138,16 @@ export default function App() {
|
|||||||
}, [canEditAliases, canManageSources, token]);
|
}, [canEditAliases, canManageSources, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !canManageIntegrations) {
|
if (!token || !canManageIntegrations) {
|
||||||
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
setIntegrationSettings({ mailUrl: "https://mailapi.se-inno.de", whatsappUrl: "https://whatsappapi.se-inno.de", voipUrl: "https://voipapi.se-inno.de", mailApiKey: "", whatsappApiKey: "", voipApiKey: "" });
|
||||||
|
setIntegrationStatus({ mailConfigured: false, whatsappConfigured: false, voipConfigured: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.getIntegrations(token)
|
api.getIntegrations(token)
|
||||||
.then((result) => setIntegrationSettings(result.settings || { mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }))
|
.then((result) => {
|
||||||
|
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", voipApiKey: "", ...(result.settings || {}) });
|
||||||
|
setIntegrationStatus({ mailConfigured: Boolean(result.mailConfigured), whatsappConfigured: Boolean(result.whatsappConfigured), voipConfigured: Boolean(result.voipConfigured) });
|
||||||
|
})
|
||||||
.catch((error) => setBootError(error.message));
|
.catch((error) => setBootError(error.message));
|
||||||
}, [canManageIntegrations, token]);
|
}, [canManageIntegrations, token]);
|
||||||
|
|
||||||
@@ -2086,17 +2091,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyIntegrationResult = (result) => {
|
||||||
|
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", voipApiKey: "", ...(result.settings || {}) });
|
||||||
|
setIntegrationStatus({ mailConfigured: Boolean(result.mailConfigured), whatsappConfigured: Boolean(result.whatsappConfigured), voipConfigured: Boolean(result.voipConfigured) });
|
||||||
|
};
|
||||||
|
|
||||||
const saveIntegrationSettings = async () => {
|
const saveIntegrationSettings = async () => {
|
||||||
resetFlash();
|
resetFlash();
|
||||||
try {
|
try {
|
||||||
const result = await api.saveIntegrations(token, integrationSettings);
|
const result = await api.saveIntegrations(token, integrationSettings);
|
||||||
setIntegrationSettings(result.settings || integrationSettings);
|
applyIntegrationResult(result);
|
||||||
setSuccessMessage("API-Verbindungen gespeichert.");
|
setSuccessMessage("API-Verbindungen gespeichert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBootError(error.message);
|
setBootError(error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearIntegrationKey = async (channel) => {
|
||||||
|
const labels = { mail: "Mail", whatsapp: "WhatsApp", voip: "Telefon" };
|
||||||
|
if (!window.confirm(`${labels[channel]} API-Key wirklich entfernen?`)) return;
|
||||||
|
resetFlash();
|
||||||
|
try {
|
||||||
|
const clearField = channel === "mail" ? "clearMailApiKey" : channel === "whatsapp" ? "clearWhatsappApiKey" : "clearVoipApiKey";
|
||||||
|
const result = await api.saveIntegrations(token, { ...integrationSettings, [clearField]: true });
|
||||||
|
applyIntegrationResult(result);
|
||||||
|
setSuccessMessage(`${labels[channel]} API-Key entfernt.`);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetRunnerDraft = () => {
|
const resetRunnerDraft = () => {
|
||||||
setRunnerDraft({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
setRunnerDraft({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
||||||
};
|
};
|
||||||
@@ -3484,18 +3508,21 @@ export default function App() {
|
|||||||
<div className="form-grid compact-forms">
|
<div className="form-grid compact-forms">
|
||||||
<label>
|
<label>
|
||||||
<span>Mail API-Key</span>
|
<span>Mail API-Key</span>
|
||||||
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
<input type="password" autoComplete="new-password" value={integrationSettings.mailApiKey || ""} placeholder={integrationStatus.mailConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
||||||
<small className="muted">{integrationSettings.mailApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
<small className="muted">{integrationStatus.mailConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
|
{integrationStatus.mailConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("mail")}>Key entfernen</button> : null}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>WhatsApp API-Key</span>
|
<span>WhatsApp API-Key</span>
|
||||||
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
<input type="password" autoComplete="new-password" value={integrationSettings.whatsappApiKey || ""} placeholder={integrationStatus.whatsappConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
||||||
<small className="muted">{integrationSettings.whatsappApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
<small className="muted">{integrationStatus.whatsappConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
|
{integrationStatus.whatsappConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("whatsapp")}>Key entfernen</button> : null}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Telefon API-Key</span>
|
<span>Telefon API-Key</span>
|
||||||
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
<input type="password" autoComplete="new-password" value={integrationSettings.voipApiKey || ""} placeholder={integrationStatus.voipConfigured ? "Key gespeichert - nur zum Ersetzen eingeben" : "API-Key eingeben"} onChange={(event) => setIntegrationSettings((current) => ({ ...current, voipApiKey: event.target.value }))} />
|
||||||
<small className="muted">{integrationSettings.phoneApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
<small className="muted">{integrationStatus.voipConfigured ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
|
{integrationStatus.voipConfigured ? <button className="ghost-button compact-button danger" type="button" onClick={() => clearIntegrationKey("voip")}>Key entfernen</button> : null}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
||||||
@@ -3554,7 +3581,7 @@ export default function App() {
|
|||||||
<span>aktiv</span>
|
<span>aktiv</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="checkbox-field compact-check">
|
<label className="checkbox-field compact-check">
|
||||||
<input type="checkbox" checked={runnerDraft.mailEnabled === true} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailEnabled: event.target.checked }))} disabled={!integrationSettings.mailEnabled && !integrationSettings.mailApiKey} />
|
<input type="checkbox" checked={runnerDraft.mailEnabled === true} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailEnabled: event.target.checked }))} disabled={!integrationStatus.mailConfigured && !integrationSettings.mailApiKey} />
|
||||||
<span>CSV per Mail verschicken</span>
|
<span>CSV per Mail verschicken</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
Reference in New Issue
Block a user