commit
This commit is contained in:
@@ -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
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!values.length) {
|
|
||||||
return [0, 1];
|
|
||||||
}
|
}
|
||||||
|
return getAnalogDomain(rows, visibleSeries);
|
||||||
const min = Math.min(...values);
|
}, [analogSeries.length, digitalSeries.length, rows, visibleSeries]);
|
||||||
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user