API added
This commit is contained in:
+53
-8
@@ -1,4 +1,4 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js";
|
import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js";
|
||||||
@@ -214,11 +214,22 @@ function sanitizeApiSettings(body = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeRunnerJob(body = {}, existing = {}) {
|
function normalizeRunnerTime(value) {
|
||||||
const time = String(body.time ?? existing.time ?? "06:00").trim();
|
const raw = String(value ?? "").trim().replace(/\s*uhr$/i, "").replace(".", ":");
|
||||||
if (!/^\d{2}:\d{2}$/.test(time)) {
|
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
throw new Error("Uhrzeit bitte im Format HH:MM angeben.");
|
if (!match) {
|
||||||
|
throw new Error("Uhrzeit bitte im deutschen Format HH:MM Uhr angeben.");
|
||||||
}
|
}
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
if (hours > 23 || minutes > 59) {
|
||||||
|
throw new Error("Uhrzeit ist ungültig.");
|
||||||
|
}
|
||||||
|
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRunnerJob(body = {}, existing = {}) {
|
||||||
|
const time = normalizeRunnerTime(body.time ?? existing.time ?? "06:00");
|
||||||
return {
|
return {
|
||||||
...existing,
|
...existing,
|
||||||
id: existing.id || body.id || `job-${Date.now()}`,
|
id: existing.id || body.id || `job-${Date.now()}`,
|
||||||
@@ -246,6 +257,26 @@ async function listRunnerJobs() {
|
|||||||
}));
|
}));
|
||||||
return jobs.filter(Boolean).sort((left, right) => String(left.time).localeCompare(String(right.time)));
|
return jobs.filter(Boolean).sort((left, right) => String(left.time).localeCompare(String(right.time)));
|
||||||
}
|
}
|
||||||
|
async function listRunnerQueue() {
|
||||||
|
const entries = await redis.lrange("runner:run-now", 0, 99);
|
||||||
|
const jobs = await listRunnerJobs();
|
||||||
|
const jobsById = new Map(jobs.map((job) => [job.id, job]));
|
||||||
|
return entries.map((raw, index) => {
|
||||||
|
try {
|
||||||
|
const queued = JSON.parse(raw);
|
||||||
|
const job = jobsById.get(queued.jobId);
|
||||||
|
return {
|
||||||
|
position: index + 1,
|
||||||
|
jobId: queued.jobId || "",
|
||||||
|
jobName: job?.name || queued.jobId || "Unbekannter Job",
|
||||||
|
requestedAt: queued.requestedAt || "",
|
||||||
|
requestedBy: queued.requestedBy || "",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { position: index + 1, jobId: "", jobName: "Ungültiger Auftrag", requestedAt: "", requestedBy: "" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -803,8 +834,9 @@ app.post("/api/dashboard/data", authenticateTokenOptional, wrap(async (req, res)
|
|||||||
|
|
||||||
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
|
app.get("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (_req, res) => {
|
||||||
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
const current = JSON.parse(await redis.get("integration:api") || "{}");
|
||||||
|
const settings = sanitizeApiSettings(current);
|
||||||
res.json({
|
res.json({
|
||||||
...sanitizeApiSettings(current),
|
settings,
|
||||||
mailConfigured: Boolean(current.mailApiKey),
|
mailConfigured: Boolean(current.mailApiKey),
|
||||||
whatsappConfigured: Boolean(current.whatsappApiKey),
|
whatsappConfigured: Boolean(current.whatsappApiKey),
|
||||||
voipConfigured: Boolean(current.voipApiKey),
|
voipConfigured: Boolean(current.voipApiKey),
|
||||||
@@ -814,11 +846,16 @@ app.get("/api/integrations", authenticateToken, requirePermission("manage_integr
|
|||||||
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
|
app.put("/api/integrations", authenticateToken, requirePermission("manage_integrations"), wrap(async (req, res) => {
|
||||||
const payload = sanitizeApiSettings(req.body || {});
|
const payload = sanitizeApiSettings(req.body || {});
|
||||||
await redis.set("integration:api", JSON.stringify(payload));
|
await redis.set("integration:api", JSON.stringify(payload));
|
||||||
res.json({ ...payload, mailConfigured: Boolean(payload.mailApiKey), whatsappConfigured: Boolean(payload.whatsappApiKey), voipConfigured: Boolean(payload.voipApiKey) });
|
res.json({
|
||||||
|
settings: 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) => {
|
app.get("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (_req, res) => {
|
||||||
res.json({ jobs: await listRunnerJobs() });
|
res.json({ jobs: await listRunnerJobs(), queue: await listRunnerQueue() });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
|
app.post("/api/runner/jobs", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
|
||||||
@@ -839,6 +876,14 @@ app.patch("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manag
|
|||||||
res.json(job);
|
res.json(job);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.post("/api/runner/jobs/:jobId/test", 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." });
|
||||||
|
await redis.rpush("runner:run-now", JSON.stringify({ jobId: id, requestedAt: new Date().toISOString(), requestedBy: req.user.sub }));
|
||||||
|
res.status(202).json({ queued: true, jobId: id });
|
||||||
|
}));
|
||||||
|
|
||||||
app.delete("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
|
app.delete("/api/runner/jobs/:jobId", authenticateToken, requirePermission("manage_runner"), wrap(async (req, res) => {
|
||||||
const id = req.params.jobId;
|
const id = req.params.jobId;
|
||||||
await redis.del(`runner:job:${id}`, `runner:last:${id}`, `runner:last-result:${id}`);
|
await redis.del(`runner:job:${id}`, `runner:last:${id}`, `runner:last-result:${id}`);
|
||||||
|
|||||||
+313
-48
@@ -1,4 +1,4 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -33,7 +33,7 @@ function baseStore() {
|
|||||||
return {
|
return {
|
||||||
sources: [],
|
sources: [],
|
||||||
datapoints: [],
|
datapoints: [],
|
||||||
scans: { opcua: [], bacnet: [] },
|
scans: { opcua: [], bacnet: [], ipsymcon: [] },
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,12 +112,26 @@ function sanitizeTrendTable(value) {
|
|||||||
|
|
||||||
function normalizeProtocol(value) {
|
function normalizeProtocol(value) {
|
||||||
const protocol = String(value || "").toLowerCase();
|
const protocol = String(value || "").toLowerCase();
|
||||||
if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"].includes(protocol)) {
|
if (["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"].includes(protocol)) {
|
||||||
return protocol;
|
return protocol;
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactSourceSecrets(source) {
|
||||||
|
const options = { ...(source?.options || {}) };
|
||||||
|
const hasIpsymconApiKey = Boolean(options.ipsymconApiKey);
|
||||||
|
delete options.ipsymconApiKey;
|
||||||
|
return { ...source, options, hasIpsymconApiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactStoreSecrets(store) {
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
sources: (store.sources || []).map(redactSourceSecrets),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWriteMode(value) {
|
function normalizeWriteMode(value) {
|
||||||
return String(value || "").toLowerCase() === "cov" ? "cov" : "interval";
|
return String(value || "").toLowerCase() === "cov" ? "cov" : "interval";
|
||||||
}
|
}
|
||||||
@@ -204,6 +218,9 @@ function ensureSource(store, body) {
|
|||||||
const bacnetBroadcastAddress = normalizeText(body.bacnetBroadcastAddress || body.options?.bacnetBroadcastAddress || existing?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255";
|
const bacnetBroadcastAddress = normalizeText(body.bacnetBroadcastAddress || body.options?.bacnetBroadcastAddress || existing?.options?.bacnetBroadcastAddress || "255.255.255.255") || "255.255.255.255";
|
||||||
const bacnetForeignDeviceTtl = normalizeBacnetTtl(body.bacnetForeignDeviceTtl || body.options?.bacnetForeignDeviceTtl || existing?.options?.bacnetForeignDeviceTtl);
|
const bacnetForeignDeviceTtl = normalizeBacnetTtl(body.bacnetForeignDeviceTtl || body.options?.bacnetForeignDeviceTtl || existing?.options?.bacnetForeignDeviceTtl);
|
||||||
const bacnetScanSubnets = normalizeText(body.bacnetScanSubnets || body.options?.bacnetScanSubnets || existing?.options?.bacnetScanSubnets || "");
|
const bacnetScanSubnets = normalizeText(body.bacnetScanSubnets || body.options?.bacnetScanSubnets || existing?.options?.bacnetScanSubnets || "");
|
||||||
|
const ipsymconCategoryIds = normalizeText(body.ipsymconCategoryIds ?? body.options?.ipsymconCategoryIds ?? existing?.options?.ipsymconCategoryIds ?? "");
|
||||||
|
const requestedIpsymconApiKey = normalizeText(body.ipsymconApiKey ?? body.options?.ipsymconApiKey ?? "");
|
||||||
|
const ipsymconApiKey = requestedIpsymconApiKey || normalizeText(existing?.options?.ipsymconApiKey);
|
||||||
const tableName = sanitizeTrendTable(existing?.tableName || buildSourceTableName(body));
|
const tableName = sanitizeTrendTable(existing?.tableName || buildSourceTableName(body));
|
||||||
const source = {
|
const source = {
|
||||||
id: body.id || `src-${Date.now()}`,
|
id: body.id || `src-${Date.now()}`,
|
||||||
@@ -225,6 +242,8 @@ function ensureSource(store, body) {
|
|||||||
bacnetBroadcastAddress,
|
bacnetBroadcastAddress,
|
||||||
bacnetForeignDeviceTtl,
|
bacnetForeignDeviceTtl,
|
||||||
bacnetScanSubnets,
|
bacnetScanSubnets,
|
||||||
|
ipsymconCategoryIds,
|
||||||
|
ipsymconApiKey,
|
||||||
},
|
},
|
||||||
lastValues: body.lastValues || {},
|
lastValues: body.lastValues || {},
|
||||||
lastPollAt: body.lastPollAt || null,
|
lastPollAt: body.lastPollAt || null,
|
||||||
@@ -461,6 +480,9 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
|
|||||||
unit: existing.enabled === true ? normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind) : normalizePointUnit(scanned.unit || existing.unit, scanned.kind || existing.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,
|
||||||
|
alarmEnabled: existing.alarmEnabled === true,
|
||||||
|
alarmCondition: normalizeText(existing.alarmCondition || scanned.alarmCondition),
|
||||||
|
alarmGroupId: normalizeText(existing.alarmGroupId || scanned.alarmGroupId),
|
||||||
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
|
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
|
||||||
condition: normalizeText(existing.condition || scanned.condition),
|
condition: normalizeText(existing.condition || scanned.condition),
|
||||||
dataType: normalizeText(existing.dataType || scanned.dataType),
|
dataType: normalizeText(existing.dataType || scanned.dataType),
|
||||||
@@ -476,7 +498,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
|
|||||||
|
|
||||||
function upsertDatapoints(store, datapoints) {
|
function upsertDatapoints(store, datapoints) {
|
||||||
datapoints.forEach((datapoint) => {
|
datapoints.forEach((datapoint) => {
|
||||||
store.datapoints = store.datapoints.filter((item) => item.id !== datapoint.id && !(item.sourceId === datapoint.sourceId && item.pointIndex === datapoint.pointIndex));
|
store.datapoints = store.datapoints.filter((item) => !(item.sourceId === datapoint.sourceId && item.id === datapoint.id));
|
||||||
store.datapoints.push(datapoint);
|
store.datapoints.push(datapoint);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -795,6 +817,175 @@ async function readOpcuaValue(source, datapoint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseIpsymconCategoryIds(value) {
|
||||||
|
const unique = new Set();
|
||||||
|
for (const entry of String(value || "").split(/[;,\s]+/)) {
|
||||||
|
const id = Number(entry.trim());
|
||||||
|
if (Number.isInteger(id) && id > 0) {
|
||||||
|
unique.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIpsymconEndpoint(source) {
|
||||||
|
const rawHost = normalizeText(source?.host);
|
||||||
|
if (!rawHost) {
|
||||||
|
throw new Error("IP-Symcon Host oder URL fehlt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = /^https?:\/\//i.test(rawHost) ? rawHost : `http://${rawHost}`;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(endpoint);
|
||||||
|
} catch {
|
||||||
|
throw new Error("IP-Symcon Host oder URL ist ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(source?.port || 3777) || 3777;
|
||||||
|
if (!url.port) {
|
||||||
|
url.port = String(port);
|
||||||
|
}
|
||||||
|
const path = url.pathname.replace(/\/+$/, "");
|
||||||
|
url.pathname = !path || path === "/" ? "/hook/api" : (/\/hook\/api$/i.test(path) ? path : `${path}/hook/api`);
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestIpsymconApi(source, payload) {
|
||||||
|
const apiKey = normalizeText(source?.options?.ipsymconApiKey || source?.ipsymconApiKey);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("Für diese IP-Symcon-Quelle fehlt der API-Schlüssel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 30000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildIpsymconEndpoint(source), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
api_key: apiKey,
|
||||||
|
include_hidden: true,
|
||||||
|
follow_links: true,
|
||||||
|
stale_after_seconds: 240,
|
||||||
|
...payload,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data?.ok !== true) {
|
||||||
|
throw new Error(data?.error?.message || data?.error || `IP-Symcon API antwortet mit HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
throw new Error("IP-Symcon API hat nicht innerhalb von 30 Sekunden geantwortet.");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIpsymconVariable(source, variable, categoryId, index) {
|
||||||
|
const variableId = Number(variable?.id);
|
||||||
|
const variableType = normalizeText(variable?.variable_type || variable?.type || "");
|
||||||
|
const kind = variableType === "boolean" || typeof variable?.value === "boolean" ? "digital" : "analog";
|
||||||
|
const name = normalizeText(variable?.full_name || variable?.name || `Variable ${variableId}`) || `Variable ${variableId}`;
|
||||||
|
return {
|
||||||
|
id: `${source.id}::ipsymcon::${variableId}`,
|
||||||
|
protocol: "ipsymcon",
|
||||||
|
pointIndex: index + 1,
|
||||||
|
alias: name,
|
||||||
|
name,
|
||||||
|
address: String(variableId),
|
||||||
|
nodeId: String(variableId),
|
||||||
|
dataType: variableType ? `ipsymcon-${variableType}` : "ipsymcon-variable",
|
||||||
|
kind,
|
||||||
|
unit: normalizePointUnit(variable?.unit, kind),
|
||||||
|
currentValue: variable?.value ?? null,
|
||||||
|
quality: normalizeText(variable?.quality || "unknown"),
|
||||||
|
path: normalizeText(variable?.actual_path || ""),
|
||||||
|
categoryId: Number(categoryId) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanIpsymconSource(source) {
|
||||||
|
const categoryIds = parseIpsymconCategoryIds(source?.options?.ipsymconCategoryIds || source?.ipsymconCategoryIds);
|
||||||
|
if (!categoryIds.length) {
|
||||||
|
throw new Error("Bitte mindestens eine IP-Symcon Kategorie-ID angeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const variablesById = new Map();
|
||||||
|
const categories = [];
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
let offset = 0;
|
||||||
|
let pageCount = 0;
|
||||||
|
do {
|
||||||
|
const response = await requestIpsymconApi(source, {
|
||||||
|
function: "getcategory",
|
||||||
|
id: categoryId,
|
||||||
|
value: 0,
|
||||||
|
max_depth: 30,
|
||||||
|
limit: 1000,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
const result = response.result || {};
|
||||||
|
const variables = Array.isArray(result.variables) ? result.variables : [];
|
||||||
|
categories.push({
|
||||||
|
id: categoryId,
|
||||||
|
name: normalizeText(result.root?.name || `Kategorie ${categoryId}`),
|
||||||
|
path: normalizeText(result.root?.path || ""),
|
||||||
|
returned: variables.length,
|
||||||
|
total: Number(result.pagination?.total || variables.length),
|
||||||
|
});
|
||||||
|
for (const variable of variables) {
|
||||||
|
const variableId = Number(variable?.id);
|
||||||
|
if (Number.isInteger(variableId) && variableId > 0 && !variablesById.has(variableId)) {
|
||||||
|
variablesById.set(variableId, { variable, categoryId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pagination = result.pagination || {};
|
||||||
|
if (!pagination.has_more || variables.length === 0 || pageCount >= 20) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset = Number(pagination.next_offset);
|
||||||
|
if (!Number.isFinite(offset) || offset < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageCount += 1;
|
||||||
|
} while (variablesById.size < LOCAL_POINT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = Array.from(variablesById.values())
|
||||||
|
.slice(0, LOCAL_POINT_LIMIT)
|
||||||
|
.map(({ variable, categoryId }, index) => mapIpsymconVariable(source, variable, categoryId, index));
|
||||||
|
return {
|
||||||
|
host: buildIpsymconEndpoint(source),
|
||||||
|
categoryIds,
|
||||||
|
categories,
|
||||||
|
points,
|
||||||
|
total: variablesById.size,
|
||||||
|
truncated: variablesById.size > points.length,
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIpsymconValue(source, datapoint) {
|
||||||
|
const variableId = Number(datapoint.nodeId || datapoint.address);
|
||||||
|
if (!Number.isInteger(variableId) || variableId <= 0) {
|
||||||
|
throw new Error(`IP-Symcon Variablen-ID fehlt für ${datapoint.alias || datapoint.name}.`);
|
||||||
|
}
|
||||||
|
const response = await requestIpsymconApi(source, { function: "getvalue", id: variableId });
|
||||||
|
const raw = response.value ?? response.variable?.value;
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
return raw ? 1 : 0;
|
||||||
|
}
|
||||||
|
const numeric = Number(raw);
|
||||||
|
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : raw === null || raw === undefined ? null : String(raw);
|
||||||
|
}
|
||||||
function parseBacnetObject(datapoint) {
|
function parseBacnetObject(datapoint) {
|
||||||
const rawAddress = String(datapoint.address || "").trim();
|
const rawAddress = String(datapoint.address || "").trim();
|
||||||
const shorthand = rawAddress.match(/^(ai|ao|av|bi|bo|bv)(\d+)$/i);
|
const shorthand = rawAddress.match(/^(ai|ao|av|bi|bo|bv)(\d+)$/i);
|
||||||
@@ -1445,7 +1636,9 @@ async function readDatapointCurrent(source, datapoint) {
|
|||||||
if (source.protocol === "knx-ip") {
|
if (source.protocol === "knx-ip") {
|
||||||
return await readKnxValue(source, datapoint);
|
return await readKnxValue(source, datapoint);
|
||||||
}
|
}
|
||||||
throw new Error("Protokoll wird nicht unterstützt.");
|
if (source.protocol === "ipsymcon") {
|
||||||
|
return await readIpsymconValue(source, datapoint);
|
||||||
|
} throw new Error("Protokoll wird nicht unterstützt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollGenericSource(source, datapoints, readValue) {
|
async function pollGenericSource(source, datapoints, readValue) {
|
||||||
@@ -1596,13 +1789,32 @@ async function ensureSourceDatapointRegistryTable() {
|
|||||||
\`protocol\` VARCHAR(32) NOT NULL,
|
\`protocol\` VARCHAR(32) NOT NULL,
|
||||||
\`unit\` VARCHAR(64) NULL,
|
\`unit\` VARCHAR(64) NULL,
|
||||||
\`kind\` VARCHAR(32) NULL,
|
\`kind\` VARCHAR(32) NULL,
|
||||||
|
\`enabled\` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
\`alarm_enabled\` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
\`last_seen_at\` TIMESTAMP NULL,
|
||||||
\`created_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
\`created_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (\`datapoint_id\`),
|
PRIMARY KEY (\`datapoint_id\`),
|
||||||
UNIQUE KEY \`uniq_source_point\` (\`source_id\`, \`point_index\`),
|
KEY \`idx_source_point\` (\`source_id\`, \`point_index\`),
|
||||||
KEY \`idx_source_datapoint_table\` (\`table_name\`)
|
KEY \`idx_source_datapoint_table\` (\`table_name\`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `kind`",
|
||||||
|
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `alarm_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `enabled`",
|
||||||
|
"ALTER TABLE `source_datapoint_registry` ADD COLUMN IF NOT EXISTS `last_seen_at` TIMESTAMP NULL AFTER `alarm_enabled`",
|
||||||
|
"ALTER TABLE `source_datapoint_registry` DROP INDEX IF EXISTS `uniq_source_point`",
|
||||||
|
"ALTER TABLE `source_datapoint_registry` ADD INDEX IF NOT EXISTS `idx_source_point` (`source_id`, `point_index`)",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const statement of migrations) {
|
||||||
|
try {
|
||||||
|
await db.query(statement);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Registry-Migration übersprungen:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncDatapointRegistryEntries(datapoints) {
|
async function syncDatapointRegistryEntries(datapoints) {
|
||||||
@@ -1617,16 +1829,6 @@ async function syncDatapointRegistryEntries(datapoints) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
||||||
await db.query("DELETE FROM `source_datapoint_registry` WHERE `datapoint_id` = ? OR (`source_id` = ? AND `point_index` = ?)", [
|
|
||||||
String(datapoint.id),
|
|
||||||
String(datapoint.sourceId),
|
|
||||||
pointIndex,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (datapoint.enabled !== true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`
|
`
|
||||||
INSERT INTO \`source_datapoint_registry\` (
|
INSERT INTO \`source_datapoint_registry\` (
|
||||||
@@ -1638,8 +1840,23 @@ async function syncDatapointRegistryEntries(datapoints) {
|
|||||||
\`name\`,
|
\`name\`,
|
||||||
\`protocol\`,
|
\`protocol\`,
|
||||||
\`unit\`,
|
\`unit\`,
|
||||||
\`kind\`
|
\`kind\`,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
\`enabled\`,
|
||||||
|
\`alarm_enabled\`,
|
||||||
|
\`last_seen_at\`
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
\`source_id\` = VALUES(\`source_id\`),
|
||||||
|
\`table_name\` = VALUES(\`table_name\`),
|
||||||
|
\`point_index\` = VALUES(\`point_index\`),
|
||||||
|
\`alias\` = VALUES(\`alias\`),
|
||||||
|
\`name\` = VALUES(\`name\`),
|
||||||
|
\`protocol\` = VALUES(\`protocol\`),
|
||||||
|
\`unit\` = VALUES(\`unit\`),
|
||||||
|
\`kind\` = VALUES(\`kind\`),
|
||||||
|
\`enabled\` = VALUES(\`enabled\`),
|
||||||
|
\`alarm_enabled\` = VALUES(\`alarm_enabled\`),
|
||||||
|
\`last_seen_at\` = CURRENT_TIMESTAMP
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
String(datapoint.id),
|
String(datapoint.id),
|
||||||
@@ -1651,11 +1868,12 @@ async function syncDatapointRegistryEntries(datapoints) {
|
|||||||
String(datapoint.protocol || "unknown"),
|
String(datapoint.protocol || "unknown"),
|
||||||
normalizeUnit(datapoint.unit || ""),
|
normalizeUnit(datapoint.unit || ""),
|
||||||
String(datapoint.kind || "analog"),
|
String(datapoint.kind || "analog"),
|
||||||
|
datapoint.enabled === true ? 1 : 0,
|
||||||
|
datapoint.alarmEnabled === true ? 1 : 0,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteDatapointRegistryForSource(sourceId) {
|
async function deleteDatapointRegistryForSource(sourceId) {
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
return;
|
return;
|
||||||
@@ -1673,7 +1891,7 @@ async function rebuildRegistryTablesFromStore() {
|
|||||||
for (const source of store.sources || []) {
|
for (const source of store.sources || []) {
|
||||||
await syncSourceRegistryEntry(source);
|
await syncSourceRegistryEntry(source);
|
||||||
}
|
}
|
||||||
await syncDatapointRegistryEntries((store.datapoints || []).filter((datapoint) => datapoint.enabled === true));
|
await syncDatapointRegistryEntries(store.datapoints || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1911,6 +2129,9 @@ async function pollDueSources() {
|
|||||||
const store = readStore();
|
const store = readStore();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const source of store.sources) {
|
for (const source of store.sources) {
|
||||||
|
if (source.protocol === "ipsymcon") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (shouldPollSource(source, now)) {
|
if (shouldPollSource(source, now)) {
|
||||||
await pollSource(source, store.datapoints);
|
await pollSource(source, store.datapoints);
|
||||||
}
|
}
|
||||||
@@ -1924,6 +2145,28 @@ setInterval(() => {
|
|||||||
pollDueSources().catch((error) => console.error("Collector polling failed", error));
|
pollDueSources().catch((error) => console.error("Collector polling failed", error));
|
||||||
}, Math.max(500, pollTickMs));
|
}, Math.max(500, pollTickMs));
|
||||||
|
|
||||||
|
async function applyExternalWorkerStates(sources) {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query(
|
||||||
|
"SELECT source_id, worker, last_poll_at, last_poll_status, last_poll_error FROM source_worker_state WHERE worker = 'seltd-symcon-collector'",
|
||||||
|
);
|
||||||
|
const states = new Map(rows.map((row) => [String(row.source_id), row]));
|
||||||
|
return (sources || []).map((source) => {
|
||||||
|
const state = source.protocol === "ipsymcon" ? states.get(String(source.id)) : null;
|
||||||
|
if (!state) return source;
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
lastPollAt: state.last_poll_at ? new Date(state.last_poll_at).toISOString() : source.lastPollAt,
|
||||||
|
lastPollStatus: state.last_poll_status || source.lastPollStatus,
|
||||||
|
lastPollError: state.last_poll_error || "",
|
||||||
|
worker: state.worker,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return sources || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||||
|
|
||||||
@@ -1939,18 +2182,20 @@ const server = createServer(async (req, res) => {
|
|||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/capabilities") {
|
if (req.method === "GET" && url.pathname === "/capabilities") {
|
||||||
replyJson(res, 200, {
|
replyJson(res, 200, {
|
||||||
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
|
||||||
imports: ["modbus-csv", "knx-xml"],
|
imports: ["modbus-csv", "knx-xml"],
|
||||||
scans: ["opc-ua", "bacnet-ip"],
|
scans: ["opc-ua", "bacnet-ip", "ipsymcon"],
|
||||||
manualDatapoints: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
manualDatapoints: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
|
||||||
polling: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
polling: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
|
||||||
note: "Modbus TCP, OPC UA, BACnet IP und KNX IP werden zyklisch gelesen und in Trendtabellen geschrieben.",
|
note: "Modbus TCP, OPC UA, BACnet IP, KNX IP und IP-Symcon werden zyklisch gelesen und in Trendtabellen geschrieben.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/sources") {
|
if (req.method === "GET" && url.pathname === "/sources") {
|
||||||
replyJson(res, 200, readStore());
|
const store = readStore();
|
||||||
|
store.sources = await applyExternalWorkerStates(store.sources);
|
||||||
|
replyJson(res, 200, redactStoreSecrets(store));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1964,23 +2209,25 @@ const server = createServer(async (req, res) => {
|
|||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/debug") {
|
if (req.method === "GET" && url.pathname === "/debug") {
|
||||||
const store = readStore();
|
const store = readStore();
|
||||||
|
store.sources = await applyExternalWorkerStates(store.sources);
|
||||||
replyJson(res, 200, {
|
replyJson(res, 200, {
|
||||||
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,
|
alarmDatapointCount: store.datapoints.filter((item) => item.alarmEnabled === true).length,
|
||||||
sources: store.sources.map((source) => ({
|
sources: store.sources.map((source) => ({
|
||||||
...source,
|
...redactSourceSecrets(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,
|
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,
|
||||||
bacnet: store.scans.bacnet[0] || null,
|
bacnet: store.scans.bacnet[0] || null,
|
||||||
|
ipsymcon: store.scans.ipsymcon[0] || null,
|
||||||
},
|
},
|
||||||
driverState: {
|
driverState: {
|
||||||
pollingImplemented: true,
|
pollingImplemented: true,
|
||||||
pollingProtocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
|
pollingProtocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip", "ipsymcon"],
|
||||||
note: "Alle konfigurierten Protokollquellen pollten über native Treiber. Fehler stehen direkt an der Quelle.",
|
note: "Alle konfigurierten Protokollquellen pollten über native Treiber. Fehler stehen direkt an der Quelle.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1996,7 +2243,7 @@ const server = createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
const createdSource = next.sources.at(-1);
|
const createdSource = next.sources.at(-1);
|
||||||
await ensureSourceTrendTable(createdSource);
|
await ensureSourceTrendTable(createdSource);
|
||||||
replyJson(res, 201, createdSource);
|
replyJson(res, 201, redactSourceSecrets(createdSource));
|
||||||
} catch {
|
} catch {
|
||||||
replyJson(res, 400, { error: "Ungültige JSON-Daten." });
|
replyJson(res, 400, { error: "Ungültige JSON-Daten." });
|
||||||
}
|
}
|
||||||
@@ -2017,7 +2264,7 @@ const server = createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
const updatedSource = next.sources.find((item) => item.id === sourceId);
|
const updatedSource = next.sources.find((item) => item.id === sourceId);
|
||||||
await ensureSourceTrendTable(updatedSource);
|
await ensureSourceTrendTable(updatedSource);
|
||||||
replyJson(res, 200, updatedSource);
|
replyJson(res, 200, redactSourceSecrets(updatedSource));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replyJson(res, error.message === "not-found" ? 404 : 400, {
|
replyJson(res, error.message === "not-found" ? 404 : 400, {
|
||||||
error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.",
|
error: error.message === "not-found" ? "Quelle nicht gefunden." : "Ungültige JSON-Daten.",
|
||||||
@@ -2195,6 +2442,42 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/scan/ipsymcon") {
|
||||||
|
try {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const store = readStore();
|
||||||
|
const source = store.sources.find((item) => item.id === body.sourceId);
|
||||||
|
if (!source || source.protocol !== "ipsymcon") {
|
||||||
|
replyJson(res, 404, { error: "IP-Symcon-Quelle nicht gefunden." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await scanIpsymconSource(source);
|
||||||
|
let scannedCandidates = [];
|
||||||
|
touchStore((current) => {
|
||||||
|
const currentSource = current.sources.find((item) => item.id === source.id);
|
||||||
|
if (currentSource) {
|
||||||
|
scannedCandidates = buildScannedDatapoints(current, currentSource, result.points || [], (point, index) => ({
|
||||||
|
...point,
|
||||||
|
id: point.id || `${currentSource.id}::ipsymcon::${point.address || index + 1}`,
|
||||||
|
protocol: "ipsymcon",
|
||||||
|
pointIndex: index + 1,
|
||||||
|
alias: point.alias || point.name || `Variable ${point.address || index + 1}`,
|
||||||
|
name: point.name || point.alias || `Variable ${point.address || index + 1}`,
|
||||||
|
}));
|
||||||
|
upsertDatapoints(current, scannedCandidates);
|
||||||
|
}
|
||||||
|
current.scans.ipsymcon.unshift(result);
|
||||||
|
current.scans.ipsymcon = current.scans.ipsymcon.slice(0, 20);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
await syncDatapointRegistryEntries(scannedCandidates);
|
||||||
|
replyJson(res, 200, result);
|
||||||
|
} catch (error) {
|
||||||
|
replyJson(res, 400, { error: error.message || "IP-Symcon-Scan fehlgeschlagen." });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
if (req.method === "POST" && url.pathname === "/scan/bacnet") {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
@@ -2240,21 +2523,3 @@ rebuildRegistryTablesFromStore().catch((error) => console.error("Registry sync f
|
|||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`SE Local Trenddata collector listening on port ${port}`);
|
console.log(`SE Local Trenddata collector listening on port ${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+26
-3
@@ -1,4 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
container_name: seltd-mariadb
|
container_name: seltd-mariadb
|
||||||
@@ -102,6 +102,28 @@
|
|||||||
bacnet-collector:
|
bacnet-collector:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
|
symcon-collector:
|
||||||
|
build:
|
||||||
|
context: ./symcon-collector
|
||||||
|
container_name: seltd-symcon-collector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DB_HOST: mariadb
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: wago
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||||
|
SOURCES_FILE: /app/collector-data/sources.json
|
||||||
|
SYMCON_POLL_TICK_MS: ${SYMCON_POLL_TICK_MS:-1000}
|
||||||
|
SYMCON_MAX_CONCURRENCY: ${SYMCON_MAX_CONCURRENCY:-4}
|
||||||
|
volumes:
|
||||||
|
- collector_data:/app/collector-data:ro
|
||||||
|
depends_on:
|
||||||
|
mariadb:
|
||||||
|
condition: service_healthy
|
||||||
|
collector:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
bacnet-collector:
|
bacnet-collector:
|
||||||
build:
|
build:
|
||||||
context: ./bacnet-collector
|
context: ./bacnet-collector
|
||||||
@@ -128,7 +150,8 @@
|
|||||||
DB_USER: root
|
DB_USER: root
|
||||||
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
DB_PASSWORD: ${MARIADB_ROOT_PASSWORD:-SE3112}
|
||||||
EXPORT_DIR: /exports
|
EXPORT_DIR: /exports
|
||||||
RUNNER_TICK_MS: ${RUNNER_TICK_MS:-30000}
|
RUNNER_TICK_MS: ${RUNNER_TICK_MS:-1000}
|
||||||
|
RUNNER_TIMEZONE: ${RUNNER_TIMEZONE:-Europe/Berlin}
|
||||||
volumes:
|
volumes:
|
||||||
- runner_exports:/exports
|
- runner_exports:/exports
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -141,7 +164,7 @@
|
|||||||
container_name: seltd-dockhand
|
container_name: seltd-dockhand
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3008:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- dockhand_data:/app/data
|
- dockhand_data:/app/data
|
||||||
|
|||||||
+76
-13
@@ -1,4 +1,4 @@
|
|||||||
import dns from "node:dns";
|
import dns from "node:dns";
|
||||||
import { createReadStream, mkdirSync, statSync } from "node:fs";
|
import { createReadStream, mkdirSync, statSync } from "node:fs";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
@@ -18,26 +18,51 @@ const db = mysql.createPool({
|
|||||||
connectionLimit: 3,
|
connectionLimit: 3,
|
||||||
});
|
});
|
||||||
const exportDir = process.env.EXPORT_DIR || "/exports";
|
const exportDir = process.env.EXPORT_DIR || "/exports";
|
||||||
const tickMs = Number(process.env.RUNNER_TICK_MS || 30000);
|
const tickMs = Math.max(500, Number(process.env.RUNNER_TICK_MS || 1000));
|
||||||
|
const timeZone = process.env.RUNNER_TIMEZONE || "Europe/Berlin";
|
||||||
|
|
||||||
function safeParse(value, fallback = null) {
|
function safeParse(value, fallback = null) {
|
||||||
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
|
try { return value ? JSON.parse(value) : fallback; } catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatExportValue(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "boolean") return value ? "1" : "0";
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const numeric = Number(text);
|
||||||
|
// German decimals prevent Excel from treating values such as 24.2 as dates.
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return new Intl.NumberFormat("de-DE", { useGrouping: false, maximumFractionDigits: 10 }).format(numeric);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
function csvCell(value, delimiter) {
|
function csvCell(value, delimiter) {
|
||||||
const text = String(value ?? "");
|
const text = String(value ?? "");
|
||||||
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
|
return /["\r\n;]/.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDateParts(date = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(date);
|
||||||
|
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
|
||||||
|
}
|
||||||
|
|
||||||
function localDateKey(date = new Date()) {
|
function localDateKey(date = new Date()) {
|
||||||
const y = date.getFullYear();
|
const parts = localDateParts(date);
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function localTime(date = new Date()) {
|
function localTime(date = new Date()) {
|
||||||
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
const parts = localDateParts(date);
|
||||||
|
return `${parts.hour}:${parts.minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTableName(value) {
|
function sanitizeTableName(value) {
|
||||||
@@ -57,7 +82,11 @@ async function isNarrowTable(table) {
|
|||||||
async function getPointMeta(isp, pointIndex) {
|
async function getPointMeta(isp, pointIndex) {
|
||||||
const raw = await redis.get(`meta:isp:${isp}`);
|
const raw = await redis.get(`meta:isp:${isp}`);
|
||||||
const meta = safeParse(raw, {});
|
const meta = safeParse(raw, {});
|
||||||
const point = (meta.points || []).find((item) => Number(item.pointIndex) === Number(pointIndex));
|
const points = meta.points || {};
|
||||||
|
// Legacy metadata stores values by index, newer sources use an array.
|
||||||
|
const point = Array.isArray(points)
|
||||||
|
? points.find((item) => Number(item.pointIndex) === Number(pointIndex))
|
||||||
|
: points[String(pointIndex)] || points[Number(pointIndex)];
|
||||||
return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" };
|
return { alias: point?.alias || `Wert${pointIndex}`, unit: point?.unit || "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,14 +105,22 @@ async function exportSelection(job, selection) {
|
|||||||
`SELECT datum, alias, value FROM \`${table}\` WHERE point_index = ? AND datum >= ? ORDER BY datum ASC`,
|
`SELECT datum, alias, value FROM \`${table}\` WHERE point_index = ? AND datum >= ? ORDER BY datum ASC`,
|
||||||
[pointIndex, since],
|
[pointIndex, since],
|
||||||
);
|
);
|
||||||
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, item.value, meta.unit]);
|
for (const item of data) {
|
||||||
|
const value = formatExportValue(item.value);
|
||||||
|
if (value === null) continue;
|
||||||
|
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, item.alias || meta.alias, value, meta.unit]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const column = `Wert${pointIndex}`;
|
const column = `Wert${pointIndex}`;
|
||||||
const [data] = await db.query(
|
const [data] = await db.query(
|
||||||
`SELECT datum, \`${column}\` AS value FROM \`${table}\` WHERE datum >= ? ORDER BY datum ASC`,
|
`SELECT datum, \`${column}\` AS value FROM \`${table}\` WHERE datum >= ? ORDER BY datum ASC`,
|
||||||
[since],
|
[since],
|
||||||
);
|
);
|
||||||
for (const item of data) rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, item.value, meta.unit]);
|
for (const item of data) {
|
||||||
|
const value = formatExportValue(item.value);
|
||||||
|
if (value === null) continue;
|
||||||
|
rows.push([item.datum?.toISOString?.() || item.datum, table, pointIndex, meta.alias, value, meta.unit]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +128,8 @@ async function exportSelection(job, selection) {
|
|||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const fileName = `${selection.name || selection.id || "selection"}-${stamp}.csv`.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
const fileName = `${selection.name || selection.id || "selection"}-${stamp}.csv`.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||||
const filePath = path.join(exportDir, fileName);
|
const filePath = path.join(exportDir, fileName);
|
||||||
await writeFile(filePath, rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n"), "utf8");
|
// Excel uses the BOM to reliably recognize UTF-8 when opening CSV files directly.
|
||||||
|
await writeFile(filePath, `\uFEFF${rows.map((row) => row.map((cell) => csvCell(cell, delimiter)).join(delimiter)).join("\r\n")}`, "utf8");
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,17 +182,25 @@ async function postMultipartIpv4(url, fields, attachmentPath = "") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMailRecipients(value) {
|
||||||
|
return [...new Set(String(value || "").split(/[;,]/).map((entry) => entry.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
async function maybeMail(job, filePath) {
|
async function maybeMail(job, filePath) {
|
||||||
if (!job.mailEnabled || !job.mailTo) return;
|
if (!job.mailEnabled || !job.mailTo) return;
|
||||||
const settings = safeParse(await redis.get("integration:api"), {});
|
const settings = safeParse(await redis.get("integration:api"), {});
|
||||||
if (!settings.mailApiKey) return;
|
if (!settings.mailApiKey) return;
|
||||||
|
const recipients = parseMailRecipients(job.mailTo);
|
||||||
|
if (!recipients.length) return;
|
||||||
|
for (const address of recipients) {
|
||||||
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
|
await postMultipartIpv4(settings.mailUrl || "https://mailapi.se-inno.de", {
|
||||||
api_key: settings.mailApiKey,
|
api_key: settings.mailApiKey,
|
||||||
address: job.mailTo,
|
address,
|
||||||
subject: job.mailSubject || "SE-LTD CSV Export",
|
subject: job.mailSubject || "SE-LTD CSV Export",
|
||||||
message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.",
|
message: job.mailMessage || "Automatischer CSV Export aus SE Local Trenddata.",
|
||||||
}, filePath);
|
}, filePath);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runJob(job) {
|
async function runJob(job) {
|
||||||
const selection = safeParse(await redis.get(`selection:${job.selectionId}`));
|
const selection = safeParse(await redis.get(`selection:${job.selectionId}`));
|
||||||
@@ -164,7 +210,24 @@ async function runJob(job) {
|
|||||||
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() }));
|
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: true, filePath, ranAt: new Date().toISOString() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runQueuedJobs() {
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
const queued = safeParse(await redis.lpop("runner:run-now"));
|
||||||
|
if (!queued?.jobId) return;
|
||||||
|
const job = safeParse(await redis.get(`runner:job:${queued.jobId}`));
|
||||||
|
if (!job) continue;
|
||||||
|
try {
|
||||||
|
await runJob(job);
|
||||||
|
console.log(`Runner-Test ausgeführt: ${job.name || job.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
await redis.set(`runner:last-result:${job.id}`, JSON.stringify({ ok: false, error: error.message, ranAt: new Date().toISOString() }));
|
||||||
|
console.error(`Runner-Test fehlgeschlagen (${job.name || job.id}):`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function tick() {
|
async function tick() {
|
||||||
|
await runQueuedJobs();
|
||||||
const ids = await redis.smembers("runner:job:ids");
|
const ids = await redis.smembers("runner:job:ids");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const time = localTime(now);
|
const time = localTime(now);
|
||||||
@@ -182,6 +245,6 @@ async function tick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("SE Local Trenddata runner started");
|
console.log(`SE Local Trenddata runner started (${timeZone}, ${tickMs} ms)`);
|
||||||
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
|
setInterval(() => tick().catch((error) => console.error(error)), tickMs);
|
||||||
tick().catch((error) => console.error(error));
|
tick().catch((error) => console.error(error));
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "seltd-symcon-collector",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mysql2": "^3.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const sourcesFile = process.env.SOURCES_FILE || "/app/collector-data/sources.json";
|
||||||
|
const pollTickMs = Math.max(500, Number(process.env.SYMCON_POLL_TICK_MS || 1000));
|
||||||
|
const maxConcurrency = Math.max(1, Math.min(16, Number(process.env.SYMCON_MAX_CONCURRENCY || 4)));
|
||||||
|
const sourcePollState = new Map();
|
||||||
|
const valueCache = new Map();
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
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: Math.max(2, maxConcurrency + 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInterval(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? Math.round(number) : 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTrendTable(value) {
|
||||||
|
const table = String(value || "").toLowerCase();
|
||||||
|
if (!/^(isp[a-z0-9_]*|trend_[a-z0-9_]+)$/.test(table)) {
|
||||||
|
throw new Error("Ungültiger Trendtabellenname.");
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWorkerStateTable() {
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`source_worker_state\` (
|
||||||
|
\`source_id\` VARCHAR(191) NOT NULL,
|
||||||
|
\`worker\` VARCHAR(64) NOT NULL,
|
||||||
|
\`last_poll_at\` TIMESTAMP NULL,
|
||||||
|
\`last_poll_status\` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
\`last_poll_error\` TEXT NULL,
|
||||||
|
\`updated_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (\`source_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateWorkerState(source, status, error = "") {
|
||||||
|
await db.query(
|
||||||
|
`
|
||||||
|
INSERT INTO \`source_worker_state\` (\`source_id\`, \`worker\`, \`last_poll_at\`, \`last_poll_status\`, \`last_poll_error\`)
|
||||||
|
VALUES (?, 'seltd-symcon-collector', CURRENT_TIMESTAMP, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
\`worker\` = VALUES(\`worker\`),
|
||||||
|
\`last_poll_at\` = CURRENT_TIMESTAMP,
|
||||||
|
\`last_poll_status\` = VALUES(\`last_poll_status\`),
|
||||||
|
\`last_poll_error\` = VALUES(\`last_poll_error\`)
|
||||||
|
`,
|
||||||
|
[String(source.id), String(status || ""), String(error || "")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore() {
|
||||||
|
if (!existsSync(sourcesFile)) {
|
||||||
|
return { sources: [], datapoints: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const store = JSON.parse(readFileSync(sourcesFile, "utf8"));
|
||||||
|
return {
|
||||||
|
sources: Array.isArray(store.sources) ? store.sources : [],
|
||||||
|
datapoints: Array.isArray(store.datapoints) ? store.datapoints : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Quellenkonfiguration konnte nicht gelesen werden:", error.message);
|
||||||
|
return { sources: [], datapoints: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPoll(source, now) {
|
||||||
|
const lastPoll = sourcePollState.get(source.id) || 0;
|
||||||
|
return now - lastPoll >= normalizeInterval(source.pollIntervalSeconds) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesLogCondition(condition, value) {
|
||||||
|
const text = String(condition || "").trim();
|
||||||
|
if (!text) return true;
|
||||||
|
const numeric = Number(value);
|
||||||
|
const match = text.match(/^(?:value\s*)?(>=|<=|>|<|==|!=)\s*(-?\d+(?:[.,]\d+)?)$/i);
|
||||||
|
if (!match || !Number.isFinite(numeric)) return true;
|
||||||
|
const target = Number(match[2].replace(",", "."));
|
||||||
|
if (match[1] === ">") return numeric > target;
|
||||||
|
if (match[1] === ">=") return numeric >= target;
|
||||||
|
if (match[1] === "<") return numeric < target;
|
||||||
|
if (match[1] === "<=") return numeric <= target;
|
||||||
|
if (match[1] === "==") return numeric === target;
|
||||||
|
if (match[1] === "!=") return numeric !== target;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEndpoint(source) {
|
||||||
|
const rawHost = normalizeText(source.host);
|
||||||
|
if (!rawHost) throw new Error("IP-Symcon Host oder URL fehlt.");
|
||||||
|
const input = /^https?:\/\//i.test(rawHost) ? rawHost : `http://${rawHost}`;
|
||||||
|
const url = new URL(input);
|
||||||
|
if (!url.port) url.port = String(Number(source.port || 3777) || 3777);
|
||||||
|
const path = url.pathname.replace(/\/+$/, "");
|
||||||
|
url.pathname = !path || path === "/" ? "/hook/api" : (/\/hook\/api$/i.test(path) ? path : `${path}/hook/api`);
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestApi(source, payload) {
|
||||||
|
const apiKey = normalizeText(source.options?.ipsymconApiKey || source.ipsymconApiKey);
|
||||||
|
if (!apiKey) throw new Error("IP-Symcon API-Schlüssel fehlt.");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildEndpoint(source), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({ api_key: apiKey, ...payload }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.ok !== true) {
|
||||||
|
throw new Error(data?.error?.message || data?.error || `IP-Symcon API HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readValue(source, datapoint) {
|
||||||
|
const variableId = Number(datapoint.nodeId || datapoint.address);
|
||||||
|
if (!Number.isInteger(variableId) || variableId <= 0) {
|
||||||
|
throw new Error(`Ungültige Variablen-ID bei ${datapoint.alias || datapoint.name}.`);
|
||||||
|
}
|
||||||
|
const response = await requestApi(source, { function: "getvalue", id: variableId });
|
||||||
|
const raw = response.value ?? response.variable?.value;
|
||||||
|
if (typeof raw === "boolean") return raw ? 1 : 0;
|
||||||
|
const numeric = Number(raw);
|
||||||
|
return Number.isFinite(numeric) ? numeric * (Number(datapoint.scale || 1) || 1) : raw === null || raw === undefined ? null : String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLimit(items, limit, task) {
|
||||||
|
const result = [];
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const item = items[nextIndex++];
|
||||||
|
result.push(await task(item));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureNarrowTrendTable(tableName) {
|
||||||
|
const table = sanitizeTrendTable(tableName);
|
||||||
|
const [columns] = await db.query(
|
||||||
|
"SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
|
||||||
|
[table],
|
||||||
|
);
|
||||||
|
const names = new Set(columns.map((entry) => String(entry.name).toLowerCase()));
|
||||||
|
if (names.size && Array.from(names).some((name) => /^wert\d+$/.test(name))) {
|
||||||
|
throw new Error(`${table} ist eine Legacy-Tabelle und keine IP-Symcon-Trendtabelle.`);
|
||||||
|
}
|
||||||
|
if (!names.size) {
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE \`${table}\` (
|
||||||
|
\`id\` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
\`datum\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
\`userlevel\` INT NOT NULL DEFAULT 0,
|
||||||
|
\`source_id\` VARCHAR(191) NOT NULL,
|
||||||
|
\`point_index\` INT NOT NULL,
|
||||||
|
\`alias\` VARCHAR(255) NOT NULL,
|
||||||
|
\`value\` TEXT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_${table}_datum\` (\`datum\`),
|
||||||
|
KEY \`idx_${table}_point_datum\` (\`point_index\`, \`datum\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeReadings(source, readings) {
|
||||||
|
if (!readings.length) return 0;
|
||||||
|
const table = await ensureNarrowTrendTable(source.tableName);
|
||||||
|
const placeholders = readings.map(() => "(?, ?, ?, ?, ?)").join(", ");
|
||||||
|
const values = readings.flatMap((reading) => [0, String(source.id), Number(reading.pointIndex), String(reading.alias), String(reading.value)]);
|
||||||
|
await db.query(`INSERT INTO \`${table}\` (\`userlevel\`, \`source_id\`, \`point_index\`, \`alias\`, \`value\`) VALUES ${placeholders}`, values);
|
||||||
|
return readings.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSource(source, points) {
|
||||||
|
const errors = [];
|
||||||
|
const results = await mapLimit(points, maxConcurrency, async (datapoint) => {
|
||||||
|
try {
|
||||||
|
return { datapoint, value: await readValue(source, datapoint) };
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${datapoint.alias || datapoint.address}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const readings = [];
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result) continue;
|
||||||
|
const { datapoint, value } = result;
|
||||||
|
const key = `${source.id}:${datapoint.id}`;
|
||||||
|
const serialized = String(value);
|
||||||
|
const writeMode = datapoint.writeMode || source.writeMode || "interval";
|
||||||
|
if (matchesLogCondition(datapoint.condition, value) && (writeMode !== "cov" || valueCache.get(key) !== serialized)) {
|
||||||
|
readings.push({ pointIndex: datapoint.pointIndex, alias: datapoint.alias || datapoint.name || `Wert${datapoint.pointIndex}`, value });
|
||||||
|
}
|
||||||
|
valueCache.set(key, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = await writeReadings(source, readings);
|
||||||
|
const suffix = errors.length ? `, ${errors.length} Fehler` : "";
|
||||||
|
const status = errors.length ? `teilweise ok: ${written} Wert(e) geschrieben${suffix}` : `ok: ${written} Wert(e) geschrieben`;
|
||||||
|
await updateWorkerState(source, status, errors.slice(0, 5).join(" | "));
|
||||||
|
console.log(`[${source.name || source.id}] ${written} Wert(e) geschrieben${suffix}`);
|
||||||
|
if (errors.length) console.warn(`[${source.name || source.id}]`, errors.slice(0, 5).join(" | "));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollDueSources() {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const store = readStore();
|
||||||
|
const now = Date.now();
|
||||||
|
const sources = store.sources.filter((source) => source.protocol === "ipsymcon" && shouldPoll(source, now));
|
||||||
|
for (const source of sources) {
|
||||||
|
const points = store.datapoints.filter((point) => point.sourceId === source.id && point.protocol === "ipsymcon" && point.enabled === true);
|
||||||
|
sourcePollState.set(source.id, now);
|
||||||
|
if (!points.length) {
|
||||||
|
await updateWorkerState(source, "idle: keine Datenpunkte");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await pollSource(source, points);
|
||||||
|
} catch (error) {
|
||||||
|
await updateWorkerState(source, "error", error.message || "Polling fehlgeschlagen.").catch(() => undefined);
|
||||||
|
console.error(`[${source.name || source.id}] Polling fehlgeschlagen:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => pollDueSources().catch((error) => console.error("IP-Symcon Polling fehlgeschlagen:", error.message)), pollTickMs);
|
||||||
|
ensureWorkerStateTable()
|
||||||
|
.then(() => pollDueSources())
|
||||||
|
.catch((error) => console.error("IP-Symcon Start-Polling fehlgeschlagen:", error.message));
|
||||||
|
console.log(`SE LTD Symcon Collector gestartet (Intervall-Prüfung ${pollTickMs} ms, Parallelität ${maxConcurrency}).`);
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
+157
-47
@@ -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";
|
||||||
@@ -123,6 +123,7 @@ const PROTOCOL_DEFAULT_PORTS = {
|
|||||||
"opc-ua": "4840",
|
"opc-ua": "4840",
|
||||||
"bacnet-ip": "47808",
|
"bacnet-ip": "47808",
|
||||||
"knx-ip": "3671",
|
"knx-ip": "3671",
|
||||||
|
ipsymcon: "3777",
|
||||||
};
|
};
|
||||||
const defaultSourceDraft = {
|
const defaultSourceDraft = {
|
||||||
id: "",
|
id: "",
|
||||||
@@ -139,6 +140,8 @@ const defaultSourceDraft = {
|
|||||||
bacnetBroadcastAddress: "auto",
|
bacnetBroadcastAddress: "auto",
|
||||||
bacnetScanSubnets: "",
|
bacnetScanSubnets: "",
|
||||||
bacnetForeignDeviceTtl: "120",
|
bacnetForeignDeviceTtl: "120",
|
||||||
|
ipsymconCategoryIds: "",
|
||||||
|
ipsymconApiKey: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +193,10 @@ function getOpcuaScanDatapointId(sourceId, node) {
|
|||||||
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
|
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getIpsymconScanDatapointId(sourceId, point) {
|
||||||
|
return sourceId && point?.address ? `${sourceId}::ipsymcon::${point.address}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function getBacnetScanDatapointId(sourceId, sourceHost, point, index = 0) {
|
function getBacnetScanDatapointId(sourceId, sourceHost, point, index = 0) {
|
||||||
if (!sourceId || !point) {
|
if (!sourceId || !point) {
|
||||||
return "";
|
return "";
|
||||||
@@ -732,10 +739,11 @@ export default function App() {
|
|||||||
const [collectorPointFilter, setCollectorPointFilter] = useState("");
|
const [collectorPointFilter, setCollectorPointFilter] = useState("");
|
||||||
const [alarmPointFilter, setAlarmPointFilter] = 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, ipsymcon: null });
|
||||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||||
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
||||||
const [runnerJobs, setRunnerJobs] = useState([]);
|
const [runnerJobs, setRunnerJobs] = useState([]);
|
||||||
|
const [runnerQueue, setRunnerQueue] = useState([]);
|
||||||
const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
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]);
|
||||||
@@ -811,18 +819,36 @@ export default function App() {
|
|||||||
setIsps(nextIsps);
|
setIsps(nextIsps);
|
||||||
return nextIsps;
|
return nextIsps;
|
||||||
};
|
};
|
||||||
|
const scannedLoggedPointIds = useMemo(() => new Set(
|
||||||
|
collectorDatapoints
|
||||||
|
.filter((item) => item.sourceId === selectedSourceId && item.enabled === true)
|
||||||
|
.map((item) => String(item.id))
|
||||||
|
), [collectorDatapoints, selectedSourceId]);
|
||||||
const filteredOpcuaNodes = useMemo(
|
const filteredOpcuaNodes = useMemo(
|
||||||
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
|
() => (scanResults.opcua?.nodes || []).filter((node) => !scannedLoggedPointIds.has(getOpcuaScanDatapointId(selectedSourceId, node)) && matchesSearch(collectorPointFilter, [
|
||||||
node.displayName,
|
node.displayName,
|
||||||
node.browseName,
|
node.browseName,
|
||||||
node.nodeId,
|
node.nodeId,
|
||||||
node.path,
|
node.path,
|
||||||
node.currentValue,
|
node.currentValue,
|
||||||
])),
|
])),
|
||||||
[collectorPointFilter, scanResults.opcua]
|
[collectorPointFilter, scanResults.opcua, scannedLoggedPointIds, selectedSourceId]
|
||||||
|
);
|
||||||
|
const filteredIpsymconPoints = useMemo(
|
||||||
|
() => (scanResults.ipsymcon?.points || []).filter((point) => !scannedLoggedPointIds.has(getIpsymconScanDatapointId(selectedSourceId, point)) && matchesSearch(collectorPointFilter, [
|
||||||
|
point.alias,
|
||||||
|
point.name,
|
||||||
|
point.address,
|
||||||
|
point.nodeId,
|
||||||
|
point.path,
|
||||||
|
point.categoryId,
|
||||||
|
point.currentValue,
|
||||||
|
point.unit,
|
||||||
|
])),
|
||||||
|
[collectorPointFilter, scanResults.ipsymcon, scannedLoggedPointIds, selectedSourceId]
|
||||||
);
|
);
|
||||||
const filteredBacnetPoints = useMemo(
|
const filteredBacnetPoints = useMemo(
|
||||||
() => (scanResults.bacnet?.points || []).filter((point) => matchesSearch(collectorPointFilter, [
|
() => (scanResults.bacnet?.points || []).filter((point, index) => !scannedLoggedPointIds.has(getBacnetScanDatapointId(selectedSourceId, currentSource?.host, point, index)) && matchesSearch(collectorPointFilter, [
|
||||||
point.alias,
|
point.alias,
|
||||||
point.name,
|
point.name,
|
||||||
point.id,
|
point.id,
|
||||||
@@ -831,7 +857,7 @@ export default function App() {
|
|||||||
point.objectInstance,
|
point.objectInstance,
|
||||||
point.currentValue,
|
point.currentValue,
|
||||||
])),
|
])),
|
||||||
[collectorPointFilter, scanResults.bacnet]
|
[collectorPointFilter, currentSource?.host, scanResults.bacnet, scannedLoggedPointIds, selectedSourceId]
|
||||||
);
|
);
|
||||||
const filteredBacnetDevices = useMemo(
|
const filteredBacnetDevices = useMemo(
|
||||||
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
|
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
|
||||||
@@ -842,7 +868,7 @@ export default function App() {
|
|||||||
device.vendorId,
|
device.vendorId,
|
||||||
device.pointCount,
|
device.pointCount,
|
||||||
])),
|
])),
|
||||||
[collectorPointFilter, scanResults.bacnet]
|
[collectorPointFilter, currentSource?.host, scanResults.bacnet, scannedLoggedPointIds, selectedSourceId]
|
||||||
);
|
);
|
||||||
const filteredAvailableCollectorPoints = useMemo(
|
const filteredAvailableCollectorPoints = useMemo(
|
||||||
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
|
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
|
||||||
@@ -1000,7 +1026,7 @@ export default function App() {
|
|||||||
}, [collectorSources, isCreatingSource, selectedSourceId]);
|
}, [collectorSources, isCreatingSource, selectedSourceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setScanResults({ opcua: null, bacnet: null });
|
setScanResults({ opcua: null, bacnet: null, ipsymcon: null });
|
||||||
setCollectorPointFilter("");
|
setCollectorPointFilter("");
|
||||||
}, [selectedSourceId]);
|
}, [selectedSourceId]);
|
||||||
|
|
||||||
@@ -1123,11 +1149,12 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !canManageRunner) {
|
if (!token || !canManageRunner) {
|
||||||
setRunnerJobs([]);
|
setRunnerJobs([]);
|
||||||
|
setRunnerQueue([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.getRunnerJobs(token)
|
api.getRunnerJobs(token)
|
||||||
.then((result) => setRunnerJobs(result.jobs || []))
|
.then((result) => { setRunnerJobs(result.jobs || []); setRunnerQueue(result.queue || []); })
|
||||||
.catch((error) => setBootError(error.message));
|
.catch((error) => setBootError(error.message));
|
||||||
}, [canManageRunner, token]);
|
}, [canManageRunner, token]);
|
||||||
|
|
||||||
@@ -1187,6 +1214,8 @@ export default function App() {
|
|||||||
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
|
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
|
||||||
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
|
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
|
||||||
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
|
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
|
||||||
|
ipsymconCategoryIds: currentSource.options?.ipsymconCategoryIds || "",
|
||||||
|
ipsymconApiKey: "",
|
||||||
});
|
});
|
||||||
setManualDatapointDraft((current) => ({
|
setManualDatapointDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1722,6 +1751,8 @@ export default function App() {
|
|||||||
bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress,
|
bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress,
|
||||||
bacnetScanSubnets: sourceDraft.bacnetScanSubnets,
|
bacnetScanSubnets: sourceDraft.bacnetScanSubnets,
|
||||||
bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl,
|
bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl,
|
||||||
|
ipsymconCategoryIds: sourceDraft.ipsymconCategoryIds,
|
||||||
|
ipsymconApiKey: sourceDraft.ipsymconApiKey,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1906,6 +1937,25 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runIpsymconScan = async () => {
|
||||||
|
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||||
|
if (!activeSourceId) {
|
||||||
|
setBootError("Bitte zuerst eine IP-Symcon-Quelle auswählen oder anlegen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlash();
|
||||||
|
try {
|
||||||
|
const result = await api.scanIpsymcon({ sourceId: activeSourceId });
|
||||||
|
setScanResults((current) => ({ ...current, ipsymcon: result }));
|
||||||
|
await refreshCollectorData();
|
||||||
|
await refreshCollectorDebug().catch(() => undefined);
|
||||||
|
setSuccessMessage(`IP-Symcon-Scan abgeschlossen: ${result.points?.length || 0} Datenpunkt(e) aus ${result.categories?.length || 0} Kategorie(n).`);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectBacnetDevice = (device) => {
|
const selectBacnetDevice = (device) => {
|
||||||
const host = device.host || device.address || "";
|
const host = device.host || device.address || "";
|
||||||
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
|
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
|
||||||
@@ -2085,6 +2135,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const refreshed = await api.getRunnerJobs(token);
|
const refreshed = await api.getRunnerJobs(token);
|
||||||
setRunnerJobs(refreshed.jobs || []);
|
setRunnerJobs(refreshed.jobs || []);
|
||||||
|
setRunnerQueue(refreshed.queue || []);
|
||||||
resetRunnerDraft();
|
resetRunnerDraft();
|
||||||
setSuccessMessage("Runner-Job gespeichert.");
|
setSuccessMessage("Runner-Job gespeichert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2092,6 +2143,26 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testRunnerJob = async (job) => {
|
||||||
|
if (!window.confirm(`Test für "${job.name || job.id}" jetzt ausführen? Der CSV-Export wird sofort erstellt und bei aktivierter Mailzustellung verschickt.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetFlash();
|
||||||
|
try {
|
||||||
|
await api.testRunnerJob(token, job.id);
|
||||||
|
setSuccessMessage("Testauftrag eingereiht. Das Ergebnis wird in wenigen Sekunden am Job angezeigt.");
|
||||||
|
window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const refreshed = await api.getRunnerJobs(token);
|
||||||
|
setRunnerJobs(refreshed.jobs || []);
|
||||||
|
setRunnerQueue(refreshed.queue || []);
|
||||||
|
} catch {}
|
||||||
|
}, 1800);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteRunnerJob = async (job) => {
|
const deleteRunnerJob = async (job) => {
|
||||||
resetFlash();
|
resetFlash();
|
||||||
if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) {
|
if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) {
|
||||||
@@ -2101,6 +2172,7 @@ export default function App() {
|
|||||||
await api.deleteRunnerJob(token, job.id);
|
await api.deleteRunnerJob(token, job.id);
|
||||||
const refreshed = await api.getRunnerJobs(token);
|
const refreshed = await api.getRunnerJobs(token);
|
||||||
setRunnerJobs(refreshed.jobs || []);
|
setRunnerJobs(refreshed.jobs || []);
|
||||||
|
setRunnerQueue(refreshed.queue || []);
|
||||||
if (runnerDraft.id === job.id) {
|
if (runnerDraft.id === job.id) {
|
||||||
resetRunnerDraft();
|
resetRunnerDraft();
|
||||||
}
|
}
|
||||||
@@ -2309,7 +2381,7 @@ export default function App() {
|
|||||||
<div className={selectionListClassName}>
|
<div className={selectionListClassName}>
|
||||||
{selections.map((selection) => (
|
{selections.map((selection) => (
|
||||||
<div key={selection.id} className="stack-card">
|
<div key={selection.id} className="stack-card">
|
||||||
<button type="button" className="selection-card" onClick={() => loadSelection(selection)}>
|
<button type="button" className="selection-card saved-selection-card" onClick={() => loadSelection(selection)}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selection.name}</strong>
|
<strong>{selection.name}</strong>
|
||||||
<span>{selection.points.length} Punkte</span>
|
<span>{selection.points.length} Punkte</span>
|
||||||
@@ -2785,6 +2857,7 @@ export default function App() {
|
|||||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
||||||
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
||||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); setScanResults((current) => ({ ...current, bacnet: null })); runBacnetScan({ source, includeObjects: true }); }}>BACnet scannen</button> : null}
|
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); setScanResults((current) => ({ ...current, bacnet: null })); runBacnetScan({ source, includeObjects: true }); }}>BACnet scannen</button> : null}
|
||||||
|
{source.protocol === "ipsymcon" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runIpsymconScan(); }}>IP-Symcon scannen</button> : null}
|
||||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -2825,6 +2898,7 @@ export default function App() {
|
|||||||
{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}
|
||||||
|
{sourceToolProtocol === "ipsymcon" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runIpsymconScan}>Kategorien scannen</button></div><small className="muted">Liest die in der Quelle hinterlegten Kategorie-IDs rekursiv aus und führt doppelte Variablen zusammen.</small></> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2918,6 +2992,7 @@ export default function App() {
|
|||||||
<option value="opc-ua">OPC UA</option>
|
<option value="opc-ua">OPC UA</option>
|
||||||
<option value="bacnet-ip">BACnet IP</option>
|
<option value="bacnet-ip">BACnet IP</option>
|
||||||
<option value="knx-ip">KNX IP</option>
|
<option value="knx-ip">KNX IP</option>
|
||||||
|
<option value="ipsymcon">IP-Symcon WebHook API</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -2926,7 +3001,7 @@ export default function App() {
|
|||||||
</label>
|
</label>
|
||||||
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
|
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
|
||||||
<label>
|
<label>
|
||||||
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : "Host / IP"}</span>
|
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : sourceDraft.protocol === "ipsymcon" ? "IP-Symcon Host / URL" : "Host / IP"}</span>
|
||||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2944,10 +3019,12 @@ export default function App() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sourceDraft.protocol !== "ipsymcon" ? (
|
||||||
<label>
|
<label>
|
||||||
<span>{"Geräte-ID"}</span>
|
<span>{"Geräte-ID"}</span>
|
||||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
|
) : null}
|
||||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
|
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
|
||||||
<label>
|
<label>
|
||||||
<span>Broadcast-Adresse</span>
|
<span>Broadcast-Adresse</span>
|
||||||
@@ -2967,6 +3044,19 @@ export default function App() {
|
|||||||
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
|
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sourceDraft.protocol === "ipsymcon" ? (
|
||||||
|
<>
|
||||||
|
<label className="span-two">
|
||||||
|
<span>Kategorie-IDs</span>
|
||||||
|
<input value={sourceDraft.ipsymconCategoryIds || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, ipsymconCategoryIds: event.target.value }))} placeholder="z.B. 25724; 25725; 25726" />
|
||||||
|
<small className="muted">Mehrere IDs mit Komma, Semikolon oder Leerzeichen trennen. Die Kategorien werden beim Scan zusammengeführt.</small>
|
||||||
|
</label>
|
||||||
|
<label className="span-two">
|
||||||
|
<span>API-Schlüssel</span>
|
||||||
|
<input type="password" autoComplete="new-password" value={sourceDraft.ipsymconApiKey || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, ipsymconApiKey: event.target.value }))} placeholder={currentSource?.hasIpsymconApiKey ? "Schlüssel gespeichert - nur zum Ersetzen eingeben" : "API-Schlüssel eingeben"} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<label className="span-two">
|
<label className="span-two">
|
||||||
<span>Technischer Tabellenname</span>
|
<span>Technischer Tabellenname</span>
|
||||||
<input value={sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)} readOnly />
|
<input value={sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)} readOnly />
|
||||||
@@ -3035,7 +3125,7 @@ export default function App() {
|
|||||||
<div className="panel-header compact-header nested-gap">
|
<div className="panel-header compact-header nested-gap">
|
||||||
<div>
|
<div>
|
||||||
<h3>Gefunden / importiert</h3>
|
<h3>Gefunden / importiert</h3>
|
||||||
<p>Links landen Scan-Ergebnisse und importierte Punkte. Von hier schiebst du Werte ins Logging.</p>
|
<p>Links landen Scan-Ergebnisse und importierte Punkte. Bereits geloggte Werte sind ausgeblendet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3066,6 +3156,25 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filteredIpsymconPoints.slice(0, 2000).map((point, index) => {
|
||||||
|
const pointId = getIpsymconScanDatapointId(selectedSourceId, point);
|
||||||
|
const linkedPoint = collectorSourcePointById.get(pointId);
|
||||||
|
const isLogged = linkedPoint?.enabled === true || loggedCollectorPointIds.has(pointId);
|
||||||
|
return (
|
||||||
|
<div key={pointId || `${point.address}-${index}`} className="datapoint-row inactive">
|
||||||
|
<div className="datapoint-main">
|
||||||
|
<strong>{normalizeDisplayText(point.alias || point.name || `Variable ${point.address}`)}</strong>
|
||||||
|
<span>{`${normalizeDisplayText(point.path || "IP-Symcon")} · ID ${point.address}${point.unit ? ` · ${normalizeDisplayUnit(point.unit)}` : ""}`}</span>
|
||||||
|
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
|
||||||
|
</div>
|
||||||
|
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
|
||||||
|
<div className="button-row wrap-tight">
|
||||||
|
{linkedPoint ? <button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(linkedPoint)}>Lesen</button> : null}
|
||||||
|
<button className="primary-button compact-button" type="button" disabled={!linkedPoint || isLogged} onClick={() => linkedPoint && updateCollectorPoint(linkedPoint, { enabled: true })}>{isLogged ? "Im Logging" : "Ins Logging"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{filteredBacnetPoints.slice(0, 160).map((point, index) => {
|
{filteredBacnetPoints.slice(0, 160).map((point, index) => {
|
||||||
const pointId = getBacnetScanDatapointId(selectedSourceId, currentSource?.host, point, index);
|
const pointId = getBacnetScanDatapointId(selectedSourceId, currentSource?.host, point, index);
|
||||||
const linkedPoint = collectorSourcePointById.get(pointId);
|
const linkedPoint = collectorSourcePointById.get(pointId);
|
||||||
@@ -3085,7 +3194,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(!scanResults.opcua && !scanResults.bacnet) && filteredAvailableCollectorPoints.length ? filteredAvailableCollectorPoints.slice(0, 2000).map((item) => (
|
{(!filteredOpcuaNodes.length && !filteredIpsymconPoints.length && !filteredBacnetPoints.length) && filteredAvailableCollectorPoints.length ? filteredAvailableCollectorPoints.slice(0, 2000).map((item) => (
|
||||||
<div key={item.id} className="datapoint-row inactive">
|
<div key={item.id} className="datapoint-row inactive">
|
||||||
<div className="datapoint-main">
|
<div className="datapoint-main">
|
||||||
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
||||||
@@ -3100,7 +3209,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : null}
|
)) : null}
|
||||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredIpsymconPoints.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3376,14 +3485,17 @@ export default function App() {
|
|||||||
<label>
|
<label>
|
||||||
<span>Mail API-Key</span>
|
<span>Mail API-Key</span>
|
||||||
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
||||||
|
<small className="muted">{integrationSettings.mailApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>WhatsApp API-Key</span>
|
<span>WhatsApp API-Key</span>
|
||||||
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
||||||
|
<small className="muted">{integrationSettings.whatsappApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Telefon API-Key</span>
|
<span>Telefon API-Key</span>
|
||||||
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
||||||
|
<small className="muted">{integrationSettings.phoneApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
||||||
@@ -3413,8 +3525,9 @@ export default function App() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Uhrzeit</span>
|
<span>Uhrzeit (deutsch)</span>
|
||||||
<input type="time" value={runnerDraft.time} onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
|
<input value={runnerDraft.time} inputMode="numeric" placeholder="z.B. 06:30" onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
|
||||||
|
<small className="muted">Format HH:MM Uhr, Zeitzone Europe/Berlin.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Zeitraum</span>
|
<span>Zeitraum</span>
|
||||||
@@ -3446,7 +3559,8 @@ export default function App() {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Mail an</span>
|
<span>Mail an</span>
|
||||||
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com" />
|
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com; service@example.com" />
|
||||||
|
<small className="muted">Mehrere Empfänger mit Semikolon oder Komma trennen.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Betreff</span>
|
<span>Betreff</span>
|
||||||
@@ -3462,17 +3576,21 @@ export default function App() {
|
|||||||
<button className="primary-button compact-button" type="button" onClick={saveRunnerJob}>{runnerDraft.id ? "Runner-Job speichern" : "Runner-Job anlegen"}</button>
|
<button className="primary-button compact-button" type="button" onClick={saveRunnerJob}>{runnerDraft.id ? "Runner-Job speichern" : "Runner-Job anlegen"}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="selection-list compact source-list">
|
<div className="runner-status-grid">
|
||||||
|
<section className="runner-status-panel">
|
||||||
|
<div className="runner-status-header"><h4>Vorhandene Runner</h4><span>{runnerJobs.length}</span></div>
|
||||||
|
<div className="runner-job-list">
|
||||||
{runnerJobs.map((job) => {
|
{runnerJobs.map((job) => {
|
||||||
const selection = selections.find((item) => item.id === job.selectionId);
|
const selection = selections.find((item) => item.id === job.selectionId);
|
||||||
return (
|
return (
|
||||||
<div key={job.id} className="selection-card source-card preview-card">
|
<div key={job.id} className="runner-job-row">
|
||||||
<div>
|
<div className="runner-job-copy">
|
||||||
<strong>{normalizeDisplayText(job.name, "Runner-Job")}</strong>
|
<strong>{normalizeDisplayText(job.name, "Runner-Job")}</strong>
|
||||||
<span>{`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} · ${job.enabled === false ? "pausiert" : "aktiv"}`}</span>
|
<span>{`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} Uhr · ${job.enabled === false ? "pausiert" : "aktiv"}`}</span>
|
||||||
<small>{job.lastRunAt ? `letzter Lauf ${formatTimestampLabel(job.lastRunAt)}` : "noch nicht gelaufen"}</small>
|
<small>{job.lastResult ? (job.lastResult.ok ? `Letzter Lauf: ${formatTimestampLabel(job.lastResult.ranAt)}` : `Fehler: ${normalizeDisplayText(job.lastResult.error, "unbekannt")}`) : "Noch nicht gelaufen"}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row wrap-tight">
|
<div className="button-row wrap-tight runner-job-actions">
|
||||||
|
<button className="ghost-button compact-button" type="button" onClick={() => testRunnerJob(job)}>Test</button>
|
||||||
<button className="ghost-button compact-button" type="button" onClick={() => editRunnerJob(job)}>Bearbeiten</button>
|
<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>
|
<button className="ghost-button compact-button danger" type="button" onClick={() => deleteRunnerJob(job)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3481,6 +3599,22 @@ export default function App() {
|
|||||||
})}
|
})}
|
||||||
{!runnerJobs.length ? <div className="empty-state slim-empty"><p>Noch kein automatischer Export angelegt.</p></div> : null}
|
{!runnerJobs.length ? <div className="empty-state slim-empty"><p>Noch kein automatischer Export angelegt.</p></div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="runner-status-panel">
|
||||||
|
<div className="runner-status-header"><h4>Warteschlange</h4><span>{runnerQueue.length}</span></div>
|
||||||
|
<div className="runner-queue-table">
|
||||||
|
<div className="runner-queue-head"><span>#</span><span>Job</span><span>Eingereiht</span></div>
|
||||||
|
{runnerQueue.map((entry) => (
|
||||||
|
<div key={`${entry.position}-${entry.jobId}-${entry.requestedAt}`} className="runner-queue-row">
|
||||||
|
<span>{entry.position}</span>
|
||||||
|
<strong>{normalizeDisplayText(entry.jobName, "Unbekannter Job")}</strong>
|
||||||
|
<small>{entry.requestedAt ? formatTimestampLabel(entry.requestedAt) : "-"}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!runnerQueue.length ? <div className="empty-state slim-empty"><p>Keine Aufträge warten auf die Ausführung.</p></div> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{adminSection === "debug" && canViewDebug ? (
|
{adminSection === "debug" && canViewDebug ? (
|
||||||
@@ -3604,27 +3738,3 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,4 @@
|
|||||||
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
||||||
const explicit = String(explicitEnv || "").trim();
|
const explicit = String(explicitEnv || "").trim();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return explicit.replace(/\/$/, "") + suffix;
|
return explicit.replace(/\/$/, "") + suffix;
|
||||||
@@ -85,6 +85,7 @@ export const api = {
|
|||||||
getRunnerJobs: (token) => apiRequest("/runner/jobs", { token }),
|
getRunnerJobs: (token) => apiRequest("/runner/jobs", { token }),
|
||||||
createRunnerJob: (token, body) => apiRequest("/runner/jobs", { method: "POST", token, body }),
|
createRunnerJob: (token, body) => apiRequest("/runner/jobs", { method: "POST", token, body }),
|
||||||
updateRunnerJob: (token, id, body) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "PATCH", token, body }),
|
updateRunnerJob: (token, id, body) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "PATCH", token, body }),
|
||||||
|
testRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}/test`, { method: "POST", token }),
|
||||||
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
||||||
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
||||||
getCollectorSources: () => collectorRequest("/sources"),
|
getCollectorSources: () => collectorRequest("/sources"),
|
||||||
@@ -100,5 +101,5 @@ export const api = {
|
|||||||
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
||||||
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 }),
|
||||||
|
scanIpsymcon: (body) => collectorRequest("/scan/ipsymcon", { method: "POST", body }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+177
-5
@@ -1,8 +1,9 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f4f6fb;
|
--bg: #f4f6fb;
|
||||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
--bg-accent: linear-gradient(180deg, rgba(248, 251, 255, 0.24) 0%, rgba(238, 243, 251, 0.32) 100%);
|
||||||
--surface: rgba(255, 255, 255, 0.96);
|
--page-image: url("/bg-whitemode.png");
|
||||||
|
--surface: rgba(255, 255, 255, 0.91);
|
||||||
--surface-strong: #ffffff;
|
--surface-strong: #ffffff;
|
||||||
--text: #18212f;
|
--text: #18212f;
|
||||||
--muted: #667085;
|
--muted: #667085;
|
||||||
@@ -23,8 +24,9 @@
|
|||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0e1624;
|
--bg: #0e1624;
|
||||||
--bg-accent: linear-gradient(180deg, #101827 0%, #0b1320 100%);
|
--bg-accent: linear-gradient(180deg, rgba(16, 24, 39, 0.2) 0%, rgba(11, 19, 32, 0.3) 100%);
|
||||||
--surface: rgba(16, 24, 38, 0.96);
|
--page-image: url("/bg-darkmode.png");
|
||||||
|
--surface: rgba(16, 24, 38, 0.92);
|
||||||
--surface-strong: #121b2a;
|
--surface-strong: #121b2a;
|
||||||
--text: #edf2f7;
|
--text: #edf2f7;
|
||||||
--muted: #94a3b8;
|
--muted: #94a3b8;
|
||||||
@@ -52,7 +54,11 @@ body,
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg-accent);
|
background-color: var(--bg);
|
||||||
|
background-image: var(--bg-accent), var(--page-image);
|
||||||
|
background-position: center, center;
|
||||||
|
background-size: cover, cover;
|
||||||
|
background-attachment: fixed, fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -1405,3 +1411,169 @@ span {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runner-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-status-panel {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, var(--accent) 8%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-status-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.46rem 0.58rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-status-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-status-header > span {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 1.35rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-list,
|
||||||
|
.runner-queue-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 230px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-row,
|
||||||
|
.runner-queue-row,
|
||||||
|
.runner-queue-head {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.42rem 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-row:last-child,
|
||||||
|
.runner-queue-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-copy strong,
|
||||||
|
.runner-job-copy span,
|
||||||
|
.runner-job-copy small,
|
||||||
|
.runner-queue-row strong,
|
||||||
|
.runner-queue-row small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-copy strong,
|
||||||
|
.runner-queue-row strong {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-copy span,
|
||||||
|
.runner-job-copy small,
|
||||||
|
.runner-queue-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-queue-head,
|
||||||
|
.runner-queue-row {
|
||||||
|
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-queue-head {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-queue-row {
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-queue-row > span {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.runner-status-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runner-job-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.saved-selection-card {
|
||||||
|
min-height: 36px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.38rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-selection-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-selection-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-selection-copy .selection-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.08rem 0.32rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user