commit
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
.scratch/runtime
|
||||
|
||||
+141
-24
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
|
||||
+371
-21
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user