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:
@@ -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, () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,25 @@
|
|||||||
|
Write-Host "Stopping Docker Compose stack..." -ForegroundColor Yellow
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "docker compose down failed." -ForegroundColor Red
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Building Docker Compose stack without cache..." -ForegroundColor Yellow
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "docker compose build failed." -ForegroundColor Red
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting Docker Compose stack..." -ForegroundColor Yellow
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "docker compose up failed." -ForegroundColor Red
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Docker Compose stack successfully rebuilt and started." -ForegroundColor Green
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY src ./src
|
||||||
|
RUN mkdir -p /exports
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user