Refactor code structure for improved readability and maintainability

This commit is contained in:
jhartworks
2026-09-16 12:41:24 +02:00
parent bfce688f6a
commit 4d8b07fc68
22 changed files with 2670 additions and 96 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env and replace all placeholders before production use.
JWT_SECRET=replace-with-a-long-random-secret
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-strong-admin-password
ADMIN_NAME=System Admin
# Existing controllers may require these legacy credentials.
MARIADB_ROOT_PASSWORD=SE3112
MARIADB_PASSWORD=3112
DB_PASSWORD_FALLBACK=root
REDIS_PASSWORD=replace-with-a-long-random-redis-password
CORS_ORIGIN=http://localhost:5173
VITE_API_PORT=18080
+2 -1
View File
@@ -1,7 +1,8 @@
node_modules node_modules
dist dist
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
.scratch/runtime .scratch/runtime
data/ data/
web/src web/src
.env
+24 -1
View File
@@ -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
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY src ./src COPY src ./src
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"start": "node src/index.js" "start": "node src/index.js",
"check": "node --check src/index.js && node --check src/db.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
+28 -9
View File
@@ -1,4 +1,4 @@
import mysql from "mysql2/promise"; import mysql from "mysql2/promise";
import { config } from "./config.js"; import { config } from "./config.js";
function createPool(password) { function createPool(password) {
@@ -24,7 +24,15 @@ function isAccessDenied(error) {
return error?.code === "ER_ACCESS_DENIED_ERROR"; return error?.code === "ER_ACCESS_DENIED_ERROR";
} }
async function query(sql, values) { function isTransientConnectionError(error) {
return ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "PROTOCOL_CONNECTION_LOST"].includes(error?.code);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function queryWithCredentials(sql, values) {
try { try {
return await activePool.query(sql, values); return await activePool.query(sql, values);
} catch (error) { } catch (error) {
@@ -33,21 +41,18 @@ async function query(sql, values) {
} }
for (const password of candidatePasswords) { for (const password of candidatePasswords) {
if (password === activePassword) { if (password === activePassword) continue;
continue;
}
const fallbackPool = createPool(password); const fallbackPool = createPool(password);
try { try {
const result = await fallbackPool.query(sql, values); const result = await fallbackPool.query(sql, values);
const previousPool = activePool;
activePool = fallbackPool; activePool = fallbackPool;
activePassword = password; activePassword = password;
previousPool.end().catch(() => undefined);
return result; return result;
} catch (fallbackError) { } catch (fallbackError) {
await fallbackPool.end().catch(() => undefined); await fallbackPool.end().catch(() => undefined);
if (!isAccessDenied(fallbackError)) { if (!isAccessDenied(fallbackError)) throw fallbackError;
throw fallbackError;
}
} }
} }
@@ -55,4 +60,18 @@ async function query(sql, values) {
} }
} }
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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -1,9 +1,9 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY src ./src COPY src ./src
RUN mkdir -p /app/data RUN mkdir -p /app/data
+2433
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -24,14 +24,14 @@ services:
image: redis:7.4-alpine image: redis:7.4-alpine
container_name: seltd-redis container_name: seltd-redis
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "/usr/local/etc/redis/redis.conf"] environment:
ports: REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}
- "6379:6379" command: ["sh", "-c", "exec redis-server /usr/local/etc/redis/redis.conf --requirepass \"$$REDIS_PASSWORD\""]
volumes: volumes:
- redis_data:/data - redis_data:/data
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -49,7 +49,7 @@ services:
DB_USER: root DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root} DB_PASSWORD_FALLBACK: ${DB_PASSWORD_FALLBACK:-root}
REDIS_URL: redis://redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in .env}@redis:6379/0
JWT_SECRET: ${JWT_SECRET:-change-me} JWT_SECRET: ${JWT_SECRET:-change-me}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
@@ -63,6 +63,11 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
web: web:
build: build:
@@ -74,7 +79,13 @@ services:
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:
- api api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ > /dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
collector: collector:
build: build:
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
+2 -1
View File
@@ -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"
+6 -4
View File
@@ -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
View File
@@ -1,13 +1,12 @@
FROM node:20-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
EXPOSE 5173 EXPOSE 5173
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 5173", "dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173" "preview": "vite preview --host 0.0.0.0 --port 4173",
"check": "vite build"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
+38 -11
View File
@@ -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>