This commit is contained in:
jhartworks
2026-06-23 14:48:36 +02:00
parent a445fef521
commit f0cb94ce4e
8 changed files with 796 additions and 81 deletions
-1
View File
@@ -1,6 +1,5 @@
node_modules
dist
.env
.DS_Store
npm-debug.log*
.scratch/runtime
+141 -24
View File
@@ -20,10 +20,13 @@ import {
deleteSelection,
getDashboard,
getPreferences,
getRoleMap,
getSelection,
listRoles,
listSelections,
saveDashboard,
savePreferences,
saveRole,
saveSelection,
} from "./lib/store.js";
import { redis } from "./redis.js";
@@ -111,6 +114,70 @@ function normalizeSqlDump(sql) {
.trim();
}
const PERMISSION_CATALOG = [
{ id: "view_dashboard", label: "Dashboard anzeigen" },
{ id: "view_trends", label: "Trendansicht anzeigen" },
{ id: "edit_aliases", label: "Aliase bearbeiten" },
{ id: "manage_sources", label: "Quellen verwalten" },
{ id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Treiber-Debug anzeigen" },
];
async function getUserPermissions(roleName) {
const roleMap = await getRoleMap(redis);
return roleMap.get(String(roleName || "").trim())?.permissions || [];
}
async function serializeUserWithPermissions(row) {
const user = serializeUser(row);
return {
...user,
permissions: await getUserPermissions(user.role),
};
}
function requirePermission(...permissions) {
return async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Authentication required." });
}
const granted = new Set(await getUserPermissions(req.user.role));
if (!permissions.some((permission) => granted.has(permission))) {
return res.status(403).json({ error: "Insufficient permissions." });
}
next();
} catch (error) {
next(error);
}
};
}
function normalizeRoleName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "viewer";
}
async function resolveExistingRole(roleName, fallback = "viewer") {
const roleMap = await getRoleMap(redis);
const normalized = normalizeRoleName(roleName);
return roleMap.has(normalized) ? normalized : fallback;
}
async function ensureRolesSchema() {
await listRoles(redis);
try {
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
} catch {}
}
async function resolveSelectionPoints(body, user) {
if (body.selectionId) {
const selection = await getSelection(redis, body.selectionId);
@@ -150,7 +217,7 @@ async function ensureUserSchema() {
name VARCHAR(120) NOT NULL,
email VARCHAR(160) NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('viewer', 'technician', 'admin') NOT NULL DEFAULT 'viewer',
role VARCHAR(80) NOT NULL DEFAULT 'viewer',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -193,6 +260,10 @@ async function ensureUserSchema() {
try {
await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)");
} catch {}
try {
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
} catch {}
}
async function ensureAdminUser() {
@@ -255,7 +326,7 @@ app.post("/api/auth/login", wrap(async (req, res) => {
return res.status(401).json({ error: "Ungültige Zugangsdaten." });
}
const serializedUser = serializeUser(user);
const serializedUser = await serializeUserWithPermissions(user);
return res.json({
token: createToken(serializedUser),
@@ -296,7 +367,7 @@ app.get("/api/auth/me", authenticateToken, wrap(async (req, res) => {
return res.status(404).json({ error: "Benutzer nicht gefunden." });
}
return res.json({ user: serializeUser(rows[0]) });
return res.json({ user: await serializeUserWithPermissions(rows[0]) });
}));
app.get("/api/isps", authenticateToken, wrap(async (_req, res) => {
@@ -323,7 +394,7 @@ app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => {
app.put(
"/api/isps/:isp/aliases",
authenticateToken,
requireRoles("technician", "admin"),
requirePermission("edit_aliases"),
wrap(async (req, res) => {
const { displayName, description, points } = req.body || {};
const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => {
@@ -359,7 +430,7 @@ app.put(
})
);
app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.post("/api/trend-tables", authenticateToken, requirePermission("manage_sources"), wrap(async (req, res) => {
const rawName = String(req.body.tableName || "").trim().toLowerCase();
const displayName = String(req.body.displayName || rawName).trim();
const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`);
@@ -373,7 +444,7 @@ app.post("/api/trend-tables", authenticateToken, requireRoles("admin"), wrap(asy
app.post(
"/api/admin/normalize-alias-metadata",
authenticateToken,
requireRoles("technician", "admin"),
requirePermission("edit_aliases"),
wrap(async (req, res) => {
const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null;
@@ -398,7 +469,7 @@ app.post(
app.post(
"/api/admin/import-sql",
authenticateToken,
requireRoles("admin"),
requirePermission("manage_sources"),
wrap(async (req, res) => {
const sql = normalizeSqlDump(req.body.sql);
@@ -412,22 +483,26 @@ app.post(
})
);
app.get("/api/users", authenticateToken, requireRoles("admin"), wrap(async (_req, res) => {
app.get("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (_req, res) => {
const [rows] = await pool.query(
"SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username"
);
res.json({ users: rows.map(serializeUser) });
}));
app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.post("/api/users", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
const username = String(req.body.username || "").trim().toLowerCase();
const name = String(req.body.name || "").trim();
const email = String(req.body.email || "").trim().toLowerCase() || null;
const role = ["viewer", "technician", "admin"].includes(req.body.role) ? req.body.role : "viewer";
const password = String(req.body.password || "");
const role = await resolveExistingRole(req.body.role, "viewer");
if (!username || !name || password.length < 8) {
return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." });
if (!username || !name) {
return res.status(400).json({ error: "Username und Name sind erforderlich." });
}
if (password.length < 8) {
return res.status(400).json({ error: "Das Passwort muss mindestens 8 Zeichen lang sein." });
}
const passwordHash = await bcrypt.hash(password, 10);
@@ -444,7 +519,7 @@ app.post("/api/users", authenticateToken, requireRoles("admin"), wrap(async (req
res.status(201).json({ user: serializeUser(rows[0]) });
}));
app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async (req, res) => {
app.patch("/api/users/:id", authenticateToken, requirePermission("manage_users"), wrap(async (req, res) => {
const userId = Number(req.params.id);
const payload = req.body || {};
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
@@ -457,7 +532,11 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
const username = typeof payload.username === "string" ? payload.username.trim().toLowerCase() || current.username : current.username;
const name = typeof payload.name === "string" ? payload.name.trim() || current.name : current.name;
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() || null : current.email;
const role = ["viewer", "technician", "admin"].includes(payload.role) ? payload.role : current.role;
const nextRole = payload.role ? await resolveExistingRole(payload.role, current.role) : current.role;
if (userId === req.user.sub && payload.role && nextRole !== current.role) {
return res.status(403).json({ error: "Die eigene Rolle kann nicht geändert werden." });
}
const active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash;
@@ -467,19 +546,54 @@ app.patch("/api/users/:id", authenticateToken, requireRoles("admin"), wrap(async
SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ?
WHERE id = ?
`,
[username, name, email, role, active, passwordHash, userId]
[username, name, email, nextRole, active, passwordHash, userId]
);
const [updatedRows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
res.json({ user: serializeUser(updatedRows[0]) });
}));
app.get("/api/selections", authenticateToken, wrap(async (req, res) => {
app.get("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (_req, res) => {
const roles = await listRoles(redis);
res.json({ roles, permissions: PERMISSION_CATALOG });
}));
app.post("/api/roles", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
const payload = req.body || {};
const name = normalizeRoleName(payload.name);
if (!name) {
return res.status(400).json({ error: "Rollenname fehlt." });
}
const role = await saveRole(redis, {
name,
label: String(payload.label || name).trim() || name,
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],
});
res.status(201).json({ role });
}));
app.patch("/api/roles/:name", authenticateToken, requirePermission("manage_roles"), wrap(async (req, res) => {
const roleMap = await getRoleMap(redis);
const existing = roleMap.get(normalizeRoleName(req.params.name));
if (!existing) {
return res.status(404).json({ error: "Rolle nicht gefunden." });
}
const role = await saveRole(redis, {
...existing,
label: String(req.body.label || existing.label).trim() || existing.label,
permissions: Array.isArray(req.body.permissions) ? req.body.permissions : existing.permissions,
}, existing.name);
res.json({ role });
}));
app.get("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const selections = await listSelections(redis, req.user);
res.json({ selections });
}));
app.post("/api/selections", authenticateToken, wrap(async (req, res) => {
app.post("/api/selections", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = assertPoints(req.body.points || []);
const selection = await saveSelection(redis, {
ownerId: req.user.sub,
@@ -493,7 +607,7 @@ app.post("/api/selections", authenticateToken, wrap(async (req, res) => {
res.status(201).json({ selection });
}));
app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => {
app.put("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
@@ -520,7 +634,7 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res)
res.json({ selection });
}));
app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, res) => {
app.delete("/api/selections/:selectionId", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const existing = await getSelection(redis, req.params.selectionId);
if (!existing) {
@@ -535,29 +649,29 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r
res.status(204).send();
}));
app.post("/api/trends/query", authenticateToken, wrap(async (req, res) => {
app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user);
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range));
res.json(trendData);
}));
app.post("/api/points/latest", authenticateToken, wrap(async (req, res) => {
app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user);
const values = await fetchLatestValues(pool, redis, points);
res.json({ values });
}));
app.get("/api/dashboard", authenticateToken, wrap(async (req, res) => {
app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const dashboard = await getDashboard(redis, req.user.sub);
res.json(dashboard);
}));
app.put("/api/dashboard", authenticateToken, wrap(async (req, res) => {
app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const dashboard = await saveDashboard(redis, req.user.sub, req.body || {});
res.json(dashboard);
}));
app.post("/api/dashboard/data", authenticateToken, wrap(async (req, res) => {
app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => {
const widgets = Array.isArray(req.body.widgets) ? req.body.widgets : [];
const results = [];
@@ -593,9 +707,12 @@ app.use((error, _req, res, _next) => {
await ensureUtf8Database();
await ensureUserSchema();
await ensureRolesSchema();
await ensureAdminUser();
app.listen(config.port, () => {
console.log(`SE Local Trenddata API listening on port ${config.port}`);
});
+113
View File
@@ -1,10 +1,123 @@
const SELECTION_SET_KEY = "selection:ids";
const SELECTION_SEQ_KEY = "selection:seq";
const ROLE_SET_KEY = "role:names";
export const DEFAULT_ROLE_DEFINITIONS = [
{
name: "viewer",
label: "Viewer",
permissions: ["view_dashboard", "view_trends"],
isSystem: true,
},
{
name: "technician",
label: "Techniker",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "view_debug"],
isSystem: true,
},
{
name: "admin",
label: "Admin",
permissions: ["view_dashboard", "view_trends", "edit_aliases", "manage_sources", "manage_users", "manage_roles", "view_debug"],
isSystem: true,
},
];
function safeParse(jsonValue) {
return jsonValue ? JSON.parse(jsonValue) : null;
}
function normalizeRoleName(value) {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return normalized || "viewer";
}
function normalizePermissions(input) {
return [...new Set((Array.isArray(input) ? input : [])
.map((entry) => String(entry || "").trim())
.filter(Boolean))].sort();
}
function normalizeRoleRecord(role) {
const systemRole = DEFAULT_ROLE_DEFINITIONS.find((entry) => entry.name === normalizeRoleName(role?.name));
return {
name: normalizeRoleName(role?.name),
label: String(role?.label || systemRole?.label || role?.name || "Rolle").trim() || systemRole?.label || "Rolle",
permissions: normalizePermissions(role?.permissions || systemRole?.permissions || []),
isSystem: Boolean(role?.isSystem ?? systemRole?.isSystem),
updatedAt: role?.updatedAt || new Date().toISOString(),
};
}
async function ensureDefaultRoles(redis) {
for (const definition of DEFAULT_ROLE_DEFINITIONS) {
const key = `role:${definition.name}`;
const existing = safeParse(await redis.get(key));
const payload = normalizeRoleRecord({
...definition,
...(existing || {}),
name: definition.name,
label: existing?.label || definition.label,
permissions: Array.isArray(existing?.permissions) && existing.permissions.length ? existing.permissions : definition.permissions,
isSystem: true,
});
await redis.set(key, JSON.stringify(payload));
await redis.sadd(ROLE_SET_KEY, definition.name);
}
}
export async function listRoles(redis) {
await ensureDefaultRoles(redis);
const names = await redis.smembers(ROLE_SET_KEY);
const items = await Promise.all(names.map((name) => redis.get(`role:${name}`)));
return items
.map((item) => safeParse(item))
.filter(Boolean)
.map((role) => normalizeRoleRecord(role))
.sort((left, right) => left.label.localeCompare(right.label, "de-DE"));
}
export async function getRoleMap(redis) {
const roles = await listRoles(redis);
return new Map(roles.map((role) => [role.name, role]));
}
export async function saveRole(redis, role, previousName = "") {
await ensureDefaultRoles(redis);
const existingName = normalizeRoleName(previousName || role?.name);
const payload = normalizeRoleRecord(role);
const current = safeParse(await redis.get(`role:${existingName}`));
if (current?.isSystem && existingName !== payload.name) {
throw new Error("Systemrollen können nicht umbenannt werden.");
}
const merged = normalizeRoleRecord({
...(current || {}),
...payload,
name: current?.isSystem ? existingName : payload.name,
isSystem: Boolean(current?.isSystem || payload.isSystem),
updatedAt: new Date().toISOString(),
});
if (existingName && existingName !== merged.name) {
await redis.del(`role:${existingName}`);
await redis.srem(ROLE_SET_KEY, existingName);
}
await redis.set(`role:${merged.name}`, JSON.stringify(merged));
await redis.sadd(ROLE_SET_KEY, merged.name);
return merged;
}
export async function listSelections(redis, user) {
const ids = await redis.smembers(SELECTION_SET_KEY);
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`)));
+116 -6
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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -102,6 +102,27 @@ function normalizePollInterval(value) {
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60;
}
function normalizePointKind(value) {
return String(value || "").toLowerCase() === "digital" ? "digital" : "analog";
}
function normalizeUnit(value) {
const raw = String(value ?? "").trim();
if (!raw) {
return "°C";
}
return raw
.replace(/°/g, "°")
.replace(/^\?C$/i, "°C")
.replace(/^°\s*C$/i, "°C");
}
function normalizePointIndex(value, fallback) {
const numeric = Number(value);
return Number.isInteger(numeric) && numeric >= 1 && numeric <= 200 ? numeric : fallback;
}
function ensureSource(store, body) {
const now = new Date().toISOString();
const source = {
@@ -120,6 +141,9 @@ function ensureSource(store, body) {
writeMode: normalizeWriteMode(body.writeMode),
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
},
lastPollAt: body.lastPollAt || null,
lastPollStatus: body.lastPollStatus || "idle",
lastPollError: body.lastPollError || "",
createdAt: body.createdAt || now,
updatedAt: now,
};
@@ -155,16 +179,19 @@ function parseModbusCsv(payload) {
return columnIndex >= 0 ? row[columnIndex] || fallback : fallback;
};
const address = get("address", get("register", String(index + 1)));
const pointIndex = normalizePointIndex(get("pointindex", index + 1), index + 1);
return {
id: `dp-modbus-${Date.now()}-${index}`,
sourceId: payload.sourceId || "",
protocol: "modbus-tcp",
address: get("address", get("register", String(index + 1))),
pointIndex: Number(get("pointindex", index + 1)) || index + 1,
address,
pointIndex,
name: get("name", `Register ${index + 1}`),
alias: get("alias", get("name", `Register ${index + 1}`)),
unit: get("unit"),
unit: normalizeUnit(get("unit")),
dataType: get("datatype", "holding-register"),
kind: normalizePointKind(get("kind", get("type", "analog"))),
scale: Number(get("scale", 1)) || 1,
host: payload.host || "",
tableName: payload.tableName || "",
@@ -209,8 +236,9 @@ function parseKnxXml(payload) {
pointIndex: index + 1,
name,
alias: name,
unit: "",
unit: normalizeUnit(""),
dataType: dpt || "group-address",
kind: dpt.startsWith("1.") ? "digital" : "analog",
host: payload.host || "",
tableName: payload.tableName || "",
createdAt: new Date().toISOString(),
@@ -222,6 +250,44 @@ function parseKnxXml(payload) {
return datapoints;
}
function createManualDatapoint(store, payload) {
const source = store.sources.find((item) => item.id === payload.sourceId);
if (!source) {
throw new Error("Quelle nicht gefunden.");
}
const existing = store.datapoints.filter((item) => item.sourceId === source.id);
const fallbackIndex = existing.length + 1;
const pointIndex = normalizePointIndex(payload.pointIndex, fallbackIndex);
const protocol = normalizeProtocol(payload.protocol || source.protocol);
const alias = String(payload.alias || payload.name || `Punkt ${pointIndex}`).trim() || `Punkt ${pointIndex}`;
const name = String(payload.name || payload.alias || alias).trim() || alias;
const datapoint = {
id: payload.id || `dp-${protocol}-${Date.now()}-${pointIndex}`,
sourceId: source.id,
protocol,
pointIndex,
name,
alias,
unit: normalizeUnit(payload.unit),
kind: normalizePointKind(payload.kind),
dataType: String(payload.dataType || (protocol === "knx-ip" ? "group-address" : "holding-register")).trim(),
address: String(payload.address || payload.register || "").trim(),
nodeId: String(payload.nodeId || "").trim(),
scale: Number(payload.scale || 1) || 1,
host: source.host || "",
tableName: source.tableName || "",
createdAt: new Date().toISOString(),
};
if (!datapoint.address && protocol !== "opc-ua") {
datapoint.address = String(pointIndex);
}
return datapoint;
}
function tryTcpConnect(host, portNumber, timeout = 1200) {
return new Promise((resolve) => {
if (!host || !portNumber) {
@@ -265,7 +331,8 @@ const server = createServer(async (req, res) => {
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
imports: ["modbus-csv", "knx-xml"],
scans: ["opc-ua", "bacnet-ip"],
note: "Importe und erste Scan-Stubs sind aktiv. Poll-Intervall und Schreibmodus werden bereits pro Quelle gespeichert; die echte Live-Kommunikation folgt als nächste Ausbaustufe.",
manualDatapoints: ["modbus-tcp", "knx-ip"],
note: "Importe, manuelle Datenpunkte und Diagnose sind aktiv. Live-Polling und echte Feldbus-Kommunikation sind noch im nächsten Ausbauschritt.",
});
return;
}
@@ -283,6 +350,28 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "GET" && url.pathname === "/debug") {
const store = readStore();
replyJson(res, 200, {
updatedAt: store.updatedAt,
sourceCount: store.sources.length,
datapointCount: store.datapoints.length,
sources: store.sources.map((source) => ({
...source,
datapointCount: store.datapoints.filter((item) => item.sourceId === source.id).length,
})),
latestScans: {
opcua: store.scans.opcua[0] || null,
bacnet: store.scans.bacnet[0] || null,
},
driverState: {
pollingImplemented: false,
note: "Der Collector speichert Quellen, Importe und Scan-Ergebnisse bereits. Echtes zyklisches Polling auf Bus-Ebene ist aktuell noch nicht implementiert.",
},
});
return;
}
if (req.method === "POST" && url.pathname === "/sources") {
try {
const body = await readBody(req);
@@ -329,6 +418,23 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && url.pathname === "/datapoints/manual") {
try {
const body = await readBody(req);
const next = touchStore((store) => {
const datapoint = createManualDatapoint(store, body);
store.datapoints = store.datapoints.filter((item) => !(item.sourceId === datapoint.sourceId && item.pointIndex === datapoint.pointIndex));
store.datapoints.push(datapoint);
return store;
});
const created = next.datapoints.find((item) => item.sourceId === body.sourceId && item.pointIndex === normalizePointIndex(body.pointIndex, 1));
replyJson(res, 201, { ok: true, datapoint: created || null, updatedAt: next.updatedAt });
} catch (error) {
replyJson(res, 400, { error: error.message || "Datenpunkt konnte nicht angelegt werden." });
}
return;
}
if (req.method === "POST" && url.pathname === "/imports/modbus-csv") {
try {
const body = await readBody(req);
@@ -369,6 +475,10 @@ const server = createServer(async (req, res) => {
try {
const body = await readBody(req);
const host = body.host || "";
if (!host) {
replyJson(res, 400, { error: "Für den OPC-UA-Scan fehlt Host oder IP." });
return;
}
const portNumber = Number(body.port || 4840);
const reachable = await tryTcpConnect(host, portNumber);
const result = {
+4 -4
View File
@@ -1,13 +1,13 @@
<!doctype html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SE Trendportal</title>
<title>SE-LTD</title>
<link rel="icon" type="image/png" href="/logo.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>
+371 -21
View File
@@ -463,12 +463,16 @@ export default function App() {
const [trendResult, setTrendResult] = useState(null);
const [latestValues, setLatestValues] = useState([]);
const [selections, setSelections] = useState([]);
const [selectedSelectionId, setSelectedSelectionId] = useState("");
const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft);
const [dashboard, setDashboard] = useState({ widgets: [] });
const [dashboardData, setDashboardData] = useState({});
const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft);
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [rolePermissions, setRolePermissions] = useState(PERMISSION_OPTIONS);
const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" });
const [roleDraft, setRoleDraft] = useState({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] });
const [themeMode, setThemeMode] = useState("system");
const [csvDelimiter, setCsvDelimiter] = useState(";");
const [sidebarPinned, setSidebarPinned] = useState(true);
@@ -484,15 +488,24 @@ export default function App() {
const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
const canEditAliases = user?.role === "admin" || user?.role === "technician";
const canManageUsers = user?.role === "admin";
const canEditAliases = hasPermission(user, "edit_aliases");
const canManageUsers = hasPermission(user, "manage_users");
const canManageRoles = hasPermission(user, "manage_roles");
const canViewDebug = hasPermission(user, "view_debug");
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
const currentView = views.find((view) => view.id === activeView) || views[0];
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
const roleOptions = roles.length ? roles : [
{ name: "viewer", label: "Viewer" },
{ name: "technician", label: "Techniker" },
{ name: "admin", label: "Admin" },
];
const currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]);
const collectorSourcePoints = useMemo(() => (
selectedSourceId
@@ -677,7 +690,7 @@ export default function App() {
}, [dashboard, token]);
useEffect(() => {
if (!token || user?.role !== "admin") {
if (!token || !canManageUsers) {
setUsers([]);
return;
}
@@ -685,10 +698,24 @@ export default function App() {
api.getUsers(token)
.then((result) => setUsers(result.users))
.catch((error) => setBootError(error.message));
}, [token, user]);
}, [canManageUsers, token]);
useEffect(() => {
if (!token || !canEditAliases) {
if (!token || !canManageRoles) {
setRoles([]);
return;
}
api.getRoles(token)
.then((result) => {
setRoles(result.roles || []);
setRolePermissions(result.permissions || PERMISSION_OPTIONS);
})
.catch((error) => setBootError(error.message));
}, [canManageRoles, token]);
useEffect(() => {
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
setCollectorCapabilities(null);
setCollectorSources([]);
setCollectorDatapoints([]);
@@ -700,7 +727,7 @@ export default function App() {
.catch((error) => setBootError(error.message));
refreshCollectorData().catch((error) => setBootError(error.message));
}, [canEditAliases, token]);
}, [canEditAliases, canManageSources, canViewDebug, token]);
useEffect(() => {
if (!currentSource) {
@@ -719,13 +746,45 @@ export default function App() {
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
});
setManualDatapointDraft((current) => ({
...current,
dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register",
}));
}, [currentSource]);
useEffect(() => {
if (!canManageUsers && adminSection === "users") {
setAdminSection("aliases");
return;
}
}, [adminSection, canManageUsers]);
if (!canManageRoles && adminSection === "roles") {
setAdminSection("aliases");
return;
}
if (!canViewDebug && adminSection === "debug") {
setAdminSection("aliases");
}
}, [activeView, adminSection, canAccessAdmin, canManageRoles, canManageUsers, canViewDashboard, canViewDebug, canViewTrends]);
useEffect(() => {
if (!selectedSelectionId) {
return;
}
if (!selections.some((selection) => selection.id === selectedSelectionId)) {
setSelectedSelectionId("");
}
}, [selectedSelectionId, selections]);
useEffect(() => {
if (!token || !canViewDebug || adminSection !== "debug") {
return;
}
refreshCollectorDebug().catch((error) => setBootError(error.message));
}, [adminSection, canViewDebug, token]);
const handleLogin = async (credentials) => {
setBusy(true);
@@ -771,6 +830,7 @@ export default function App() {
};
const startNewSelection = () => {
setSelectedSelectionId("");
setWorkingPoints([]);
setSelectionDraft(defaultSelectionDraft);
setTrendResult(null);
@@ -780,6 +840,7 @@ export default function App() {
};
const loadSelection = (selection) => {
setSelectedSelectionId(selection.id);
setWorkingPoints(selection.points);
setSelectionDraft({
name: selection.name,
@@ -840,6 +901,10 @@ export default function App() {
});
const refreshed = await api.getSelections(token);
setSelections(refreshed.selections);
const savedSelection = refreshed.selections.find((selection) => selection.name === (selectionDraft.name || "Neue Auswahl")) || refreshed.selections[0];
if (savedSelection) {
setSelectedSelectionId(savedSelection.id);
}
setSuccessMessage("Auswahl gespeichert.");
} catch (error) {
setBootError(error.message);
@@ -897,6 +962,11 @@ export default function App() {
setCollectorDatapoints(datapointResult.datapoints || []);
};
const refreshCollectorDebug = async () => {
const result = await api.getCollectorDebug();
setDebugSnapshot(result);
};
const updateAliasPoint = (pointIndex, changes) => {
setAliasEditor((current) => ({
...current,
@@ -1038,6 +1108,7 @@ export default function App() {
const startNewSource = () => {
setIsCreatingSource(true);
setSelectedSourceId("");
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
setSourceDraft({
...defaultSourceDraft,
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
@@ -1084,6 +1155,7 @@ export default function App() {
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
const refreshed = await api.getIsps(token);
setIsps(refreshed.isps);
setIsCreatingSource(false);
@@ -1103,6 +1175,7 @@ export default function App() {
try {
await api.deleteCollectorSource(selectedSourceId);
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setIsCreatingSource(false);
setSelectedSourceId("");
setSourceDraft(defaultSourceDraft);
@@ -1112,6 +1185,44 @@ export default function App() {
}
};
const downloadModbusTemplate = () => {
const sample = [
"pointIndex;address;name;alias;unit;datatype;kind;scale",
"1;40001;Vorlauf;Vorlauf;°C;holding-register;analog;1",
"2;40002;Freigabe;Freigabe;;coil;digital;1",
].join("\n");
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
};
const saveManualCollectorDatapoint = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
await api.createCollectorDatapoint({
sourceId: activeSourceId,
protocol: sourceDraft.protocol,
alias: manualDatapointDraft.alias,
name: manualDatapointDraft.alias,
address: manualDatapointDraft.address,
pointIndex: manualDatapointDraft.pointIndex,
unit: manualDatapointDraft.unit || "°C",
dataType: manualDatapointDraft.dataType,
kind: manualDatapointDraft.kind,
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: sourceDraft.protocol === "knx-ip" ? "group-address" : "holding-register", kind: "analog" });
setSuccessMessage("Datenpunkt angelegt.");
} catch (error) {
setBootError(error.message);
}
};
const importCollectorFile = async (event, type) => {
const file = event.target.files?.[0];
if (!file) {
@@ -1140,6 +1251,7 @@ export default function App() {
}
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert.");
} catch (error) {
setBootError(error.message);
@@ -1156,6 +1268,7 @@ export default function App() {
port: sourceDraft.port || "4840",
});
setScanResults((current) => ({ ...current, opcua: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("OPC-UA-Scan abgeschlossen.");
} catch (error) {
setBootError(error.message);
@@ -1175,6 +1288,7 @@ export default function App() {
deviceId: sourceDraft.deviceId,
});
setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen.");
} catch (error) {
setBootError(error.message);
@@ -1184,8 +1298,12 @@ export default function App() {
const createUser = async () => {
resetFlash();
try {
await api.createUser(token, userDraft);
setUserDraft({ name: "", username: "", password: "", role: "viewer" });
await api.createUser(token, {
...userDraft,
name: userDraft.name.trim(),
username: userDraft.username.trim(),
});
setUserDraft({ name: "", username: "", password: "", role: roleOptions.find((role) => role.name === "viewer")?.name || "viewer" });
const refreshed = await api.getUsers(token);
setUsers(refreshed.users);
setSuccessMessage("Benutzer angelegt.");
@@ -1194,6 +1312,35 @@ export default function App() {
}
};
const saveRoleDefinition = async () => {
resetFlash();
try {
const payload = {
name: roleDraft.name.trim(),
label: roleDraft.label.trim() || roleDraft.name.trim(),
permissions: roleDraft.permissions,
};
if (!payload.name) {
setBootError("Bitte zuerst einen Rollennamen vergeben.");
return;
}
const existing = roles.find((role) => role.name === payload.name);
if (existing) {
await api.updateRole(token, payload.name, payload);
} else {
await api.createRole(token, payload);
}
const refreshed = await api.getRoles(token);
setRoles(refreshed.roles || []);
setRolePermissions(refreshed.permissions || PERMISSION_OPTIONS);
setSuccessMessage("Rolle gespeichert.");
} catch (error) {
setBootError(error.message);
}
};
const updateUser = async (targetUser, changes) => {
resetFlash();
try {
@@ -1262,7 +1409,18 @@ export default function App() {
</div>
<nav className="nav-list">
{views.filter((view) => view.id !== "admin" || canEditAliases).map((view) => (
{views.filter((view) => {
if (view.id === "dashboard") {
return canViewDashboard;
}
if (view.id === "trends") {
return canViewTrends;
}
if (view.id === "admin") {
return canAccessAdmin;
}
return true;
}).map((view) => (
<button
key={view.id}
type="button"
@@ -1515,6 +1673,26 @@ export default function App() {
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
</div>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
@@ -1574,7 +1752,18 @@ export default function App() {
{activeView === "admin" ? (
<section className="admin-section">
<div className="admin-subnav">
{adminSections.filter((section) => section.id !== "users" || canManageUsers).map((section) => (
{adminSections.filter((section) => {
if (section.id === "users") {
return canManageUsers;
}
if (section.id === "roles") {
return canManageRoles;
}
if (section.id === "debug") {
return canViewDebug;
}
return true;
}).map((section) => (
<button
key={section.id}
type="button"
@@ -1586,7 +1775,7 @@ export default function App() {
))}
</div>
{adminSection === "aliases" ? (
{adminSection === "aliases" && canEditAliases ? (
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
@@ -1652,7 +1841,7 @@ export default function App() {
</div>
) : null}
{adminSection === "sources" ? (
{adminSection === "sources" && (canManageSources || canEditAliases) ? (
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
@@ -1777,6 +1966,7 @@ export default function App() {
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
@@ -1785,6 +1975,42 @@ export default function App() {
<small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
</div>
{(sourceDraft.protocol === "modbus-tcp" || sourceDraft.protocol === "knx-ip") ? (
<div className="protocol-block">
<strong>Einzelner Datenpunkt</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{sourceDraft.protocol === "knx-ip" ? "Gruppenadresse" : "Adresse / Register"}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="200" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
</label>
<label>
<span>Einheit</span>
<UnitField value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
</div>
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
</div>
) : null}
<div className="protocol-block">
<strong>OPC UA</strong>
<div className="button-row wrap-tight">
@@ -1834,7 +2060,7 @@ export default function App() {
<div className="panel-header compact-header nested-gap">
<div>
<h3>Benutzer</h3>
<p>Login erfolgt über Username statt E-Mail. Rollen können nachträglich geändert werden.</p>
<p>Login erfolgt über Username statt E-Mail. Die eigene Rolle bleibt gesperrt.</p>
</div>
</div>
<div className="form-grid compact-forms">
@@ -1853,9 +2079,7 @@ export default function App() {
<label>
<span>Rolle</span>
<select value={userDraft.role} onChange={(event) => setUserDraft((current) => ({ ...current, role: event.target.value }))}>
<option value="viewer">viewer</option>
<option value="technician">technician</option>
<option value="admin">admin</option>
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
</select>
</label>
</div>
@@ -1868,11 +2092,10 @@ export default function App() {
<strong>{listedUser.name}</strong>
<span>@{listedUser.username}</span>
</div>
<select value={listedUser.role} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
<option value="viewer">viewer</option>
<option value="technician">technician</option>
<option value="admin">admin</option>
<select value={listedUser.role} disabled={listedUser.id === user?.id} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
</select>
{listedUser.id === user?.id ? <small className="muted">Eigene Rolle gesperrt</small> : null}
<label className="checkbox-field compact-check">
<input type="checkbox" checked={listedUser.active} onChange={(event) => updateUser(listedUser, { active: event.target.checked })} />
<span>aktiv</span>
@@ -1882,6 +2105,111 @@ export default function App() {
</div>
</div>
) : null}
{adminSection === "roles" && canManageRoles ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Rollen</h3>
<p>Neue Rollen anlegen oder vorhandene Rechte gezielt anpassen.</p>
</div>
</div>
<div className="selection-list compact">
{roles.map((role) => (
<button
key={role.name}
type="button"
className={roleDraft.name === role.name ? "selection-card source-card active" : "selection-card source-card"}
onClick={() => setRoleDraft({ name: role.name, label: role.label, permissions: role.permissions || [] })}
>
<div>
<strong>{role.label}</strong>
<span>{role.name}</span>
</div>
<span>{(role.permissions || []).length} Rechte</span>
</button>
))}
</div>
<div className="form-grid compact-forms">
<label>
<span>Rollenname</span>
<input value={roleDraft.name} readOnly={Boolean(roles.find((role) => role.name === roleDraft.name)?.isSystem)} onChange={(event) => setRoleDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Anzeigename</span>
<input value={roleDraft.label} onChange={(event) => setRoleDraft((current) => ({ ...current, label: event.target.value }))} />
</label>
</div>
<div className="selection-list compact">
{rolePermissions.map((permission) => (
<label key={permission.id} className="checkbox-field compact-check">
<input
type="checkbox"
checked={roleDraft.permissions.includes(permission.id)}
onChange={(event) => setRoleDraft((current) => ({
...current,
permissions: event.target.checked
? current.permissions.concat(permission.id).filter((value, index, list) => list.indexOf(value) === index)
: current.permissions.filter((entry) => entry !== permission.id),
}))}
/>
<span>{permission.label}</span>
</label>
))}
</div>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => setRoleDraft({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] })}>Neue Rolle</button>
<button className="primary-button compact-button" type="button" onClick={saveRoleDefinition}>Rolle speichern</button>
</div>
</div>
) : null}
{adminSection === "debug" && canViewDebug ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Treiber-Debug</h3>
<p>Status von Quellen, Datenpunkten und letzten Scans.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={refreshCollectorDebug}>Aktualisieren</button>
</div>
<div className="grid two compact-grid">
<div className="panel nested-panel">
<h3>Collector</h3>
<p>{debugSnapshot?.driverState?.note || "Noch keine Debug-Daten geladen."}</p>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>Quellen</strong><span>{debugSnapshot?.sourceCount || 0}</span></div>
<div className="selection-card source-card preview-card"><strong>Datenpunkte</strong><span>{debugSnapshot?.datapointCount || 0}</span></div>
<div className="selection-card source-card preview-card"><strong>Letztes Update</strong><span>{debugSnapshot?.updatedAt ? formatTimestampLabel(debugSnapshot.updatedAt) : "-"}</span></div>
</div>
</div>
<div className="panel nested-panel">
<h3>Letzte Scans</h3>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>OPC UA</strong><span>{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte</span></div>
</div>
</div>
</div>
<div className="selection-list compact source-list">
{(debugSnapshot?.sources || []).map((source) => (
<div key={source.id} className="selection-card source-card preview-card">
<div>
<strong>{source.name}</strong>
<span>{source.protocol} · {source.host || "ohne Host"}</span>
</div>
<span>{source.datapointCount} DP</span>
</div>
))}
</div>
</div>
) : null}
</section>
) : null}
@@ -1963,3 +2291,25 @@ export default function App() {
+5
View File
@@ -62,6 +62,9 @@ export const api = {
getUsers: (token) => apiRequest("/users", { token }),
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", token, body }),
getRoles: (token) => apiRequest("/roles", { token }),
createRole: (token, body) => apiRequest("/roles", { method: "POST", token, body }),
updateRole: (token, name, body) => apiRequest(`/roles/${encodeURIComponent(name)}`, { method: "PATCH", token, body }),
getSelections: (token) => apiRequest("/selections", { token }),
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", token, body }),
@@ -79,6 +82,8 @@ export const api = {
createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
createCollectorDatapoint: (body) => collectorRequest("/datapoints/manual", { method: "POST", body }),
getCollectorDebug: () => collectorRequest("/debug"),
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
+46 -25
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { jsPDF } from "jspdf";
import * as XLSX from "xlsx";
import {
@@ -32,6 +32,31 @@ function buildRandomColor(index) {
return `hsl(${hue} ${saturation}% ${lightness}%)`;
}
function getAnalogDomain(rows, visibleSeries) {
const values = [];
rows.forEach((row) => {
visibleSeries.filter((item) => item.kind !== "digital").forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
}
});
});
if (!values.length) {
return [0, 1];
}
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [min - 1, max + 1];
}
const padding = (max - min) * 0.08;
return [min - padding, max + padding];
}
export default function TrendChart({
result,
title = "Trendansicht",
@@ -66,6 +91,8 @@ export default function TrendChart({
})), [result]);
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series;
const analogSeries = visibleSeries.filter((item) => item.kind !== "digital");
const digitalSeries = visibleSeries.filter((item) => item.kind === "digital");
const baseXDomain = useMemo(() => {
if (!rows.length) {
@@ -75,29 +102,11 @@ export default function TrendChart({
}, [rows]);
const baseYDomain = useMemo(() => {
const values = [];
rows.forEach((row) => {
visibleSeries.forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
if (analogSeries.length === 0 && digitalSeries.length > 0) {
return [0, 2];
}
});
});
if (!values.length) {
return [0, 1];
}
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [min - 1, max + 1];
}
const padding = (max - min) * 0.08;
return [min - padding, max + padding];
}, [rows, visibleSeries]);
return getAnalogDomain(rows, visibleSeries);
}, [analogSeries.length, digitalSeries.length, rows, visibleSeries]);
const xDomain = zoomDomain?.x || baseXDomain;
const yDomain = zoomDomain?.y || baseYDomain;
@@ -375,7 +384,18 @@ export default function TrendChart({
minTickGap={28}
stroke="var(--muted)"
/>
<YAxis stroke="var(--muted)" domain={yDomain} width={52} />
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} /> : null}
{digitalSeries.length ? (
<YAxis
yAxisId="digital"
orientation="right"
domain={[0, 2]}
ticks={[0, 1]}
width={46}
stroke="var(--muted)"
allowDecimals={false}
/>
) : null}
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
contentStyle={{
@@ -401,7 +421,8 @@ export default function TrendChart({
return (
<Line
key={entry.key}
type="monotone"
yAxisId={entry.kind === "digital" ? "digital" : "analog"}
type={entry.kind === "digital" ? "stepAfter" : "monotone"}
dataKey={entry.key}
dot={false}
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}