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 node_modules
dist dist
.env
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
.scratch/runtime .scratch/runtime
+141 -24
View File
@@ -20,10 +20,13 @@ import {
deleteSelection, deleteSelection,
getDashboard, getDashboard,
getPreferences, getPreferences,
getRoleMap,
getSelection, getSelection,
listRoles,
listSelections, listSelections,
saveDashboard, saveDashboard,
savePreferences, savePreferences,
saveRole,
saveSelection, saveSelection,
} from "./lib/store.js"; } from "./lib/store.js";
import { redis } from "./redis.js"; import { redis } from "./redis.js";
@@ -111,6 +114,70 @@ function normalizeSqlDump(sql) {
.trim(); .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) { 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);
@@ -150,7 +217,7 @@ async function ensureUserSchema() {
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
email VARCHAR(160) NULL, email VARCHAR(160) NULL,
password_hash VARCHAR(255) NOT 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, active TINYINT(1) NOT NULL DEFAULT 1,
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,
@@ -193,6 +260,10 @@ async function ensureUserSchema() {
try { try {
await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)"); await pool.query("ALTER TABLE app_users ADD UNIQUE KEY uniq_app_users_email (email)");
} catch {} } catch {}
try {
await pool.query("ALTER TABLE app_users MODIFY role VARCHAR(80) NOT NULL DEFAULT 'viewer'");
} catch {}
} }
async function ensureAdminUser() { 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." }); return res.status(401).json({ error: "Ungültige Zugangsdaten." });
} }
const serializedUser = serializeUser(user); const serializedUser = await serializeUserWithPermissions(user);
return res.json({ return res.json({
token: createToken(serializedUser), 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.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) => { 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( app.put(
"/api/isps/:isp/aliases", "/api/isps/:isp/aliases",
authenticateToken, authenticateToken,
requireRoles("technician", "admin"), requirePermission("edit_aliases"),
wrap(async (req, res) => { wrap(async (req, res) => {
const { displayName, description, points } = req.body || {}; const { displayName, description, points } = req.body || {};
const updated = await updateIspMetadata(redis, req.params.isp, (metadata) => { 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 rawName = String(req.body.tableName || "").trim().toLowerCase();
const displayName = String(req.body.displayName || rawName).trim(); const displayName = String(req.body.displayName || rawName).trim();
const safeName = await createWideTrendTable(pool, rawName.startsWith("trend_") ? rawName : `trend_${rawName}`); 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( app.post(
"/api/admin/normalize-alias-metadata", "/api/admin/normalize-alias-metadata",
authenticateToken, authenticateToken,
requireRoles("technician", "admin"), requirePermission("edit_aliases"),
wrap(async (req, res) => { wrap(async (req, res) => {
const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null; const targetIsp = req.body?.isp ? sanitizeIspName(req.body.isp) : null;
@@ -398,7 +469,7 @@ app.post(
app.post( app.post(
"/api/admin/import-sql", "/api/admin/import-sql",
authenticateToken, authenticateToken,
requireRoles("admin"), requirePermission("manage_sources"),
wrap(async (req, res) => { wrap(async (req, res) => {
const sql = normalizeSqlDump(req.body.sql); 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( const [rows] = await pool.query(
"SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username" "SELECT id, username, name, email, role, active, created_at, updated_at FROM app_users ORDER BY username"
); );
res.json({ users: rows.map(serializeUser) }); 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 username = String(req.body.username || "").trim().toLowerCase();
const name = String(req.body.name || "").trim(); const name = String(req.body.name || "").trim();
const email = String(req.body.email || "").trim().toLowerCase() || null; 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 password = String(req.body.password || "");
const role = await resolveExistingRole(req.body.role, "viewer");
if (!username || !name || password.length < 8) { if (!username || !name) {
return res.status(400).json({ error: "Username, Name und Passwort sind erforderlich." }); 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); 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]) }); 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 userId = Number(req.params.id);
const payload = req.body || {}; const payload = req.body || {};
const [rows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]); 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 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 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 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 active = typeof payload.active === "boolean" ? Number(payload.active) : current.active;
const passwordHash = payload.password ? await bcrypt.hash(String(payload.password), 10) : current.password_hash; 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 = ? SET username = ?, name = ?, email = ?, role = ?, active = ?, password_hash = ?
WHERE id = ? 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]); const [updatedRows] = await pool.query("SELECT * FROM app_users WHERE id = ? LIMIT 1", [userId]);
res.json({ user: serializeUser(updatedRows[0]) }); 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); const selections = await listSelections(redis, req.user);
res.json({ selections }); 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 points = assertPoints(req.body.points || []);
const selection = await saveSelection(redis, { const selection = await saveSelection(redis, {
ownerId: req.user.sub, ownerId: req.user.sub,
@@ -493,7 +607,7 @@ app.post("/api/selections", authenticateToken, wrap(async (req, res) => {
res.status(201).json({ selection }); 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); const existing = await getSelection(redis, req.params.selectionId);
if (!existing) { if (!existing) {
@@ -520,7 +634,7 @@ app.put("/api/selections/:selectionId", authenticateToken, wrap(async (req, res)
res.json({ selection }); 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); const existing = await getSelection(redis, req.params.selectionId);
if (!existing) { if (!existing) {
@@ -535,29 +649,29 @@ app.delete("/api/selections/:selectionId", authenticateToken, wrap(async (req, r
res.status(204).send(); 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 points = await resolveSelectionPoints(req.body, req.user);
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range)); const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range));
res.json(trendData); 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 points = await resolveSelectionPoints(req.body, req.user);
const values = await fetchLatestValues(pool, redis, points); const values = await fetchLatestValues(pool, redis, points);
res.json({ values }); 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); const dashboard = await getDashboard(redis, req.user.sub);
res.json(dashboard); 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 || {}); const dashboard = await saveDashboard(redis, req.user.sub, req.body || {});
res.json(dashboard); 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 widgets = Array.isArray(req.body.widgets) ? req.body.widgets : [];
const results = []; const results = [];
@@ -593,9 +707,12 @@ app.use((error, _req, res, _next) => {
await ensureUtf8Database(); await ensureUtf8Database();
await ensureUserSchema(); await ensureUserSchema();
await ensureRolesSchema();
await ensureAdminUser(); await ensureAdminUser();
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`SE Local Trenddata API listening on port ${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_SET_KEY = "selection:ids";
const SELECTION_SEQ_KEY = "selection:seq"; 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) { function safeParse(jsonValue) {
return jsonValue ? JSON.parse(jsonValue) : null; 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) { export async function listSelections(redis, user) {
const ids = await redis.smembers(SELECTION_SET_KEY); const ids = await redis.smembers(SELECTION_SET_KEY);
const items = await Promise.all(ids.map((id) => redis.get(`selection:${id}`))); 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 { 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";
@@ -102,6 +102,27 @@ function normalizePollInterval(value) {
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : 60; 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) { function ensureSource(store, body) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const source = { const source = {
@@ -120,6 +141,9 @@ function ensureSource(store, body) {
writeMode: normalizeWriteMode(body.writeMode), writeMode: normalizeWriteMode(body.writeMode),
pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds), pollIntervalSeconds: normalizePollInterval(body.pollIntervalSeconds),
}, },
lastPollAt: body.lastPollAt || null,
lastPollStatus: body.lastPollStatus || "idle",
lastPollError: body.lastPollError || "",
createdAt: body.createdAt || now, createdAt: body.createdAt || now,
updatedAt: now, updatedAt: now,
}; };
@@ -155,16 +179,19 @@ function parseModbusCsv(payload) {
return columnIndex >= 0 ? row[columnIndex] || fallback : fallback; 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 { return {
id: `dp-modbus-${Date.now()}-${index}`, id: `dp-modbus-${Date.now()}-${index}`,
sourceId: payload.sourceId || "", sourceId: payload.sourceId || "",
protocol: "modbus-tcp", protocol: "modbus-tcp",
address: get("address", get("register", String(index + 1))), address,
pointIndex: Number(get("pointindex", index + 1)) || index + 1, pointIndex,
name: get("name", `Register ${index + 1}`), name: get("name", `Register ${index + 1}`),
alias: get("alias", 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"), dataType: get("datatype", "holding-register"),
kind: normalizePointKind(get("kind", get("type", "analog"))),
scale: Number(get("scale", 1)) || 1, scale: Number(get("scale", 1)) || 1,
host: payload.host || "", host: payload.host || "",
tableName: payload.tableName || "", tableName: payload.tableName || "",
@@ -209,8 +236,9 @@ function parseKnxXml(payload) {
pointIndex: index + 1, pointIndex: index + 1,
name, name,
alias: name, alias: name,
unit: "", unit: normalizeUnit(""),
dataType: dpt || "group-address", dataType: dpt || "group-address",
kind: dpt.startsWith("1.") ? "digital" : "analog",
host: payload.host || "", host: payload.host || "",
tableName: payload.tableName || "", tableName: payload.tableName || "",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -222,6 +250,44 @@ function parseKnxXml(payload) {
return datapoints; 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) { function tryTcpConnect(host, portNumber, timeout = 1200) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!host || !portNumber) { if (!host || !portNumber) {
@@ -265,7 +331,8 @@ const server = createServer(async (req, res) => {
protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"], protocols: ["modbus-tcp", "opc-ua", "bacnet-ip", "knx-ip"],
imports: ["modbus-csv", "knx-xml"], imports: ["modbus-csv", "knx-xml"],
scans: ["opc-ua", "bacnet-ip"], 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; return;
} }
@@ -283,6 +350,28 @@ const server = createServer(async (req, res) => {
return; 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") { if (req.method === "POST" && url.pathname === "/sources") {
try { try {
const body = await readBody(req); const body = await readBody(req);
@@ -329,6 +418,23 @@ const server = createServer(async (req, res) => {
return; 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") { if (req.method === "POST" && url.pathname === "/imports/modbus-csv") {
try { try {
const body = await readBody(req); const body = await readBody(req);
@@ -369,6 +475,10 @@ const server = createServer(async (req, res) => {
try { try {
const body = await readBody(req); const body = await readBody(req);
const host = body.host || ""; 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 portNumber = Number(body.port || 4840);
const reachable = await tryTcpConnect(host, portNumber); const reachable = await tryTcpConnect(host, portNumber);
const result = { const result = {
+4 -4
View File
@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
+371 -21
View File
@@ -463,12 +463,16 @@ export default function App() {
const [trendResult, setTrendResult] = useState(null); const [trendResult, setTrendResult] = useState(null);
const [latestValues, setLatestValues] = useState([]); const [latestValues, setLatestValues] = useState([]);
const [selections, setSelections] = useState([]); const [selections, setSelections] = useState([]);
const [selectedSelectionId, setSelectedSelectionId] = useState("");
const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft); const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft);
const [dashboard, setDashboard] = useState({ widgets: [] }); const [dashboard, setDashboard] = useState({ widgets: [] });
const [dashboardData, setDashboardData] = useState({}); const [dashboardData, setDashboardData] = useState({});
const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft); const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [rolePermissions, setRolePermissions] = useState(PERMISSION_OPTIONS);
const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" }); 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 [themeMode, setThemeMode] = useState("system");
const [csvDelimiter, setCsvDelimiter] = useState(";"); const [csvDelimiter, setCsvDelimiter] = useState(";");
const [sidebarPinned, setSidebarPinned] = useState(true); const [sidebarPinned, setSidebarPinned] = useState(true);
@@ -484,15 +488,24 @@ export default function App() {
const [selectedSourceId, setSelectedSourceId] = useState(""); const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false); const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); 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 [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]); const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null; const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
const canEditAliases = user?.role === "admin" || user?.role === "technician"; const canEditAliases = hasPermission(user, "edit_aliases");
const canManageUsers = user?.role === "admin"; const canManageUsers = hasPermission(user, "manage_users");
const canManageRoles = hasPermission(user, "manage_roles");
const canViewDebug = hasPermission(user, "view_debug");
const sidebarCollapsed = !sidebarPinned && !sidebarHover; const sidebarCollapsed = !sidebarPinned && !sidebarHover;
const currentView = views.find((view) => view.id === activeView) || views[0]; const currentView = views.find((view) => view.id === activeView) || views[0];
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list"; const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
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 currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]);
const collectorSourcePoints = useMemo(() => ( const collectorSourcePoints = useMemo(() => (
selectedSourceId selectedSourceId
@@ -677,7 +690,7 @@ export default function App() {
}, [dashboard, token]); }, [dashboard, token]);
useEffect(() => { useEffect(() => {
if (!token || user?.role !== "admin") { if (!token || !canManageUsers) {
setUsers([]); setUsers([]);
return; return;
} }
@@ -685,10 +698,24 @@ export default function App() {
api.getUsers(token) api.getUsers(token)
.then((result) => setUsers(result.users)) .then((result) => setUsers(result.users))
.catch((error) => setBootError(error.message)); .catch((error) => setBootError(error.message));
}, [token, user]); }, [canManageUsers, token]);
useEffect(() => { 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); setCollectorCapabilities(null);
setCollectorSources([]); setCollectorSources([]);
setCollectorDatapoints([]); setCollectorDatapoints([]);
@@ -700,7 +727,7 @@ export default function App() {
.catch((error) => setBootError(error.message)); .catch((error) => setBootError(error.message));
refreshCollectorData().catch((error) => setBootError(error.message)); refreshCollectorData().catch((error) => setBootError(error.message));
}, [canEditAliases, token]); }, [canEditAliases, canManageSources, canViewDebug, token]);
useEffect(() => { useEffect(() => {
if (!currentSource) { if (!currentSource) {
@@ -719,13 +746,45 @@ export default function App() {
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval", writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60), pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
}); });
setManualDatapointDraft((current) => ({
...current,
dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register",
}));
}, [currentSource]); }, [currentSource]);
useEffect(() => { useEffect(() => {
if (!canManageUsers && adminSection === "users") { if (!canManageUsers && adminSection === "users") {
setAdminSection("aliases"); 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) => { const handleLogin = async (credentials) => {
setBusy(true); setBusy(true);
@@ -771,6 +830,7 @@ export default function App() {
}; };
const startNewSelection = () => { const startNewSelection = () => {
setSelectedSelectionId("");
setWorkingPoints([]); setWorkingPoints([]);
setSelectionDraft(defaultSelectionDraft); setSelectionDraft(defaultSelectionDraft);
setTrendResult(null); setTrendResult(null);
@@ -780,6 +840,7 @@ export default function App() {
}; };
const loadSelection = (selection) => { const loadSelection = (selection) => {
setSelectedSelectionId(selection.id);
setWorkingPoints(selection.points); setWorkingPoints(selection.points);
setSelectionDraft({ setSelectionDraft({
name: selection.name, name: selection.name,
@@ -840,6 +901,10 @@ export default function App() {
}); });
const refreshed = await api.getSelections(token); const refreshed = await api.getSelections(token);
setSelections(refreshed.selections); 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."); setSuccessMessage("Auswahl gespeichert.");
} catch (error) { } catch (error) {
setBootError(error.message); setBootError(error.message);
@@ -897,6 +962,11 @@ export default function App() {
setCollectorDatapoints(datapointResult.datapoints || []); setCollectorDatapoints(datapointResult.datapoints || []);
}; };
const refreshCollectorDebug = async () => {
const result = await api.getCollectorDebug();
setDebugSnapshot(result);
};
const updateAliasPoint = (pointIndex, changes) => { const updateAliasPoint = (pointIndex, changes) => {
setAliasEditor((current) => ({ setAliasEditor((current) => ({
...current, ...current,
@@ -1038,6 +1108,7 @@ export default function App() {
const startNewSource = () => { const startNewSource = () => {
setIsCreatingSource(true); setIsCreatingSource(true);
setSelectedSourceId(""); setSelectedSourceId("");
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
setSourceDraft({ setSourceDraft({
...defaultSourceDraft, ...defaultSourceDraft,
port: getDefaultProtocolPort(defaultSourceDraft.protocol), port: getDefaultProtocolPort(defaultSourceDraft.protocol),
@@ -1084,6 +1155,7 @@ export default function App() {
}); });
await refreshCollectorData(); await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
const refreshed = await api.getIsps(token); const refreshed = await api.getIsps(token);
setIsps(refreshed.isps); setIsps(refreshed.isps);
setIsCreatingSource(false); setIsCreatingSource(false);
@@ -1103,6 +1175,7 @@ export default function App() {
try { try {
await api.deleteCollectorSource(selectedSourceId); await api.deleteCollectorSource(selectedSourceId);
await refreshCollectorData(); await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setIsCreatingSource(false); setIsCreatingSource(false);
setSelectedSourceId(""); setSelectedSourceId("");
setSourceDraft(defaultSourceDraft); 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 importCollectorFile = async (event, type) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) { if (!file) {
@@ -1140,6 +1251,7 @@ export default function App() {
} }
await refreshCollectorData(); await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert."); setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert.");
} catch (error) { } catch (error) {
setBootError(error.message); setBootError(error.message);
@@ -1156,6 +1268,7 @@ export default function App() {
port: sourceDraft.port || "4840", port: sourceDraft.port || "4840",
}); });
setScanResults((current) => ({ ...current, opcua: result })); setScanResults((current) => ({ ...current, opcua: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("OPC-UA-Scan abgeschlossen."); setSuccessMessage("OPC-UA-Scan abgeschlossen.");
} catch (error) { } catch (error) {
setBootError(error.message); setBootError(error.message);
@@ -1175,6 +1288,7 @@ export default function App() {
deviceId: sourceDraft.deviceId, deviceId: sourceDraft.deviceId,
}); });
setScanResults((current) => ({ ...current, bacnet: result })); setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen."); setSuccessMessage("BACnet-Scan abgeschlossen.");
} catch (error) { } catch (error) {
setBootError(error.message); setBootError(error.message);
@@ -1184,8 +1298,12 @@ export default function App() {
const createUser = async () => { const createUser = async () => {
resetFlash(); resetFlash();
try { try {
await api.createUser(token, userDraft); await api.createUser(token, {
setUserDraft({ name: "", username: "", password: "", role: "viewer" }); ...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); const refreshed = await api.getUsers(token);
setUsers(refreshed.users); setUsers(refreshed.users);
setSuccessMessage("Benutzer angelegt."); 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) => { const updateUser = async (targetUser, changes) => {
resetFlash(); resetFlash();
try { try {
@@ -1262,7 +1409,18 @@ export default function App() {
</div> </div>
<nav className="nav-list"> <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 <button
key={view.id} key={view.id}
type="button" 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> <button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
</div> </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"> <div className="form-grid compact-forms">
<label> <label>
<span>Name</span> <span>Name</span>
@@ -1574,7 +1752,18 @@ export default function App() {
{activeView === "admin" ? ( {activeView === "admin" ? (
<section className="admin-section"> <section className="admin-section">
<div className="admin-subnav"> <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 <button
key={section.id} key={section.id}
type="button" type="button"
@@ -1586,7 +1775,7 @@ export default function App() {
))} ))}
</div> </div>
{adminSection === "aliases" ? ( {adminSection === "aliases" && canEditAliases ? (
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header"> <div className="panel-header compact-header">
<div> <div>
@@ -1652,7 +1841,7 @@ export default function App() {
</div> </div>
) : null} ) : null}
{adminSection === "sources" ? ( {adminSection === "sources" && (canManageSources || canEditAliases) ? (
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header"> <div className="panel-header compact-header">
<div> <div>
@@ -1777,6 +1966,7 @@ export default function App() {
<span>Modbus CSV</span> <span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} /> <input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label> </label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button"> <label className="ghost-button compact-button file-button">
<span>KNX XML</span> <span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} /> <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> <small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
</div> </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"> <div className="protocol-block">
<strong>OPC UA</strong> <strong>OPC UA</strong>
<div className="button-row wrap-tight"> <div className="button-row wrap-tight">
@@ -1834,7 +2060,7 @@ export default function App() {
<div className="panel-header compact-header nested-gap"> <div className="panel-header compact-header nested-gap">
<div> <div>
<h3>Benutzer</h3> <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> </div>
<div className="form-grid compact-forms"> <div className="form-grid compact-forms">
@@ -1853,9 +2079,7 @@ export default function App() {
<label> <label>
<span>Rolle</span> <span>Rolle</span>
<select value={userDraft.role} onChange={(event) => setUserDraft((current) => ({ ...current, role: event.target.value }))}> <select value={userDraft.role} onChange={(event) => setUserDraft((current) => ({ ...current, role: event.target.value }))}>
<option value="viewer">viewer</option> {roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
<option value="technician">technician</option>
<option value="admin">admin</option>
</select> </select>
</label> </label>
</div> </div>
@@ -1868,11 +2092,10 @@ export default function App() {
<strong>{listedUser.name}</strong> <strong>{listedUser.name}</strong>
<span>@{listedUser.username}</span> <span>@{listedUser.username}</span>
</div> </div>
<select value={listedUser.role} onChange={(event) => updateUser(listedUser, { role: event.target.value })}> <select value={listedUser.role} disabled={listedUser.id === user?.id} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
<option value="viewer">viewer</option> {roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
<option value="technician">technician</option>
<option value="admin">admin</option>
</select> </select>
{listedUser.id === user?.id ? <small className="muted">Eigene Rolle gesperrt</small> : null}
<label className="checkbox-field compact-check"> <label className="checkbox-field compact-check">
<input type="checkbox" checked={listedUser.active} onChange={(event) => updateUser(listedUser, { active: event.target.checked })} /> <input type="checkbox" checked={listedUser.active} onChange={(event) => updateUser(listedUser, { active: event.target.checked })} />
<span>aktiv</span> <span>aktiv</span>
@@ -1882,6 +2105,111 @@ export default function App() {
</div> </div>
</div> </div>
) : null} ) : 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> </section>
) : null} ) : null}
@@ -1963,3 +2291,25 @@ export default function App() {
+5
View File
@@ -62,6 +62,9 @@ export const api = {
getUsers: (token) => apiRequest("/users", { token }), getUsers: (token) => apiRequest("/users", { token }),
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }), createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", 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 }), getSelections: (token) => apiRequest("/selections", { token }),
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }), createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", 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 }), createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }), updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }), 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 }), importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
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 }),
+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 { jsPDF } from "jspdf";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { import {
@@ -32,6 +32,31 @@ function buildRandomColor(index) {
return `hsl(${hue} ${saturation}% ${lightness}%)`; 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({ export default function TrendChart({
result, result,
title = "Trendansicht", title = "Trendansicht",
@@ -66,6 +91,8 @@ export default function TrendChart({
})), [result]); })), [result]);
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series; 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(() => { const baseXDomain = useMemo(() => {
if (!rows.length) { if (!rows.length) {
@@ -75,29 +102,11 @@ export default function TrendChart({
}, [rows]); }, [rows]);
const baseYDomain = useMemo(() => { const baseYDomain = useMemo(() => {
const values = []; if (analogSeries.length === 0 && digitalSeries.length > 0) {
rows.forEach((row) => { return [0, 2];
visibleSeries.forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
} }
}); return getAnalogDomain(rows, visibleSeries);
}); }, [analogSeries.length, digitalSeries.length, rows, visibleSeries]);
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]);
const xDomain = zoomDomain?.x || baseXDomain; const xDomain = zoomDomain?.x || baseXDomain;
const yDomain = zoomDomain?.y || baseYDomain; const yDomain = zoomDomain?.y || baseYDomain;
@@ -375,7 +384,18 @@ export default function TrendChart({
minTickGap={28} minTickGap={28}
stroke="var(--muted)" 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 <Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")} labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
contentStyle={{ contentStyle={{
@@ -401,7 +421,8 @@ export default function TrendChart({
return ( return (
<Line <Line
key={entry.key} key={entry.key}
type="monotone" yAxisId={entry.kind === "digital" ? "digital" : "analog"}
type={entry.kind === "digital" ? "stepAfter" : "monotone"}
dataKey={entry.key} dataKey={entry.key}
dot={false} dot={false}
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8} strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}