API added

This commit is contained in:
jhartworks
2026-07-24 13:43:47 +02:00
parent 19d27f9de9
commit 986a43d5a5
12 changed files with 1112 additions and 146 deletions
+53 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY src ./src
CMD ["npm", "start"]
+12
View File
@@ -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"
}
}
+265
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "./api.js"; import { api } from "./api.js";
import Gauge from "./components/Gauge.jsx"; import Gauge from "./components/Gauge.jsx";
@@ -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
View File
@@ -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
View File
@@ -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;
}