Compare commits
3
Commits
986a43d5a5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5f86452f | ||
|
|
4d8b07fc68 | ||
|
|
bfce688f6a |
@@ -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
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
.scratch/runtime
|
||||
data/
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"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";
|
||||
|
||||
function createPool(password) {
|
||||
@@ -24,7 +24,15 @@ function isAccessDenied(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 {
|
||||
return await activePool.query(sql, values);
|
||||
} catch (error) {
|
||||
@@ -33,21 +41,18 @@ async function query(sql, values) {
|
||||
}
|
||||
|
||||
for (const password of candidatePasswords) {
|
||||
if (password === activePassword) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (password === activePassword) continue;
|
||||
const fallbackPool = createPool(password);
|
||||
try {
|
||||
const result = await fallbackPool.query(sql, values);
|
||||
const previousPool = activePool;
|
||||
activePool = fallbackPool;
|
||||
activePassword = password;
|
||||
previousPool.end().catch(() => undefined);
|
||||
return result;
|
||||
} catch (fallbackError) {
|
||||
await fallbackPool.end().catch(() => undefined);
|
||||
if (!isAccessDenied(fallbackError)) {
|
||||
throw fallbackError;
|
||||
}
|
||||
if (!isAccessDenied(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 };
|
||||
+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 {
|
||||
mailUrl: String(body.mailUrl || "https://mailapi.se-inno.de").trim() || "https://mailapi.se-inno.de",
|
||||
mailApiKey: String(body.mailApiKey || "").trim(),
|
||||
whatsappUrl: String(body.whatsappUrl || "https://whatsappapi.se-inno.de").trim() || "https://whatsappapi.se-inno.de",
|
||||
whatsappApiKey: String(body.whatsappApiKey || "").trim(),
|
||||
voipUrl: String(body.voipUrl || "https://voipapi.se-inno.de").trim() || "https://voipapi.se-inno.de",
|
||||
voipApiKey: String(body.voipApiKey || "").trim(),
|
||||
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})$/);
|
||||
@@ -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) => {
|
||||
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
||||
const settings = sanitizeApiSettings(current);
|
||||
res.json({
|
||||
settings,
|
||||
settings: publicApiSettings(current),
|
||||
mailConfigured: Boolean(current.mailApiKey),
|
||||
whatsappConfigured: Boolean(current.whatsappApiKey),
|
||||
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) => {
|
||||
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));
|
||||
res.json({
|
||||
settings: payload,
|
||||
settings: publicApiSettings(payload),
|
||||
mailConfigured: Boolean(payload.mailApiKey),
|
||||
whatsappConfigured: Boolean(payload.whatsappApiKey),
|
||||
voipConfigured: Boolean(payload.voipApiKey),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
"start": "node src/index.js",
|
||||
"check": "node --check src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bacstack": "^0.0.1-beta.14"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY src ./src
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.12.0",
|
||||
"node-opcua": "latest",
|
||||
"node-bacnet": "latest",
|
||||
"knx": "latest"
|
||||
}
|
||||
"name": "seltd-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",
|
||||
"node-opcua": "2.184.4",
|
||||
"node-bacnet": "0.2.4",
|
||||
"knx": "2.5.4"
|
||||
},
|
||||
"overrides": {
|
||||
"binary-parser": "2.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-14
@@ -1,5 +1,5 @@
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { Buffer } from "node:buffer";
|
||||
@@ -15,6 +15,7 @@ const bacnetCollectorUrl = String(process.env.BACNET_COLLECTOR_URL || "").replac
|
||||
const LOCAL_POINT_LIMIT = 2000;
|
||||
let transactionId = 1;
|
||||
let polling = false;
|
||||
let lastKnownGoodStore = null;
|
||||
|
||||
const db = mysql.createPool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
@@ -38,27 +39,41 @@ function baseStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStore(parsed) {
|
||||
return {
|
||||
...baseStore(),
|
||||
...parsed,
|
||||
scans: { ...baseStore().scans, ...(parsed.scans || {}) },
|
||||
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
||||
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
|
||||
};
|
||||
}
|
||||
|
||||
function readStore() {
|
||||
if (!existsSync(storeFile)) {
|
||||
return baseStore();
|
||||
return structuredClone(lastKnownGoodStore || baseStore());
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(storeFile, "utf8"));
|
||||
return {
|
||||
...baseStore(),
|
||||
...parsed,
|
||||
scans: { ...baseStore().scans, ...(parsed.scans || {}) },
|
||||
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
||||
datapoints: Array.isArray(parsed.datapoints) ? parsed.datapoints : [],
|
||||
};
|
||||
} catch {
|
||||
return baseStore();
|
||||
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) {
|
||||
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) {
|
||||
@@ -889,11 +904,28 @@ async function requestIpsymconApi(source, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function getIpsymconLinkedName(variable, variableId) {
|
||||
const sources = Array.isArray(variable?.sources) ? variable.sources : [];
|
||||
for (const source of sources) {
|
||||
const links = Array.isArray(source?.link_chain) ? source.link_chain : [];
|
||||
for (const link of [...links].reverse()) {
|
||||
if (Number(link?.target_id) === variableId) {
|
||||
const linkName = normalizeText(link?.name);
|
||||
if (linkName) return linkName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapIpsymconVariable(source, variable, categoryId, index) {
|
||||
const variableId = Number(variable?.id);
|
||||
const variableType = normalizeText(variable?.variable_type || variable?.type || "");
|
||||
const kind = variableType === "boolean" || typeof variable?.value === "boolean" ? "digital" : "analog";
|
||||
const name = normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`) || `Variable ${variableId}`;
|
||||
// A named variable link is the operator-facing label and takes precedence over its target's name.
|
||||
const name = getIpsymconLinkedName(variable, variableId)
|
||||
|| normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`)
|
||||
|| `Variable ${variableId}`;
|
||||
return {
|
||||
id: `${source.id}::ipsymcon::${variableId}`,
|
||||
protocol: "ipsymcon",
|
||||
|
||||
+28
-10
@@ -24,14 +24,14 @@ services:
|
||||
image: redis:7.4-alpine
|
||||
container_name: seltd-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}
|
||||
command: ["sh", "-c", "exec redis-server /usr/local/etc/redis/redis.conf --requirepass \"$$REDIS_PASSWORD\""]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
DB_USER: root
|
||||
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||
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}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||
@@ -63,6 +63,11 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
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:
|
||||
build:
|
||||
@@ -74,7 +79,13 @@ services:
|
||||
ports:
|
||||
- "5173:5173"
|
||||
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:
|
||||
build:
|
||||
@@ -101,6 +112,11 @@ services:
|
||||
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:
|
||||
@@ -136,6 +152,11 @@ services:
|
||||
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:
|
||||
@@ -143,7 +164,7 @@ services:
|
||||
container_name: seltd-runner
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_URL: redis://redis:6379
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: 3306
|
||||
DB_NAME: wago
|
||||
@@ -197,6 +218,3 @@ volumes:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
{
|
||||
{
|
||||
"name": "seltd-runner",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
"start": "node src/index.js",
|
||||
"check": "node --check src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^5.6.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
"start": "node src/index.js",
|
||||
"check": "node --check src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"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 valueCache = new Map();
|
||||
let polling = false;
|
||||
let lastKnownGoodStore = { sources: [], datapoints: [] };
|
||||
|
||||
const db = mysql.createPool({
|
||||
host: process.env.DB_HOST || "mariadb",
|
||||
@@ -67,17 +68,18 @@ async function updateWorkerState(source, status, error = "") {
|
||||
|
||||
function readStore() {
|
||||
if (!existsSync(sourcesFile)) {
|
||||
return { sources: [], datapoints: [] };
|
||||
return structuredClone(lastKnownGoodStore);
|
||||
}
|
||||
try {
|
||||
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
|
||||
return {
|
||||
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:", error.message);
|
||||
return { sources: [], datapoints: [] };
|
||||
console.warn("Quellenkonfiguration konnte nicht gelesen werden, letzter gültiger Stand wird verwendet:", error.message);
|
||||
return structuredClone(lastKnownGoodStore);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -1,13 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"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": {
|
||||
"cors": "^2.8.6",
|
||||
|
||||
+38
-11
@@ -741,7 +741,8 @@ export default function App() {
|
||||
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
|
||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null, ipsymcon: 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 [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." });
|
||||
@@ -1137,12 +1138,16 @@ export default function App() {
|
||||
}, [canEditAliases, canManageSources, token]);
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}, [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 () => {
|
||||
resetFlash();
|
||||
try {
|
||||
const result = await api.saveIntegrations(token, integrationSettings);
|
||||
setIntegrationSettings(result.settings || integrationSettings);
|
||||
applyIntegrationResult(result);
|
||||
setSuccessMessage("API-Verbindungen gespeichert.");
|
||||
} catch (error) {
|
||||
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 = () => {
|
||||
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">
|
||||
<label>
|
||||
<span>Mail API-Key</span>
|
||||
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.mailApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
<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">{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>
|
||||
<span>WhatsApp API-Key</span>
|
||||
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.whatsappApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
<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">{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>
|
||||
<span>Telefon API-Key</span>
|
||||
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.phoneApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
<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">{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>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
Reference in New Issue
Block a user