Add update script, enhance admin features, and improve styles

- Created a PowerShell script (update.ps1) for automating the process of pulling the latest repository changes, stopping, building, and starting the Docker Compose stack.
- Updated App.jsx to include new admin sections for alarms and integrations, added state management for runner jobs, and enhanced permission checks.
- Extended the API module (api.js) with new endpoints for managing integrations and runner jobs.
- Improved CSS styles for a more compact and responsive layout in the admin interface, focusing on source and datapoint cards.
This commit is contained in:
jhartworks
2026-07-09 15:24:58 +02:00
parent f247d2827d
commit 19d27f9de9
14 changed files with 1077 additions and 54 deletions
+98
View File
@@ -132,6 +132,9 @@ const PERMISSION_CATALOG = [
{ id: "manage_users", label: "Benutzer verwalten" }, { id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" }, { id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Treiber-Debug anzeigen" }, { id: "view_debug", label: "Treiber-Debug anzeigen" },
{ id: "manage_alarms", label: "Alarme verwalten" },
{ id: "manage_integrations", label: "API-Verbindungen verwalten" },
{ id: "manage_runner", label: "CSV-Runner verwalten" },
]; ];
async function getUserPermissions(roleName) { async function getUserPermissions(roleName) {
@@ -198,6 +201,51 @@ async function ensureRolesSchema() {
} catch {} } catch {}
} }
function sanitizeApiSettings(body = {}) {
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(),
updatedAt: new Date().toISOString(),
};
}
function sanitizeRunnerJob(body = {}, existing = {}) {
const time = String(body.time ?? existing.time ?? "06:00").trim();
if (!/^\d{2}:\d{2}$/.test(time)) {
throw new Error("Uhrzeit bitte im Format HH:MM angeben.");
}
return {
...existing,
id: existing.id || body.id || `job-${Date.now()}`,
name: String(body.name ?? existing.name ?? "CSV Export").trim() || "CSV Export",
selectionId: String(body.selectionId ?? existing.selectionId ?? "").trim(),
enabled: typeof body.enabled === "boolean" ? body.enabled : Boolean(existing.enabled ?? true),
time,
rangeHours: Number(body.rangeHours ?? existing.rangeHours ?? 24) || 24,
delimiter: String(body.delimiter ?? existing.delimiter ?? ";").replace("\\t", "\t") || ";",
mailEnabled: typeof body.mailEnabled === "boolean" ? body.mailEnabled : Boolean(existing.mailEnabled),
mailTo: String(body.mailTo ?? existing.mailTo ?? "").trim(),
mailSubject: String(body.mailSubject ?? existing.mailSubject ?? "SE-LTD CSV Export").trim(),
mailMessage: String(body.mailMessage ?? existing.mailMessage ?? "Automatischer CSV Export aus SE Local Trenddata.").trim(),
updatedAt: new Date().toISOString(),
createdAt: existing.createdAt || new Date().toISOString(),
};
}
async function listRunnerJobs() {
const ids = await redis.smembers("runner:job:ids");
const jobs = await Promise.all(ids.map(async (id) => {
const raw = await redis.get(`runner:job:${id}`);
const result = await redis.get(`runner:last-result:${id}`);
return raw ? { ...JSON.parse(raw), lastResult: result ? JSON.parse(result) : null } : null;
}));
return jobs.filter(Boolean).sort((left, right) => String(left.time).localeCompare(String(right.time)));
}
async function resolveSelectionPoints(body, user) { async function resolveSelectionPoints(body, user) {
if (body.selectionId) { if (body.selectionId) {
const selection = await getSelection(redis, body.selectionId); const selection = await getSelection(redis, body.selectionId);
@@ -752,6 +800,51 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res)
res.json({ widgets: results }); res.json({ widgets: results });
})); }));
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
const current = JSON.parse(await redis.get("integration:api") || "{}");
res.json({
...sanitizeApiSettings(current),
mailConfigured: Boolean(current.mailApiKey),
whatsappConfigured: Boolean(current.whatsappApiKey),
voipConfigured: Boolean(current.voipApiKey),
});
}));
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
const payload = sanitizeApiSettings(req.body || {});
await redis.set("integration:api", JSON.stringify(payload));
res.json({ ...payload, mailConfigured: Boolean(payload.mailApiKey), whatsappConfigured: Boolean(payload.whatsappApiKey), voipConfigured: Boolean(payload.voipApiKey) });
}));
app.get("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (_req, res) => {
res.json({ jobs: await listRunnerJobs() });
}));
app.post("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
const job = sanitizeRunnerJob(req.body || {});
if (!job.selectionId) return res.status(400).json({ error: "Selection ist erforderlich." });
await redis.set(`runner:job:${job.id}`, JSON.stringify(job));
await redis.sadd("runner:job:ids", job.id);
res.status(201).json(job);
}));
app.patch("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
const id = req.params.jobId;
const existing = JSON.parse(await redis.get(`runner:job:${id}`) || "null");
if (!existing) return res.status(404).json({ error: "Runner-Job nicht gefunden." });
const job = sanitizeRunnerJob(req.body || {}, existing);
await redis.set(`runner:job:${id}`, JSON.stringify(job));
await redis.sadd("runner:job:ids", id);
res.json(job);
}));
app.delete("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
const id = req.params.jobId;
await redis.del(`runner:job:${id}`, `runner:last:${id}`, `runner:last-result:${id}`);
await redis.srem("runner:job:ids", id);
res.json({ ok: true });
}));
app.get("/api/preferences", authenticateToken, wrap(async (req, res) => { app.get("/api/preferences", authenticateToken, wrap(async (req, res) => {
const preferences = await getPreferences(redis, req.user.sub); const preferences = await getPreferences(redis, req.user.sub);
res.json(preferences); res.json(preferences);
@@ -781,3 +874,8 @@ app.listen(config.port, () => {
+6 -3
View File
@@ -13,13 +13,13 @@ export const DEFAULT_ROLE_DEFINITIONS = [
{ {
name: "technician", name: "technician",
label: "Techniker", label: "Techniker",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug"], permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug", "manage_alarms"],
isSystem: true, isSystem: true,
}, },
{ {
name: "admin", name: "admin",
label: "Admin", label: "Admin",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug"], permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug", "manage_integrations", "manage_runner", "manage_alarms"],
isSystem: true, isSystem: true,
}, },
]; ];
@@ -104,7 +104,7 @@ async function ensureDefaultRoles(redis) {
...(existing || {}), ...(existing || {}),
name: definition.name, name: definition.name,
label: existing?.label || definition.label, label: existing?.label || definition.label,
permissions: Array.isArray(existing?.permissions) && existing.permissions.length ? existing.permissions : definition.permissions, permissions: Array.from(new Set([...(Array.isArray(existing?.permissions) ? existing.permissions : []), ...definition.permissions])),
isSystem: true, isSystem: true,
}); });
await redis.set(key, JSON.stringify(payload)); await redis.set(key, JSON.stringify(payload));
@@ -286,3 +286,6 @@ export async function savePreferences(redis, userId, preferences) {
await redis.set(`preferences:${userId}`, JSON.stringify(payload)); await redis.set(`preferences:${userId}`, JSON.stringify(payload));
return payload; return payload;
} }
+57 -17
View File
@@ -120,16 +120,19 @@ function getBroadcastTargets(routing, body = {}) {
if (!isAutoBroadcast(configured)) { if (!isAutoBroadcast(configured)) {
targets.push(configured); targets.push(configured);
} }
for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) { const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets);
for (const subnet of configuredSubnets) {
const broadcast = cidrBroadcast(subnet); const broadcast = cidrBroadcast(subnet);
if (broadcast) targets.push(broadcast); if (broadcast) targets.push(broadcast);
} }
for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { if (!configuredSubnets.length) {
const broadcast = broadcastFromAddress(candidate); for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) {
if (broadcast) targets.push(broadcast); const broadcast = broadcastFromAddress(candidate);
if (broadcast) targets.push(broadcast);
}
targets.push(...getInterfaceBroadcasts());
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
} }
targets.push(...getInterfaceBroadcasts());
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
if (!targets.length) { if (!targets.length) {
targets.push("255.255.255.255"); targets.push("255.255.255.255");
} }
@@ -413,6 +416,20 @@ function extractValue(propertyValue) {
return propertyValue?.values?.[0]?.value ?? propertyValue?.values?.[0] ?? null; return propertyValue?.values?.[0]?.value ?? propertyValue?.values?.[0] ?? null;
} }
function bacnetUnitSymbol(value) {
const unit = Number(value);
const map = {
3: "A",
5: "V",
53: "Pa",
62: "°C",
63: "K",
95: "",
98: "%",
};
return Object.prototype.hasOwnProperty.call(map, unit) ? map[unit] : "";
}
async function scanObjects(client, device, portNumber) { async function scanObjects(client, device, portNumber) {
const host = device.address; const host = device.address;
const deviceObject = { type: 8, instance: Number(device.deviceId) }; const deviceObject = { type: 8, instance: Number(device.deviceId) };
@@ -429,6 +446,7 @@ async function scanObjects(client, device, portNumber) {
const objectName = await readProperty(client, host, objectId, 77); const objectName = await readProperty(client, host, objectId, 77);
const description = await readProperty(client, host, objectId, 28); const description = await readProperty(client, host, objectId, 28);
const presentValue = await readProperty(client, host, objectId, 85); const presentValue = await readProperty(client, host, objectId, 85);
const units = await readProperty(client, host, objectId, 117);
const raw = extractValue(presentValue); const raw = extractValue(presentValue);
const name = normalizeText(extractValue(objectName)) || objectLabel(objectId); const name = normalizeText(extractValue(objectName)) || objectLabel(objectId);
const alias = normalizeText(extractValue(description)) || name; const alias = normalizeText(extractValue(description)) || name;
@@ -438,6 +456,7 @@ async function scanObjects(client, device, portNumber) {
host, host,
port: portNumber, port: portNumber,
deviceId: device.deviceId, deviceId: device.deviceId,
deviceName: device.name || "BACnet Gerät " + device.deviceId,
objectType: objectId.type, objectType: objectId.type,
objectInstance: objectId.instance, objectInstance: objectId.instance,
propertyId: 85, propertyId: 85,
@@ -447,6 +466,7 @@ async function scanObjects(client, device, portNumber) {
description: alias, description: alias,
alias, alias,
kind: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "digital" : "analog", kind: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "digital" : "analog",
unit: typeof raw === "boolean" || (objectId.type >= 3 && objectId.type <= 5) ? "bool" : bacnetUnitSymbol(extractValue(units)),
currentValue: raw, currentValue: raw,
hasValue: raw !== null, hasValue: raw !== null,
}); });
@@ -646,15 +666,25 @@ function isLikelyDockerGatewayAddress(address) {
function preferRealBacnetAddresses(devices) { function preferRealBacnetAddresses(devices) {
const list = Array.isArray(devices) ? devices : []; const list = Array.isArray(devices) ? devices : [];
const idsWithRealAddress = new Set( const byDeviceId = new Map();
list for (const device of list) {
.filter((device) => !isLikelyDockerGatewayAddress(device.address || device.host)) const id = String(device?.deviceId || "");
.map((device) => String(device.deviceId)), if (!id) continue;
); const address = device.address || device.host || "";
return list.filter((device) => { const current = byDeviceId.get(id);
const address = device.address || device.host; if (!current) {
return !(idsWithRealAddress.has(String(device.deviceId)) && isLikelyDockerGatewayAddress(address)); byDeviceId.set(id, device);
}); continue;
}
const currentAddress = current.address || current.host || "";
const deviceIsReal = !isLikelyDockerGatewayAddress(address);
const currentIsReal = !isLikelyDockerGatewayAddress(currentAddress);
const hasMorePoints = Number(device.pointCount || device.points?.length || 0) > Number(current.pointCount || current.points?.length || 0);
if ((deviceIsReal && !currentIsReal) || (deviceIsReal === currentIsReal && hasMorePoints)) {
byDeviceId.set(id, { ...current, ...device, points: device.points?.length ? device.points : current.points });
}
}
return Array.from(byDeviceId.values());
} }
async function scanNetwork(body) { async function scanNetwork(body) {
const timeout = Number(body.timeout || defaultTimeout); const timeout = Number(body.timeout || defaultTimeout);
@@ -706,7 +736,7 @@ async function scanNetwork(body) {
await waitForSocket(client); await waitForSocket(client);
await registerForeignDevice(client, routing); await registerForeignDevice(client, routing);
sendWhoIs(client, routing, body.deviceId); sendWhoIs(client, routing, body.deviceId);
await sleep(Math.min(timeout, 1800)); await sleep(Math.min(timeout, Number(body.initialWaitMs || process.env.BACNET_INITIAL_WAIT_MS || 4500)));
const hosts = routing.mode === "broadcast" ? buildUnicastScanHosts(body, routing) : []; const hosts = routing.mode === "broadcast" ? buildUnicastScanHosts(body, routing) : [];
const needsUnicastMapping = routing.mode === "broadcast" && hosts.length && (!devices.length || devices.some((device) => !hosts.includes(device.address))); const needsUnicastMapping = routing.mode === "broadcast" && hosts.length && (!devices.length || devices.some((device) => !hosts.includes(device.address)));
@@ -721,7 +751,7 @@ async function scanNetwork(body) {
} }
} }
currentUnicastTarget = ""; currentUnicastTarget = "";
await sleep(250); await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200));
} }
let visibleDevices = preferRealBacnetAddresses(devices); let visibleDevices = preferRealBacnetAddresses(devices);
@@ -864,3 +894,13 @@ server.listen(port, () => {
}); });
+14 -9
View File
@@ -75,16 +75,19 @@ function getBroadcastTargets(routing, body = {}) {
if (!isAutoBroadcast(configured)) { if (!isAutoBroadcast(configured)) {
targets.push(configured); targets.push(configured);
} }
for (const subnet of parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets)) { const configuredSubnets = parseHostList(body.scanSubnets || body.bacnetScanSubnets || body.options?.bacnetScanSubnets);
for (const subnet of configuredSubnets) {
const broadcast = cidrBroadcast(subnet); const broadcast = cidrBroadcast(subnet);
if (broadcast) targets.push(broadcast); if (broadcast) targets.push(broadcast);
} }
for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) { if (!configuredSubnets.length) {
const broadcast = broadcastFromAddress(candidate); for (const candidate of [body.clientHost, body.browserHost, body.locationHost]) {
if (broadcast) targets.push(broadcast); const broadcast = broadcastFromAddress(candidate);
if (broadcast) targets.push(broadcast);
}
targets.push(...getInterfaceBroadcasts());
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
} }
targets.push(...getInterfaceBroadcasts());
targets.push(...parseHostList(process.env.BACNET_BROADCAST_FALLBACKS));
if (!targets.length) { if (!targets.length) {
targets.push("255.255.255.255"); targets.push("255.255.255.255");
} }
@@ -257,10 +260,10 @@ async function discover() {
const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b); const broadcastPacket = buildRawWhoIsPacket(body.deviceId, 0x0b);
for (const broadcastAddress of getBroadcastTargets(routing, body)) { for (const broadcastAddress of getBroadcastTargets(routing, body)) {
socket.send(broadcastPacket, port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" })); socket.send(broadcastPacket, port, broadcastAddress, (error) => rawDebug.sends.push({ host: broadcastAddress, error: error?.message || "" }));
await sleep(350); await sleep(Number(body.broadcastDelayMs || process.env.BACNET_BROADCAST_DELAY_MS || 700));
} }
const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 20); const perHostDelay = Number(body.unicastDelayMs || process.env.BACNET_UNICAST_DELAY_MS || 120);
const packet = buildRawWhoIsPacket(body.deviceId, 0x0a); const packet = buildRawWhoIsPacket(body.deviceId, 0x0a);
for (const host of hosts) { for (const host of hosts) {
const before = devices.length; const before = devices.length;
@@ -272,7 +275,7 @@ async function discover() {
} }
} }
currentUnicastHost = ""; currentUnicastHost = "";
await sleep(250); await sleep(Number(body.finalWaitMs || process.env.BACNET_FINAL_WAIT_MS || 1200));
} finally { } finally {
socket.close(); socket.close();
} }
@@ -289,3 +292,5 @@ discover()
process.exitCode = 1; process.exitCode = 1;
}); });
+37 -1
View File
@@ -278,6 +278,9 @@ function parseModbusCsv(payload) {
dataType: get("datatype", "holding-register"), dataType: get("datatype", "holding-register"),
kind: normalizePointKind(get("kind", get("type", "analog"))), kind: normalizePointKind(get("kind", get("type", "analog"))),
enabled: payload.enabled === true, enabled: payload.enabled === true,
alarmEnabled: payload.alarmEnabled === true,
alarmCondition: normalizeText(payload.alarmCondition),
alarmGroupId: normalizeText(payload.alarmGroupId),
writeMode: normalizeWriteMode(get("writemode", payload.writeMode || "")), writeMode: normalizeWriteMode(get("writemode", payload.writeMode || "")),
condition: get("condition", payload.condition || ""), condition: get("condition", payload.condition || ""),
scale: Number(get("scale", 1)) || 1, scale: Number(get("scale", 1)) || 1,
@@ -328,6 +331,9 @@ function parseKnxXml(payload) {
dataType: dpt || "group-address", dataType: dpt || "group-address",
kind: dpt.startsWith("1.") ? "digital" : "analog", kind: dpt.startsWith("1.") ? "digital" : "analog",
enabled: payload.enabled === true, enabled: payload.enabled === true,
alarmEnabled: payload.alarmEnabled === true,
alarmCondition: normalizeText(payload.alarmCondition),
alarmGroupId: normalizeText(payload.alarmGroupId),
writeMode: normalizeWriteMode(payload.writeMode || ""), writeMode: normalizeWriteMode(payload.writeMode || ""),
condition: payload.condition || "", condition: payload.condition || "",
host: payload.host || "", host: payload.host || "",
@@ -364,6 +370,9 @@ function createManualDatapoint(store, payload) {
unit: normalizePointUnit(payload.unit, payload.kind), unit: normalizePointUnit(payload.unit, payload.kind),
kind: normalizePointKind(payload.kind), kind: normalizePointKind(payload.kind),
enabled: payload.enabled !== false, enabled: payload.enabled !== false,
alarmEnabled: payload.alarmEnabled === true,
alarmCondition: normalizeText(payload.alarmCondition),
alarmGroupId: normalizeText(payload.alarmGroupId),
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
condition: normalizeText(payload.condition), condition: normalizeText(payload.condition),
dataType: normalizeText(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")), dataType: normalizeText(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")),
@@ -398,6 +407,9 @@ function createScanDatapoint(source, payload, index) {
unit: normalizePointUnit(payload.unit, payload.kind), unit: normalizePointUnit(payload.unit, payload.kind),
kind: normalizePointKind(payload.kind), kind: normalizePointKind(payload.kind),
enabled: payload.enabled === true, enabled: payload.enabled === true,
alarmEnabled: payload.alarmEnabled === true,
alarmCondition: normalizeText(payload.alarmCondition),
alarmGroupId: normalizeText(payload.alarmGroupId),
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
condition: normalizeText(payload.condition), condition: normalizeText(payload.condition),
dataType: normalizeText(payload.dataType), dataType: normalizeText(payload.dataType),
@@ -446,7 +458,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
pointIndex: existing.pointIndex, pointIndex: existing.pointIndex,
alias: !existingAlias || existingLooksLikeScanDefault ? scanned.alias : existing.alias, alias: !existingAlias || existingLooksLikeScanDefault ? scanned.alias : existing.alias,
name: scanned.name || existing.name, name: scanned.name || existing.name,
unit: normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind), unit: existing.enabled === true ? normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind) : normalizePointUnit(scanned.unit || existing.unit, scanned.kind || existing.kind),
kind: existing.kind || scanned.kind, kind: existing.kind || scanned.kind,
enabled: existing.enabled === true, enabled: existing.enabled === true,
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode), writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
@@ -1066,6 +1078,11 @@ function sendBacnetWhoIs(client, routing, deviceId) {
throw new Error("Who-Is konnte nicht ausgel?st werden."); throw new Error("Who-Is konnte nicht ausgel?st werden.");
} }
function bacnetUnitSymbol(value) {
const unit = Number(value);
const map = { 3: "A", 5: "V", 53: "Pa", 62: "°C", 63: "K", 95: "", 98: "%" };
return Object.prototype.hasOwnProperty.call(map, unit) ? map[unit] : "";
}
function readBacnetProperty(client, host, objectId, propertyId) { function readBacnetProperty(client, host, objectId, propertyId) {
return new Promise((resolve) => { return new Promise((resolve) => {
client.readProperty(host, objectId, propertyId, (error, value) => { client.readProperty(host, objectId, propertyId, (error, value) => {
@@ -1092,6 +1109,7 @@ async function scanBacnetObjects(client, device, portNumber) {
const objectName = await readBacnetProperty(client, host, objectId, 77); const objectName = await readBacnetProperty(client, host, objectId, 77);
const description = await readBacnetProperty(client, host, objectId, 28); const description = await readBacnetProperty(client, host, objectId, 28);
const presentValue = await readBacnetProperty(client, host, objectId, 85); const presentValue = await readBacnetProperty(client, host, objectId, 85);
const units = await readBacnetProperty(client, host, objectId, 117);
const raw = presentValue?.values?.[0]?.value ?? null; const raw = presentValue?.values?.[0]?.value ?? null;
const label = bacnetObjectLabel(objectId); const label = bacnetObjectLabel(objectId);
const name = normalizeText(objectName?.values?.[0]?.value) || label; const name = normalizeText(objectName?.values?.[0]?.value) || label;
@@ -1100,6 +1118,8 @@ async function scanBacnetObjects(client, device, portNumber) {
id: host + "-" + objectId.type + "-" + objectId.instance, id: host + "-" + objectId.type + "-" + objectId.instance,
host, host,
port: portNumber, port: portNumber,
deviceId: device.deviceId,
deviceName: device.name || "BACnet Gerät " + device.deviceId,
objectType: objectId.type, objectType: objectId.type,
objectInstance: objectId.instance, objectInstance: objectId.instance,
propertyId: 85, propertyId: 85,
@@ -1109,6 +1129,7 @@ async function scanBacnetObjects(client, device, portNumber) {
description: alias, description: alias,
alias, alias,
kind: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "digital" : "analog", kind: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "digital" : "analog",
unit: typeof raw === "boolean" || objectId.type >= 3 && objectId.type <= 5 ? "bool" : bacnetUnitSymbol(units?.values?.[0]?.value),
currentValue: raw, currentValue: raw,
hasValue: raw !== null, hasValue: raw !== null,
}); });
@@ -1947,9 +1968,11 @@ const server = createServer(async (req, res) => {
updatedAt: store.updatedAt, updatedAt: store.updatedAt,
sourceCount: store.sources.length, sourceCount: store.sources.length,
datapointCount: store.datapoints.length, datapointCount: store.datapoints.length,
alarmDatapointCount: store.datapoints.filter((item) => item.alarmEnabled === true).length,
sources: store.sources.map((source) => ({ sources: store.sources.map((source) => ({
...source, ...source,
datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length, datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length,
alarmDatapointCount: store.datapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length,
})), })),
latestScans: { latestScans: {
opcua: store.scans.opcua[0] || null, opcua: store.scans.opcua[0] || null,
@@ -2046,6 +2069,9 @@ const server = createServer(async (req, res) => {
store.datapoints[index] = { store.datapoints[index] = {
...store.datapoints[index], ...store.datapoints[index],
enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled, enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled,
alarmEnabled: typeof body.alarmEnabled === "boolean" ? body.alarmEnabled : store.datapoints[index].alarmEnabled === true,
alarmCondition: typeof body.alarmCondition === "string" ? body.alarmCondition.trim() : store.datapoints[index].alarmCondition,
alarmGroupId: typeof body.alarmGroupId === "string" ? body.alarmGroupId.trim() : store.datapoints[index].alarmGroupId,
writeMode: typeof body.writeMode === "string" ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode, writeMode: typeof body.writeMode === "string" ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode,
condition: typeof body.condition === "string" ? body.condition.trim() : store.datapoints[index].condition, condition: typeof body.condition === "string" ? body.condition.trim() : store.datapoints[index].condition,
pointIndex: body.pointIndex ? normalizePointIndex(body.pointIndex, store.datapoints[index].pointIndex) : store.datapoints[index].pointIndex, pointIndex: body.pointIndex ? normalizePointIndex(body.pointIndex, store.datapoints[index].pointIndex) : store.datapoints[index].pointIndex,
@@ -2222,3 +2248,13 @@ server.listen(port, () => {
+32 -6
View File
@@ -89,9 +89,11 @@
DB_USER: root DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112} DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
POLL_TICK_MS: ${POLL_TICK_MS:-1000} POLL_TICK_MS: ${POLL_TICK_MS:-1000}
BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://bacnet-collector:18111} BACNET_COLLECTOR_URL: ${BACNET_COLLECTOR_URL:-http://172.17.0.1:18111}
ports: ports:
- "18110:18110" - "18110:18110"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- collector_data:/app/data - collector_data:/app/data
depends_on: depends_on:
@@ -107,14 +109,33 @@
restart: unless-stopped restart: unless-stopped
environment: environment:
PORT: 18111 PORT: 18111
BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-7000} BACNET_SCAN_TIMEOUT: ${BACNET_SCAN_TIMEOUT:-12000}
BACNET_POINT_LIMIT: ${BACNET_POINT_LIMIT:-2000} BACNET_POINT_LIMIT: ${BACNET_POINT_LIMIT:-2000}
BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-} BACNET_BROADCAST_FALLBACKS: ${BACNET_BROADCAST_FALLBACKS:-}
network_mode: "host" BACNET_UNICAST_DELAY_MS: ${BACNET_UNICAST_DELAY_MS:-120}
ports: network_mode: "host"
- "18111:18111"
- "47808:47808/udp"
runner:
build:
context: ./runner
container_name: seltd-runner
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: wago
DB_USER: root
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
EXPORT_DIR: /exports
RUNNER_TICK_MS: ${RUNNER_TICK_MS:-30000}
volumes:
- runner_exports:/exports
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
dockhand: dockhand:
image: fnsys/dockhand:latest image: fnsys/dockhand:latest
container_name: seltd-dockhand container_name: seltd-dockhand
@@ -147,7 +168,12 @@ volumes:
mariadb_data: mariadb_data:
redis_data: redis_data:
collector_data: collector_data:
runner_exports:
dockhand_data: dockhand_data:
+25
View File
@@ -0,0 +1,25 @@
Write-Host "Stopping Docker Compose stack..." -ForegroundColor Yellow
docker compose down
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose down failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Building Docker Compose stack without cache..." -ForegroundColor Yellow
docker compose build --no-cache
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose build failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Starting Docker Compose stack..." -ForegroundColor Yellow
docker compose up -d --force-recreate
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose up failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Docker Compose stack successfully rebuilt and started." -ForegroundColor Green
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
RUN mkdir -p /exports
CMD ["npm", "start"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "seltd-runner",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"ioredis": "^5.6.1",
"mysql2": "^3.12.0"
}
}
+187
View File
@@ -0,0 +1,187 @@
import dns from "node:dns";
import { createReadStream, mkdirSync, statSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import https from "node:https";
import path from "node:path";
import Redis from "ioredis";
import mysql from "mysql2/promise";
const redis = new Redis(process.env.REDIS_URL || "redis://redis:6379", { maxRetriesPerRequest: 2 });
const db = mysql.createPool({
host: process.env.DB_HOST || "mariadb",
port: Number(process.env.DB_PORT || 3306),
database: process.env.DB_NAME || "wago",
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "SE3112",
charset: "utf8mb4",
waitForConnections: true,
connectionLimit: 3,
});
const exportDir = process.env.EXPORT_DIR || "/exports";
const tickMs = Number(process.env.RUNNER_TICK_MS || 30000);
function safeParse(value, fallback = null) {
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
}
function csvCell(value, delimiter) {
const text = String(value ?? "");
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
}
function localDateKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function localTime(date = new Date()) {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function sanitizeTableName(value) {
const table = String(value || "").toLowerCase();
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) throw new Error(`Ungueltige Tabelle: ${value}`);
return table;
}
async function isNarrowTable(table) {
const [rows] = await db.query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'point_index' LIMIT 1",
[table],
);
return rows.length > 0;
}
async function getPointMeta(isp, pointIndex) {
const raw = await redis.get(`meta:isp:${isp}`);
const meta = safeParse(raw, {});
const point = (meta.points || []).find((item) => Number(item.pointIndex) === Number(pointIndex));
return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" };
}
async function exportSelection(job, selection) {
const delimiter = job.delimiter || ";";
const hours = Number(job.rangeHours || 24);
const since = new Date(Date.now() - Math.max(1, hours) * 3600000);
const rows = [["Zeit", "Tabelle", "Wertnummer", "Alias", "Wert", "Einheit"]];
for (const point of selection.points || []) {
const table = sanitizeTableName(point.isp);
const pointIndex = Number(point.pointIndex);
const meta = await getPointMeta(table, pointIndex);
if (await isNarrowTable(table)) {
const [data] = await db.query(
`SELECT datum, alias, value FROM \`${table}\` WHERE point_index = ? AND datum >= ? ORDER BY datum ASC`,
[pointIndex, since],
);
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, item.value, meta.unit]);
} else {
const column = `Wert${pointIndex}`;
const [data] = await db.query(
`SELECT datum, \`${column}\` AS value FROM \`${table}\` WHERE datum >= ? ORDER BY datum ASC`,
[since],
);
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, item.value, meta.unit]);
}
}
mkdirSync(exportDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `${selection.name || selection.id || "selection"}-${stamp}.csv`.replace(/[^a-zA-Z0-9_.-]+/g, "_");
const filePath = path.join(exportDir, fileName);
await writeFile(filePath, rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n"), "utf8");
return filePath;
}
async function postMultipartIpv4(url, fields, attachmentPath = "") {
const boundary = `----seltd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const chunks = [];
const pushField = (name, value) => {
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value ?? ""}\r\n`));
};
Object.entries(fields).forEach(([key, value]) => pushField(key, value));
if (attachmentPath) {
const fileName = path.basename(attachmentPath);
chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName}"\r\nContent-Type: text/csv\r\n\r\n`));
chunks.push(createReadStream(attachmentPath));
chunks.push(Buffer.from("\r\n"));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
const target = new URL(url);
const length = chunks.reduce((sum, chunk) => sum + (typeof chunk.path === "string" ? statSync(chunk.path).size : chunk.length), 0);
await new Promise((resolve, reject) => {
const req = https.request({
method: "POST",
hostname: target.hostname,
path: `${target.pathname}${target.search}`,
port: target.port || 443,
lookup: (hostname, options, cb) => dns.lookup(hostname, { ...options, family: 4 }, cb),
timeout: 15000,
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": length },
}, (res) => {
let body = "";
res.on("data", (chunk) => { body += chunk.toString("utf8"); });
res.on("end", () => res.statusCode >= 200 && res.statusCode < 300 ? resolve(body) : reject(new Error(`HTTP ${res.statusCode}: ${body}`)));
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("API timeout")));
const writeNext = (index) => {
if (index >= chunks.length) return req.end();
const chunk = chunks[index];
if (typeof chunk.pipe === "function") {
chunk.on("end", () => writeNext(index + 1));
chunk.on("error", reject);
chunk.pipe(req, { end: false });
} else {
req.write(chunk);
writeNext(index + 1);
}
};
writeNext(0);
});
}
async function maybeMail(job, filePath) {
if (!job.mailEnabled || !job.mailTo) return;
const settings = safeParse(await redis.get("integration:api"), {});
if (!settings.mailApiKey) return;
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
api_key: settings.mailApiKey,
address: job.mailTo,
subject: job.mailSubject || "SE-LTD CSV Export",
message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.",
}, filePath);
}
async function runJob(job) {
const selection = safeParse(await redis.get(`selection:${job.selectionId}`));
if (!selection) throw new Error(`Selection nicht gefunden: ${job.selectionId}`);
const filePath = await exportSelection(job, selection);
await maybeMail(job, filePath);
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() }));
}
async function tick() {
const ids = await redis.smembers("runner:job:ids");
const now = new Date();
const time = localTime(now);
const dateKey = localDateKey(now);
for (const id of ids) {
const job = safeParse(await redis.get(`runner:job:${id}`));
if (!job?.enabled || job.time !== time) continue;
const lastKey = `runner:last:${id}`;
if ((await redis.get(lastKey)) === dateKey) continue;
await redis.set(lastKey, dateKey);
runJob(job).catch(async (error) => {
await redis.set(`runner:last-result:${id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() }));
console.error(error);
});
}
}
console.log("SE Local Trenddata runner started");
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
tick().catch((error) => console.error(error));
+33
View File
@@ -0,0 +1,33 @@
Write-Host "Pulling latest Repo from Gittea..." -ForegroundColor Blue
git pull
if ($LASTEXITCODE -ne 0) {
Write-Host "git pull failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Stopping Docker Compose stack..." -ForegroundColor Yellow
docker compose down
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose down failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Building Docker Compose stack without cache..." -ForegroundColor Yellow
docker compose build --no-cache
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose build failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Starting Docker Compose stack..." -ForegroundColor Yellow
docker compose up -d --force-recreate
if ($LASTEXITCODE -ne 0) {
Write-Host "docker compose up failed." -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "Docker Compose stack successfully rebuilt and started." -ForegroundColor Green
+409 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "./api.js"; import { api } from "./api.js";
import Gauge from "./components/Gauge.jsx"; import Gauge from "./components/Gauge.jsx";
@@ -15,8 +15,11 @@ const adminSections = [
{ id: "aliases", label: "Aliase" }, { id: "aliases", label: "Aliase" },
{ id: "units", label: "Einheiten" }, { id: "units", label: "Einheiten" },
{ id: "sources", label: "Quellen" }, { id: "sources", label: "Quellen" },
{ id: "alarms", label: "Alarme" },
{ id: "users", label: "Benutzer" }, { id: "users", label: "Benutzer" },
{ id: "roles", label: "Rollen" }, { id: "roles", label: "Rollen" },
{ id: "integrations", label: "API" },
{ id: "runner", label: "Runner" },
{ id: "debug", label: "Debug" }, { id: "debug", label: "Debug" },
]; ];
@@ -86,6 +89,9 @@ const PERMISSION_OPTIONS = [
{ id: "manage_users", label: "Benutzer verwalten" }, { id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" }, { id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Debug anzeigen" }, { id: "view_debug", label: "Debug anzeigen" },
{ id: "manage_alarms", label: "Alarme verwalten" },
{ id: "manage_integrations", label: "API-Verbindungen verwalten" },
{ id: "manage_runner", label: "CSV-Runner verwalten" },
]; ];
const pointColors = [ const pointColors = [
@@ -724,9 +730,13 @@ export default function App() {
const [sourceModalOpen, setSourceModalOpen] = useState(false); const [sourceModalOpen, setSourceModalOpen] = useState(false);
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" }); const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" });
const [collectorPointFilter, setCollectorPointFilter] = useState(""); const [collectorPointFilter, setCollectorPointFilter] = useState("");
const [alarmPointFilter, setAlarmPointFilter] = useState("");
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft()); const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null }); const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null); const [debugSnapshot, setDebugSnapshot] = useState(null);
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
const [runnerJobs, setRunnerJobs] = 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 trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]); const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null; const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
@@ -738,8 +748,11 @@ export default function App() {
const canManageUsers = isAuthenticated && hasPermission(user, "manage_users"); const canManageUsers = isAuthenticated && hasPermission(user, "manage_users");
const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles"); const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles");
const canViewDebug = isAuthenticated && hasPermission(user, "view_debug"); const canViewDebug = isAuthenticated && hasPermission(user, "view_debug");
const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug; const canManageAlarms = isAuthenticated && hasPermission(user, "manage_alarms");
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases"; const canManageIntegrations = isAuthenticated && hasPermission(user, "manage_integrations");
const canManageRunner = isAuthenticated && hasPermission(user, "manage_runner");
const canAccessAdmin = canEditAliases || canManageSources || canManageAlarms || canManageUsers || canManageRoles || canManageIntegrations || canManageRunner || canViewDebug;
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageAlarms ? "alarms" : canManageUsers ? "users" : canManageRoles ? "roles" : canManageIntegrations ? "integrations" : canManageRunner ? "runner" : canViewDebug ? "debug" : "aliases";
const sidebarCollapsed = !sidebarPinned && !sidebarHover; const sidebarCollapsed = !sidebarPinned && !sidebarHover;
const currentView = views.find((view) => view.id === activeView) || views[0]; const currentView = views.find((view) => view.id === activeView) || views[0];
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list"; const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
@@ -765,6 +778,8 @@ export default function App() {
const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]); const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]);
const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]); const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]);
const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]); const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]);
const alarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled === true), [collectorSourcePoints]);
const availableAlarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled !== true), [collectorSourcePoints]);
const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol; const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol;
const upsertIspEntry = (entry) => { const upsertIspEntry = (entry) => {
@@ -841,6 +856,18 @@ export default function App() {
item.currentValue, item.currentValue,
])), ])),
[availableCollectorPoints, collectorPointFilter] [availableCollectorPoints, collectorPointFilter]
); const filteredAvailableAlarmPoints = useMemo(
() => availableAlarmCollectorPoints.filter((item) => matchesSearch(alarmPointFilter, [
item.alias,
item.name,
item.address,
item.nodeId,
item.dataType,
item.protocol,
item.pointIndex,
item.currentValue,
])),
[alarmPointFilter, availableAlarmCollectorPoints]
); );
const resetFlash = () => { const resetFlash = () => {
@@ -1082,6 +1109,27 @@ export default function App() {
.then((result) => setUnits(result.units || DEFAULT_UNIT_OPTIONS)) .then((result) => setUnits(result.units || DEFAULT_UNIT_OPTIONS))
.catch((error) => setBootError(error.message)); .catch((error) => setBootError(error.message));
}, [canEditAliases, canManageSources, token]); }, [canEditAliases, canManageSources, token]);
useEffect(() => {
if (!token || !canManageIntegrations) {
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
return;
}
api.getIntegrations(token)
.then((result) => setIntegrationSettings(result.settings || { mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }))
.catch((error) => setBootError(error.message));
}, [canManageIntegrations, token]);
useEffect(() => {
if (!token || !canManageRunner) {
setRunnerJobs([]);
return;
}
api.getRunnerJobs(token)
.then((result) => setRunnerJobs(result.jobs || []))
.catch((error) => setBootError(error.message));
}, [canManageRunner, token]);
useEffect(() => { useEffect(() => {
const refreshIsps = () => { const refreshIsps = () => {
@@ -1105,7 +1153,7 @@ export default function App() {
}, [token]); }, [token]);
useEffect(() => { useEffect(() => {
if (!token || !(canEditAliases || canManageSources || canViewDebug)) { if (!token || !(canEditAliases || canManageSources || canManageAlarms || canViewDebug)) {
setCollectorCapabilities(null); setCollectorCapabilities(null);
setCollectorSources([]); setCollectorSources([]);
setCollectorDatapoints([]); setCollectorDatapoints([]);
@@ -1117,7 +1165,7 @@ export default function App() {
.catch((error) => { setCollectorCapabilities(null); setCollectorError(error.message); }); .catch((error) => { setCollectorCapabilities(null); setCollectorError(error.message); });
refreshCollectorData().catch((error) => setCollectorError(error.message)); refreshCollectorData().catch((error) => setCollectorError(error.message));
}, [canEditAliases, canManageSources, canViewDebug, token]); }, [canEditAliases, canManageSources, canManageAlarms, canViewDebug, token]);
useEffect(() => { useEffect(() => {
if (!currentSource) { if (!currentSource) {
@@ -1668,6 +1716,13 @@ export default function App() {
name: sourceDraft.name.trim(), name: sourceDraft.name.trim(),
tableName: sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol), tableName: sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol),
displayName: sourceDraft.name.trim(), displayName: sourceDraft.name.trim(),
options: {
...(currentSource?.options || {}),
bacnetMode: sourceDraft.bacnetMode,
bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress,
bacnetScanSubnets: sourceDraft.bacnetScanSubnets,
bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl,
},
}; };
const saved = payload.id const saved = payload.id
@@ -1829,6 +1884,7 @@ export default function App() {
const runOpcuaScan = async () => { const runOpcuaScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id; const activeSourceId = selectedSourceId || sourceDraft.id;
const activeSource = collectorSources.find((source) => source.id === activeSourceId) || null;
if (!activeSourceId) { if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle auswählen oder anlegen."); setBootError("Bitte zuerst eine Quelle auswählen oder anlegen.");
return; return;
@@ -1837,8 +1893,8 @@ export default function App() {
resetFlash(); resetFlash();
try { try {
const result = await api.scanOpcua({ const result = await api.scanOpcua({
host: sourceDraft.host, host: activeSource?.host || sourceDraft.host,
port: sourceDraft.port || "4840", port: activeSource?.port || sourceDraft.port || "4840",
sourceId: activeSourceId, sourceId: activeSourceId,
}); });
setScanResults((current) => ({ ...current, opcua: result })); setScanResults((current) => ({ ...current, opcua: result }));
@@ -1895,6 +1951,10 @@ export default function App() {
bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto", bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto",
clientHost: typeof window !== "undefined" ? window.location.hostname : "", clientHost: typeof window !== "undefined" ? window.location.hostname : "",
bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120", bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120",
timeout: options.timeout || 12000,
initialWaitMs: options.initialWaitMs || 6000,
finalWaitMs: options.finalWaitMs || 1800,
unicastDelayMs: options.unicastDelayMs || 140,
}); });
setScanResults((current) => ({ ...current, bacnet: result })); setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorData(); await refreshCollectorData();
@@ -1976,6 +2036,79 @@ export default function App() {
} }
}; };
const saveIntegrationSettings = async () => {
resetFlash();
try {
const result = await api.saveIntegrations(token, integrationSettings);
setIntegrationSettings(result.settings || integrationSettings);
setSuccessMessage("API-Verbindungen gespeichert.");
} 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." });
};
const editRunnerJob = (job) => {
setRunnerDraft({
id: job.id,
name: job.name || "",
selectionId: job.selectionId || "",
enabled: job.enabled !== false,
time: job.time || "06:00",
rangeHours: String(job.rangeHours || 24),
delimiter: job.delimiter || ";",
mailEnabled: job.mailEnabled === true,
mailTo: job.mailTo || "",
mailSubject: job.mailSubject || "Trenddaten",
mailMessage: job.mailMessage || "Automatischer Export aus SE Local Trenddata.",
});
};
const saveRunnerJob = async () => {
resetFlash();
try {
if (!runnerDraft.selectionId) {
setBootError("Bitte eine gespeicherte Auswahl für den Export wählen.");
return;
}
const payload = {
...runnerDraft,
rangeHours: Number(runnerDraft.rangeHours) || 24,
};
if (runnerDraft.id) {
await api.updateRunnerJob(token, runnerDraft.id, payload);
} else {
await api.createRunnerJob(token, payload);
}
const refreshed = await api.getRunnerJobs(token);
setRunnerJobs(refreshed.jobs || []);
resetRunnerDraft();
setSuccessMessage("Runner-Job gespeichert.");
} catch (error) {
setBootError(error.message);
}
};
const deleteRunnerJob = async (job) => {
resetFlash();
if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) {
return;
}
try {
await api.deleteRunnerJob(token, job.id);
const refreshed = await api.getRunnerJobs(token);
setRunnerJobs(refreshed.jobs || []);
if (runnerDraft.id === job.id) {
resetRunnerDraft();
}
setSuccessMessage("Runner-Job gelöscht.");
} catch (error) {
setBootError(error.message);
}
};
const updateUser = async (targetUser, changes) => { const updateUser = async (targetUser, changes) => {
resetFlash(); resetFlash();
try { try {
@@ -2448,12 +2581,21 @@ export default function App() {
if (section.id === "sources") { if (section.id === "sources") {
return canManageSources || canEditAliases; return canManageSources || canEditAliases;
} }
if (section.id === "alarms") {
return canManageAlarms;
}
if (section.id === "users") { if (section.id === "users") {
return canManageUsers; return canManageUsers;
} }
if (section.id === "roles") { if (section.id === "roles") {
return canManageRoles; return canManageRoles;
} }
if (section.id === "integrations") {
return canManageIntegrations;
}
if (section.id === "runner") {
return canManageRunner;
}
if (section.id === "debug") { if (section.id === "debug") {
return canViewDebug; return canViewDebug;
} }
@@ -2679,16 +2821,7 @@ export default function App() {
</div> </div>
<div className="protocol-block"> <div className="protocol-block">
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP Subnetze" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong> <strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
{sourceToolProtocol === "bacnet-ip" ? (
<>
<label className="compact-label">
<span>Subnetze</span>
<input value={sourceDraft.bacnetScanSubnets || ""} onBlur={saveBacnetSubnetSetting} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } }} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.1.0/24; 10.21.2.0/24" />
</label>
<small className="muted">Wird gespeichert und vom BACnet-Scan der Quelle für Device- und Objektsuche verwendet.</small>
</>
) : null}
{sourceToolProtocol === "opc-ua" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button></div><small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small></> : null} {sourceToolProtocol === "opc-ua" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button></div><small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small></> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null} {sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null}
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null} {sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
@@ -2821,6 +2954,12 @@ export default function App() {
<input value={sourceDraft.bacnetBroadcastAddress} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" /> <input value={sourceDraft.bacnetBroadcastAddress} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" />
</label> </label>
) : null} ) : null}
{sourceDraft.protocol === "bacnet-ip" ? (
<label className="span-two">
<span>Scan-Subnetze / Einzelhosts</span>
<input value={sourceDraft.bacnetScanSubnets || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.153.0/24; 10.21.2.212; 172.16.9.117/32" />
</label>
) : null}
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? ( {sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? (
<label> <label>
@@ -2935,7 +3074,7 @@ export default function App() {
<div key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive"> <div key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive">
<div className="datapoint-main"> <div className="datapoint-main">
<strong>{normalizeDisplayText(point.alias || point.name)}</strong> <strong>{normalizeDisplayText(point.alias || point.name)}</strong>
<span>{`${normalizeDisplayText(point.host)} ? ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span> <span>{`${normalizeDisplayText(point.deviceName || point.deviceId || point.host, "Gerät ?")} · ${normalizeDisplayText(point.host)} · ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}${point.unit ? ` · ${normalizeDisplayUnit(point.unit)}` : ""}`}</span>
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small> <small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
</div> </div>
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span> <span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
@@ -2994,6 +3133,124 @@ export default function App() {
</div> </div>
</div> </div>
) : null} ) : null}
{adminSection === "alarms" && canManageAlarms ? (
<div className="panel slim-panel source-admin-panel">
<div className="panel-header compact-header">
<div>
<h3>Alarme</h3>
<p>Datenpunkte aus vorhandenen Quellen gezielt in die Alarmüberwachung übernehmen.</p>
</div>
<div className="button-row wrap-tight">
{currentSource?.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>OPC scannen</button> : null}
{currentSource?.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={() => runBacnetScan({ source: currentSource, includeObjects: true })}>BACnet scannen</button> : null}
</div>
</div>
<div className="source-admin-top alarm-admin-top">
<div className="protocol-panel source-overview-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Quellen</h3>
<p>Quelle auswählen, danach links Punkte ins Alarming schieben.</p>
</div>
</div>
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
{collectorSources.map((source) => {
const isActive = source.id === selectedSourceId;
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
const alarmCount = collectorDatapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length;
return (
<button
key={source.id}
type="button"
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
onClick={() => {
setSelectedSourceId(source.id);
setAlarmPointFilter("");
}}
>
<div className="source-tile-copy">
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
<span>{`${source.protocol} | ${normalizeDisplayText(source.tableName)}`}</span>
<small>{`${alarmCount} Alarm(e) | ${pointCount} DP`}</small>
</div>
</button>
);
})}
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Quelle angelegt.</p></div> : null}
</div>
</div>
<div className="protocol-panel source-settings-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Aktive Quelle</h3>
<p>{currentSource ? `${normalizeDisplayText(currentSource.name)} · ${currentSource.protocol}` : "Bitte links eine Quelle auswählen."}</p>
</div>
</div>
<input
className="search-input"
placeholder="Suche: Alias, Adresse, NodeId oder Wertnummer"
value={alarmPointFilter}
onChange={(event) => setAlarmPointFilter(event.target.value)}
/>
<small className="muted">Alarming ist unabhängig vom Trendlogging. Ein Punkt kann alarmiert werden, ohne in die Trendtabelle geschrieben zu werden.</small>
</div>
</div>
<div className="source-split alarm-split">
<div className="protocol-block datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Verfügbare Datenpunkte</h3>
<p>Gescannt, importiert oder manuell angelegt. Nur per Klick landet ein Punkt im Alarming.</p>
</div>
</div>
<div className="scan-result-list">
{filteredAvailableAlarmPoints.length ? filteredAvailableAlarmPoints.slice(0, 2000).map((item) => (
<div key={item.id} className="datapoint-row inactive">
<div className="datapoint-main">
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{item.currentValue === null || item.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(item.currentValue)}</small>
</div>
<span className="datapoint-value">{item.kind === "digital" ? "digital" : normalizeDisplayUnit(item.unit || "")}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="primary-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { alarmEnabled: true })}>Ins Alarming</button>
</div>
</div>
)) : <div className="empty-state slim-empty"><p>{currentSource ? "Keine verfügbaren Punkte für diese Quelle. Starte ggf. einen Scan oder Import." : "Bitte zuerst eine Quelle auswählen."}</p></div>}
</div>
</div>
<div className="protocol-block datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Wird alarmiert</h3>
<p>Diese Punkte sind für die spätere Alarmüberwachung markiert.</p>
</div>
</div>
<div className="datapoint-table">
{alarmCollectorPoints.length ? alarmCollectorPoints.slice(0, 2000).map((item) => (
<div key={item.id} className="datapoint-row active">
<div className="datapoint-main">
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{item.alarmCondition ? normalizeDisplayText(item.alarmCondition) : "noch keine Bedingung gesetzt"}</small>
</div>
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="ghost-button compact-button danger" type="button" onClick={() => updateCollectorPoint(item, { alarmEnabled: false })}>Rausnehmen</button>
</div>
</div>
)) : <div className="empty-state slim-empty"><p>Für diese Quelle ist noch kein Datenpunkt im Alarming.</p></div>}
</div>
</div>
</div>
</div>
) : null}
{adminSection === "users" && canManageUsers ? ( {adminSection === "users" && canManageUsers ? (
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header nested-gap"> <div className="panel-header compact-header nested-gap">
@@ -3107,6 +3364,125 @@ export default function App() {
</div> </div>
) : null} ) : null}
{adminSection === "integrations" && canManageIntegrations ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>API-Verbindungen</h3>
<p>Keys für Mail, WhatsApp und Telefon. Requests werden serverseitig per IPv4 ausgeführt.</p>
</div>
</div>
<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 }))} />
</label>
<label>
<span>WhatsApp API-Key</span>
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
</label>
<label>
<span>Telefon API-Key</span>
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
</label>
</div>
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
</div>
) : null}
{adminSection === "runner" && canManageRunner ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>CSV-Runner</h3>
<p>Gespeicherte Auswahlen automatisch als CSV ins Dateisystem exportieren und optional per Mail verschicken.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={resetRunnerDraft}>Neuer Job</button>
</div>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
<input value={runnerDraft.name} onChange={(event) => setRunnerDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Tagesexport Lüftung" />
</label>
<label>
<span>Auswahl</span>
<select value={runnerDraft.selectionId} onChange={(event) => setRunnerDraft((current) => ({ ...current, selectionId: event.target.value }))}>
<option value="">Auswahl wählen</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<label>
<span>Uhrzeit</span>
<input type="time" value={runnerDraft.time} onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
</label>
<label>
<span>Zeitraum</span>
<select value={runnerDraft.rangeHours} onChange={(event) => setRunnerDraft((current) => ({ ...current, rangeHours: event.target.value }))}>
<option value="1">letzte 1 h</option>
<option value="6">letzte 6 h</option>
<option value="12">letzte 12 h</option>
<option value="24">letzte 24 h</option>
<option value="48">letzte 2 Tage</option>
<option value="168">letzte 7 Tage</option>
<option value="720">letzte 30 Tage</option>
</select>
</label>
<label>
<span>CSV-Trennzeichen</span>
<select value={runnerDraft.delimiter} onChange={(event) => setRunnerDraft((current) => ({ ...current, delimiter: event.target.value }))}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value="\t">Tabulator</option>
</select>
</label>
<label className="checkbox-field compact-check">
<input type="checkbox" checked={runnerDraft.enabled !== false} onChange={(event) => setRunnerDraft((current) => ({ ...current, enabled: event.target.checked }))} />
<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} />
<span>CSV per Mail verschicken</span>
</label>
<label>
<span>Mail an</span>
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com" />
</label>
<label>
<span>Betreff</span>
<input value={runnerDraft.mailSubject} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailSubject: event.target.value }))} />
</label>
<label className="span-two">
<span>Nachricht</span>
<input value={runnerDraft.mailMessage} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailMessage: event.target.value }))} />
</label>
</div>
<div className="button-row wrap-tight">
{runnerDraft.id ? <button className="ghost-button compact-button" type="button" onClick={resetRunnerDraft}>Abbrechen</button> : null}
<button className="primary-button compact-button" type="button" onClick={saveRunnerJob}>{runnerDraft.id ? "Runner-Job speichern" : "Runner-Job anlegen"}</button>
</div>
<div className="selection-list compact source-list">
{runnerJobs.map((job) => {
const selection = selections.find((item) => item.id === job.selectionId);
return (
<div key={job.id} className="selection-card source-card preview-card">
<div>
<strong>{normalizeDisplayText(job.name, "Runner-Job")}</strong>
<span>{`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} · ${job.enabled === false ? "pausiert" : "aktiv"}`}</span>
<small>{job.lastRunAt ? `letzter Lauf ${formatTimestampLabel(job.lastRunAt)}` : "noch nicht gelaufen"}</small>
</div>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => editRunnerJob(job)}>Bearbeiten</button>
<button className="ghost-button compact-button danger" type="button" onClick={() => deleteRunnerJob(job)}>Löschen</button>
</div>
</div>
);
})}
{!runnerJobs.length ? <div className="empty-state slim-empty"><p>Noch kein automatischer Export angelegt.</p></div> : null}
</div>
</div>
) : null}
{adminSection === "debug" && canViewDebug ? ( {adminSection === "debug" && canViewDebug ? (
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header nested-gap"> <div className="panel-header compact-header nested-gap">
@@ -3233,6 +3609,21 @@ export default function App() {
+7
View File
@@ -80,6 +80,12 @@ export const api = {
loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }), loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }),
getPreferences: (token) => apiRequest("/preferences", { token }), getPreferences: (token) => apiRequest("/preferences", { token }),
savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }), savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }),
getIntegrations: (token) => apiRequest("/integrations", { token }),
saveIntegrations: (token, body) => apiRequest("/integrations", { method: "PUT", token, body }),
getRunnerJobs: (token) => apiRequest("/runner/jobs", { token }),
createRunnerJob: (token, body) => apiRequest("/runner/jobs", { method: "POST", token, body }),
updateRunnerJob: (token, id, body) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "PATCH", token, body }),
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
getCollectorCapabilities: () => collectorRequest("/capabilities"), getCollectorCapabilities: () => collectorRequest("/capabilities"),
getCollectorSources: () => collectorRequest("/sources"), getCollectorSources: () => collectorRequest("/sources"),
getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"), getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"),
@@ -95,3 +101,4 @@ export const api = {
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }), scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }), scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
}; };
+152
View File
@@ -1253,3 +1253,155 @@ span {
width: 100%; width: 100%;
} }
} }
/* Compact source and datapoint cards for dense Full-HD trend/admin views. */
.source-tile {
min-height: 0;
padding: 0.36rem 0.48rem;
align-items: center;
}
.source-tile-copy {
gap: 0.08rem;
line-height: 1.18;
}
.source-tile-copy strong,
.source-tile-copy span,
.source-tile-copy small,
.datapoint-main strong,
.datapoint-main span,
.datapoint-main small {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.source-tile-actions {
gap: 0.18rem;
}
.source-tile-actions .compact-button {
min-height: 1.55rem;
padding: 0.24rem 0.48rem;
}
.scan-result-list,
.datapoint-table {
display: grid;
gap: 0.22rem;
}
.datapoint-row {
align-items: center;
gap: 0.42rem;
min-height: 0;
padding: 0.34rem 0.46rem;
}
.datapoint-main {
min-width: 0;
line-height: 1.2;
}
.datapoint-main strong {
font-size: 0.74rem;
}
.datapoint-main span,
.datapoint-main small,
.datapoint-value {
font-size: 0.68rem;
}
.datapoint-value {
white-space: nowrap;
}
.source-overview-panel .selection-list {
gap: 0.28rem;
}
/* Stable dense datapoint lists: compact, but never overlapping. */
.source-split .scan-result-list,
.source-split .datapoint-table {
gap: 0.3rem;
max-height: min(58vh, 620px);
min-height: 300px;
overflow-y: auto;
padding-right: 0.18rem;
}
.source-split .datapoint-row {
grid-template-columns: minmax(0, 1fr) 3.2rem minmax(10.5rem, max-content);
min-height: 42px;
padding: 0.4rem 0.5rem;
gap: 0.55rem;
align-items: center;
border-radius: 9px;
}
.source-split .datapoint-main {
min-width: 0;
line-height: 1.18;
gap: 0.08rem;
}
.source-split .datapoint-main strong,
.source-split .datapoint-main span,
.source-split .datapoint-main small {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-split .datapoint-main strong {
font-size: 0.76rem;
line-height: 1.18;
}
.source-split .datapoint-main span,
.source-split .datapoint-main small {
font-size: 0.68rem;
line-height: 1.15;
}
.source-split .datapoint-value {
justify-self: end;
min-width: 2.2rem;
font-size: 0.68rem;
text-align: right;
}
.source-split .datapoint-row > .button-row {
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 0.25rem;
min-width: 0;
}
.source-split .datapoint-row > .button-row .compact-button {
min-height: 24px;
padding: 0.24rem 0.46rem;
font-size: 0.68rem;
white-space: nowrap;
}
.source-split .datapoint-row.inactive {
opacity: 0.86;
}
@media (max-width: 1180px) {
.source-split .datapoint-row {
grid-template-columns: minmax(0, 1fr) 2.6rem;
}
.source-split .datapoint-row > .button-row {
grid-column: 1 / -1;
justify-content: flex-start;
}
}