Refactor database credentials and user permissions; update UI components for better usability and accessibility

- Changed default MariaDB password in docker-compose.yml from 'SE3112' to '3112'.
- Added user creation and permissions for 'wago' in the MariaDB initialization script.
- Updated unit options in App.jsx to include a digital/bool option.
- Enhanced LoginScreen component to support inline display and improved error handling.
- Improved point filtering logic in PointLibrary and adjusted default unit handling.
- Refined authentication checks for dashboard and admin permissions.
- Simplified API calls by removing unnecessary token checks.
- Enhanced TrendChart component with better data handling and formatting.
- Updated CSS styles for improved layout and responsiveness in trend views and sidebar.
This commit is contained in:
jhartworks
2026-07-01 11:58:42 +02:00
parent e34626f15b
commit 2b9ff12a9f
11 changed files with 412 additions and 279 deletions
-1
View File
@@ -7,4 +7,3 @@ MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=* CORS_ORIGIN=*
VITE_API_PORT=18080 VITE_API_PORT=18080
CORS_ORIGIN=http://localhost:5173
-10
View File
@@ -1,10 +0,0 @@
JWT_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=System Admin
MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=*
VITE_API_PORT=18080
CORS_ORIGIN=http://localhost:5173
+19
View File
@@ -32,6 +32,25 @@ export function authenticateToken(req, res, next) {
} }
} }
export function authenticateTokenOptional(req, _res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token) {
req.user = null;
next();
return;
}
try {
req.user = jwt.verify(token, config.jwtSecret);
} catch {
req.user = null;
}
next();
}
export function requireRoles(...roles) { export function requireRoles(...roles) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) { if (!req.user || !roles.includes(req.user.role)) {
+29 -12
View File
@@ -1,7 +1,7 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { authenticateToken, createToken, requireRoles } from "./auth.js"; import { authenticateToken, authenticateTokenOptional, createToken, requireRoles } from "./auth.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { pool } from "./db.js"; import { pool } from "./db.js";
import { import {
@@ -88,7 +88,7 @@ function assertPoints(points) {
} }
function canUseSelection(selection, user) { function canUseSelection(selection, user) {
return Boolean(selection) && (selection.shared || selection.ownerId === user.sub || user.role === "admin"); return Boolean(selection) && (selection.shared || (Boolean(user) && (selection.ownerId === user.sub || user.role === "admin")));
} }
function slugifyUsername(value) { function slugifyUsername(value) {
@@ -147,6 +147,16 @@ async function serializeUserWithPermissions(row) {
}; };
} }
const GUEST_VIEWER = {
sub: "public",
role: "viewer",
name: "Gast",
};
function resolveViewerUser(req) {
return req.user || GUEST_VIEWER;
}
function requirePermission(...permissions) { function requirePermission(...permissions) {
return async (req, res, next) => { return async (req, res, next) => {
try { try {
@@ -218,6 +228,12 @@ async function ensureUtf8Database() {
} }
} }
async function ensureWagoApplicationUser() {
await pool.query("CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112'");
await pool.query(`GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON \`${config.dbName}\`.* TO 'wago'@'%'`);
await pool.query("FLUSH PRIVILEGES");
}
async function ensureUserSchema() { async function ensureUserSchema() {
await pool.query( await pool.query(
` `
@@ -384,7 +400,7 @@ async function resolveIspPointCount(isp) {
return await resolveTrendTablePointCount(pool, config.dbName, isp); return await resolveTrendTablePointCount(pool, config.dbName, isp);
} }
app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { app.get("/api/isps", authenticateTokenOptional, wrap(async (_req, res) => {
const isps = await getAvailableIsps(pool, config.dbName); const isps = await getAvailableIsps(pool, config.dbName);
const response = await Promise.all( const response = await Promise.all(
isps.map(async (isp) => { isps.map(async (isp) => {
@@ -400,7 +416,7 @@ app.get("/api/isps", authenticateToken, wrap(async (_req, res) => {
res.json({ isps: response }); res.json({ isps: response });
})); }));
app.get("/api/isps/:isp/aliases", authenticateToken, wrap(async (req, res) => { app.get("/api/isps/:isp/aliases", authenticateTokenOptional, wrap(async (req, res) => {
const pointCount = await resolveIspPointCount(req.params.isp); const pointCount = await resolveIspPointCount(req.params.isp);
const metadata = await ensureIspMetadata(pool, redis, req.params.isp, pointCount); const metadata = await ensureIspMetadata(pool, redis, req.params.isp, pointCount);
res.json(metadata); res.json(metadata);
@@ -694,20 +710,20 @@ app.delete("/api/selections/:selectionId", authenticateToken, requirePermission(
res.status(204).send(); res.status(204).send();
})); }));
app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { app.post("/api/trends/query", authenticateTokenOptional, wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user); const points = await resolveSelectionPoints(req.body, resolveViewerUser(req));
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, requirePermission("view_trends"), wrap(async (req, res) => { app.post("/api/points/latest", authenticateTokenOptional, wrap(async (req, res) => {
const points = await resolveSelectionPoints(req.body, req.user); const points = await resolveSelectionPoints(req.body, resolveViewerUser(req));
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, requirePermission("view_dashboard"), wrap(async (req, res) => { app.get("/api/dashboard", authenticateTokenOptional, wrap(async (req, res) => {
const dashboard = await getDashboard(redis, req.user.sub); const dashboard = await getDashboard(redis, req.user?.sub || "public");
res.json(dashboard); res.json(dashboard);
})); }));
@@ -716,12 +732,13 @@ app.put("/api/dashboard", authenticateToken, requirePermission("view_dashboard")
res.json(dashboard); res.json(dashboard);
})); }));
app.post("/api/dashboard/data", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { app.post("/api/dashboard/data", authenticateTokenOptional, 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 = [];
const viewer = resolveViewerUser(req);
for (const widget of widgets) { for (const widget of widgets) {
const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, req.user) : assertPoints(widget.points || []); const points = widget.selectionId ? await resolveSelectionPoints({ selectionId: widget.selectionId }, viewer) : assertPoints(widget.points || []);
if (widget.type === "chart") { if (widget.type === "chart") {
const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range)); const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range));
+14 -9
View File
@@ -113,14 +113,19 @@ function createRandomPointColor(seed) {
return hslToHex(hue, saturation, lightness); return hslToHex(hue, saturation, lightness);
} }
export function normalizeEngineeringUnit(value) { export function normalizeEngineeringUnit(value, kind = "analog") {
const normalizedKind = kind === "digital" ? "digital" : "analog";
const raw = String(value ?? "").trim(); const raw = String(value ?? "").trim();
if (!raw) { if (!raw) {
return "\u00B0C"; return normalizedKind === "digital" ? "bool" : "\u00B0C";
}
if (normalizedKind === "digital" && /^(?:bool|boolean)$/i.test(raw)) {
return "bool";
} }
if (/^\?C$/i.test(raw) || /^\u00B0\s*C$/i.test(raw) || (/[^\x00-\x7F]/.test(raw) && /C/i.test(raw))) { if (/^\?C$/i.test(raw) || /^\u00B0\s*C$/i.test(raw) || (/[^\x00-\x7F]/.test(raw) && /C/i.test(raw))) {
return "\u00B0C"; return normalizedKind === "digital" ? "bool" : "\u00B0C";
} }
return raw; return raw;
@@ -388,7 +393,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO
parsed.points[pointKey] = { parsed.points[pointKey] = {
...defaultPoint, ...defaultPoint,
alias: initialAlias, alias: initialAlias,
unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit), unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit, sourceSeed?.kind || defaultPoint.kind),
kind: sourceSeed?.kind || defaultPoint.kind, kind: sourceSeed?.kind || defaultPoint.kind,
}; };
changed = true; changed = true;
@@ -407,13 +412,13 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO
point.kind = sourceSeed.kind; point.kind = sourceSeed.kind;
changed = true; changed = true;
} }
const normalizedUnit = normalizeEngineeringUnit(point.unit); const normalizedUnit = normalizeEngineeringUnit(point.unit, point.kind || sourceSeed?.kind || defaultPoint.kind);
if (normalizedUnit !== point.unit) { if (normalizedUnit !== point.unit) {
point.unit = normalizedUnit; point.unit = normalizedUnit;
changed = true; changed = true;
} }
if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit)) && sourceSeed?.unit) { if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit, defaultPoint.kind)) && sourceSeed?.unit) {
const seededUnit = normalizeEngineeringUnit(sourceSeed.unit); const seededUnit = normalizeEngineeringUnit(sourceSeed.unit, sourceSeed.kind || point.kind || defaultPoint.kind);
if (point.unit !== seededUnit) { if (point.unit !== seededUnit) {
point.unit = seededUnit; point.unit = seededUnit;
changed = true; changed = true;
@@ -463,7 +468,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO
metadata.points[pointKey] = { metadata.points[pointKey] = {
...metadata.points[pointKey], ...metadata.points[pointKey],
alias: sourceSeed.alias || sourceSeed.name || metadata.points[pointKey].alias, alias: sourceSeed.alias || sourceSeed.name || metadata.points[pointKey].alias,
unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit), unit: normalizeEngineeringUnit(sourceSeed.unit || metadata.points[pointKey].unit, sourceSeed.kind || metadata.points[pointKey].kind),
kind: sourceSeed.kind || metadata.points[pointKey].kind, kind: sourceSeed.kind || metadata.points[pointKey].kind,
}; };
}); });
@@ -506,7 +511,7 @@ export async function getPointDescriptors(pool, redis, points) {
key: buildPointKey(isp, pointIndex), key: buildPointKey(isp, pointIndex),
displayName: metadata.displayName, displayName: metadata.displayName,
alias: pointMeta.alias, alias: pointMeta.alias,
unit: normalizeEngineeringUnit(pointMeta.unit), unit: normalizeEngineeringUnit(pointMeta.unit, pointMeta.kind),
factor: pointMeta.factor, factor: pointMeta.factor,
kind: pointMeta.kind, kind: pointMeta.kind,
min: pointMeta.min, min: pointMeta.min,
+18 -9
View File
@@ -156,16 +156,25 @@ function normalizeUnit(value) {
const raw = normalizeText(value); const raw = normalizeText(value);
const collapsed = raw.replace(/\s+/g, ""); const collapsed = raw.replace(/\s+/g, "");
if (!collapsed) { if (!collapsed) {
return "\u00B0C"; return "°C";
} }
if (/^\?C$/i.test(collapsed) || /^\u00B0C$/i.test(collapsed)) { if (/^(?:\?C|\u00B0C)$/i.test(collapsed)) {
return "\u00B0C"; return "°C";
} }
return raw; return raw;
} }
function normalizePointUnit(value, kind = "analog") {
const normalizedKind = normalizePointKind(kind);
const raw = normalizeText(value);
if (!raw) {
return normalizedKind === "digital" ? "bool" : "°C";
}
return normalizeUnit(raw);
}
function normalizePointIndex(value, fallback) { function normalizePointIndex(value, fallback) {
const numeric = Number(value); const numeric = Number(value);
return Number.isInteger(numeric) && numeric >= 1 && numeric <= LOCAL_POINT_LIMIT ? numeric : fallback; return Number.isInteger(numeric) && numeric >= 1 && numeric <= LOCAL_POINT_LIMIT ? numeric : fallback;
@@ -244,7 +253,7 @@ function parseModbusCsv(payload) {
pointIndex, pointIndex,
name: normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`, name: normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
alias: normalizeText(get("alias", get("name", `Register ${index + 1}`))) || normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`, alias: normalizeText(get("alias", get("name", `Register ${index + 1}`))) || normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
unit: normalizeUnit(get("unit")), unit: normalizePointUnit(get("unit"), get("kind", get("type", "analog"))),
dataType: get("datatype", "holding-register"), dataType: get("datatype", "holding-register"),
kind: normalizePointKind(get("kind", get("type", "analog"))), kind: normalizePointKind(get("kind", get("type", "analog"))),
enabled: payload.enabled === true, enabled: payload.enabled === true,
@@ -294,7 +303,7 @@ function parseKnxXml(payload) {
pointIndex: index + 1, pointIndex: index + 1,
name, name,
alias: name, alias: name,
unit: normalizeUnit(""), unit: normalizePointUnit("", dpt.startsWith("1.") ? "digital" : "analog"),
dataType: dpt || "group-address", dataType: dpt || "group-address",
kind: dpt.startsWith("1.") ? "digital" : "analog", kind: dpt.startsWith("1.") ? "digital" : "analog",
enabled: payload.enabled === true, enabled: payload.enabled === true,
@@ -331,7 +340,7 @@ function createManualDatapoint(store, payload) {
pointIndex, pointIndex,
name, name,
alias, alias,
unit: normalizeUnit(payload.unit), unit: normalizePointUnit(payload.unit, payload.kind),
kind: normalizePointKind(payload.kind), kind: normalizePointKind(payload.kind),
enabled: payload.enabled !== false, enabled: payload.enabled !== false,
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
@@ -365,7 +374,7 @@ function createScanDatapoint(source, payload, index) {
pointIndex, pointIndex,
name: normalizeText(payload.name || alias) || alias, name: normalizeText(payload.name || alias) || alias,
alias, alias,
unit: normalizeUnit(payload.unit), unit: normalizePointUnit(payload.unit, payload.kind),
kind: normalizePointKind(payload.kind), kind: normalizePointKind(payload.kind),
enabled: payload.enabled === true, enabled: payload.enabled === true,
writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), writeMode: normalizeWriteMode(payload.writeMode || source.writeMode),
@@ -413,7 +422,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) {
pointIndex: existing.pointIndex, pointIndex: existing.pointIndex,
alias: existing.alias || scanned.alias, alias: existing.alias || scanned.alias,
name: existing.name || scanned.name, name: existing.name || scanned.name,
unit: normalizeUnit(existing.unit || scanned.unit), unit: normalizePointUnit(existing.unit || scanned.unit, existing.kind || scanned.kind),
kind: existing.kind || scanned.kind, kind: existing.kind || scanned.kind,
enabled: existing.enabled === true, enabled: existing.enabled === true,
writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode), writeMode: normalizeWriteMode(existing.writeMode || scanned.writeMode),
@@ -1707,7 +1716,7 @@ const server = createServer(async (req, res) => {
address: typeof body.address === "string" ? normalizeText(body.address) : store.datapoints[index].address, address: typeof body.address === "string" ? normalizeText(body.address) : store.datapoints[index].address,
nodeId: typeof body.nodeId === "string" ? normalizeText(body.nodeId) : store.datapoints[index].nodeId, nodeId: typeof body.nodeId === "string" ? normalizeText(body.nodeId) : store.datapoints[index].nodeId,
dataType: typeof body.dataType === "string" && body.dataType.trim() ? normalizeText(body.dataType) : store.datapoints[index].dataType, dataType: typeof body.dataType === "string" && body.dataType.trim() ? normalizeText(body.dataType) : store.datapoints[index].dataType,
unit: body.unit ? normalizeUnit(body.unit) : store.datapoints[index].unit, unit: body.unit ? normalizePointUnit(body.unit, body.kind || store.datapoints[index].kind) : store.datapoints[index].unit,
kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind, kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind,
scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale, scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale,
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ services:
MARIADB_ROOT_HOST: mariadb MARIADB_ROOT_HOST: mariadb
MARIADB_DATABASE: wago MARIADB_DATABASE: wago
MARIADB_USER: wago MARIADB_USER: wago
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-SE3112} MARIADB_PASSWORD: ${MARIADB_PASSWORD:-3112}
ports: ports:
- "3306:3306" - "3306:3306"
volumes: volumes:
+5
View File
@@ -61,3 +61,8 @@ CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'SE3112';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
CREATE USER IF NOT EXISTS 'wago'@'%' IDENTIFIED BY '3112';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON `wago`.* TO 'wago'@'%';
FLUSH PRIVILEGES;
+135 -77
View File
@@ -102,12 +102,13 @@ const pointColors = [
]; ];
const DEFAULT_UNIT_OPTIONS = [ const DEFAULT_UNIT_OPTIONS = [
{ symbol: "\u00B0C", label: "Grad Celsius", isSystem: true }, { symbol: "°C", label: "Grad Celsius", isSystem: true },
{ symbol: "%", label: "Prozent", isSystem: true }, { symbol: "%", label: "Prozent", isSystem: true },
{ symbol: "K", label: "Kelvin", isSystem: true }, { symbol: "K", label: "Kelvin", isSystem: true },
{ symbol: "V", label: "Volt", isSystem: true }, { symbol: "V", label: "Volt", isSystem: true },
{ symbol: "A", label: "Ampere", isSystem: true }, { symbol: "A", label: "Ampere", isSystem: true },
{ symbol: "Pa", label: "Pascal", isSystem: true }, { symbol: "Pa", label: "Pascal", isSystem: true },
{ symbol: "bool", label: "Digital / Bool", isSystem: true },
]; ];
const UNIT_PRESETS = DEFAULT_UNIT_OPTIONS.map((unit) => unit.symbol); const UNIT_PRESETS = DEFAULT_UNIT_OPTIONS.map((unit) => unit.symbol);
const CUSTOM_UNIT_VALUE = "__custom__"; const CUSTOM_UNIT_VALUE = "__custom__";
@@ -164,6 +165,10 @@ function getDefaultPointDataType(protocol) {
return "holding-register"; return "holding-register";
} }
function getDefaultUnitForKind(kind) {
return kind === "digital" ? "bool" : "°C";
}
function getPointDataTypeOptions(protocol) { function getPointDataTypeOptions(protocol) {
if (protocol === "modbus-tcp") { if (protocol === "modbus-tcp") {
return MODBUS_DATA_TYPE_OPTIONS; return MODBUS_DATA_TYPE_OPTIONS;
@@ -543,7 +548,7 @@ function IconButton({ title, onClick, children, danger = false }) {
); );
} }
function LoginScreen({ onLogin, error, busy }) { function LoginScreen({ onLogin, error, busy, inline = false }) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -553,7 +558,7 @@ function LoginScreen({ onLogin, error, busy }) {
}; };
return ( return (
<div className="login-screen"> <div className={inline ? "login-screen inline-login" : "login-screen"}>
<form className="login-card" onSubmit={handleSubmit}> <form className="login-card" onSubmit={handleSubmit}>
<div className="logo-lockup"> <div className="logo-lockup">
<img src="/logo.png" alt="SE Logo" /> <img src="/logo.png" alt="SE Logo" />
@@ -609,6 +614,7 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint })
const search = filterText.trim().toLowerCase(); const search = filterText.trim().toLowerCase();
return getOrderedPointEntries(metadata) return getOrderedPointEntries(metadata)
.filter((point) => normalizeDisplayText(point.alias).trim().length > 0)
.filter((point) => { .filter((point) => {
if (!search) { if (!search) {
return true; return true;
@@ -711,7 +717,7 @@ export default function App() {
const [isCreatingSource, setIsCreatingSource] = useState(false); const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [sourceModalOpen, setSourceModalOpen] = useState(false); const [sourceModalOpen, setSourceModalOpen] = useState(false);
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol) => ({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(protocol), kind: "analog", scale: "1", writeMode: "", condition: "" }); const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" });
const [collectorPointFilter, setCollectorPointFilter] = useState(""); const [collectorPointFilter, setCollectorPointFilter] = useState("");
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft()); const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null }); const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
@@ -719,13 +725,14 @@ export default function App() {
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 canViewDashboard = hasPermission(user, "view_dashboard"); const isAuthenticated = Boolean(token);
const canViewTrends = hasPermission(user, "view_trends"); const canViewDashboard = !isAuthenticated || hasPermission(user, "view_dashboard");
const canEditAliases = hasPermission(user, "edit_aliases"); const canViewTrends = !isAuthenticated || hasPermission(user, "view_trends");
const canManageSources = hasPermission(user, "manage_sources"); const canEditAliases = isAuthenticated && hasPermission(user, "edit_aliases");
const canManageUsers = hasPermission(user, "manage_users"); const canManageSources = isAuthenticated && hasPermission(user, "manage_sources");
const canManageRoles = hasPermission(user, "manage_roles"); const canManageUsers = isAuthenticated && hasPermission(user, "manage_users");
const canViewDebug = hasPermission(user, "view_debug"); const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles");
const canViewDebug = isAuthenticated && hasPermission(user, "view_debug");
const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug; const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug;
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases"; const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases";
const sidebarCollapsed = !sidebarPinned && !sidebarHover; const sidebarCollapsed = !sidebarPinned && !sidebarHover;
@@ -777,10 +784,7 @@ export default function App() {
}; };
const refreshIspCatalog = async () => { const refreshIspCatalog = async () => {
if (!token) { const result = await api.getIsps(token || undefined);
return [];
}
const result = await api.getIsps(token);
const nextIsps = result.isps || []; const nextIsps = result.isps || [];
setIsps(nextIsps); setIsps(nextIsps);
return nextIsps; return nextIsps;
@@ -843,18 +847,31 @@ export default function App() {
}, [activeView, adminSection]); }, [activeView, adminSection]);
useEffect(() => { useEffect(() => {
if (!token) {
setSessionReady(true);
setPreferencesReady(false);
return;
}
let ignore = false; let ignore = false;
async function bootstrap() { async function bootstrap() {
setSessionReady(false); setSessionReady(false);
resetFlash(); resetFlash();
try { try {
if (!token) {
const [ispResult, dashboardResult] = await Promise.all([
api.getIsps(),
api.getDashboard(),
]);
if (ignore) {
return;
}
setUser(null);
setIsps(ispResult.isps || []);
setSelections([]);
setDashboard(dashboardResult);
setActiveView((current) => current === "admin" ? "dashboard" : current);
setPreferencesReady(false);
return;
}
const [me, pref, ispResult, selectionResult, dashboardResult] = await Promise.all([ const [me, pref, ispResult, selectionResult, dashboardResult] = await Promise.all([
api.me(token), api.me(token),
api.getPreferences(token), api.getPreferences(token),
@@ -879,11 +896,13 @@ export default function App() {
} catch (error) { } catch (error) {
if (!ignore) { if (!ignore) {
setBootError(error.message); setBootError(error.message);
if (token) {
setLoginError(error.message); setLoginError(error.message);
setToken(""); setToken("");
setUser(null); setUser(null);
localStorage.removeItem("seltd-token"); localStorage.removeItem("seltd-token");
} }
}
} finally { } finally {
if (!ignore) { if (!ignore) {
setSessionReady(true); setSessionReady(true);
@@ -952,11 +971,11 @@ export default function App() {
}, [csvDelimiter, preferencesReady, sidebarPinned, themeMode, token]); }, [csvDelimiter, preferencesReady, sidebarPinned, themeMode, token]);
useEffect(() => { useEffect(() => {
if (!token || !selectedIsp || metadataCache[selectedIsp]) { if (!selectedIsp || metadataCache[selectedIsp]) {
return; return;
} }
api.getAliases(token, selectedIsp) api.getAliases(token || undefined, selectedIsp)
.then((result) => setMetadataCache((current) => ({ ...current, [selectedIsp]: result }))) .then((result) => setMetadataCache((current) => ({ ...current, [selectedIsp]: result })))
.catch((error) => setBootError(error.message)); .catch((error) => setBootError(error.message));
}, [metadataCache, selectedIsp, token]); }, [metadataCache, selectedIsp, token]);
@@ -975,7 +994,7 @@ export default function App() {
}, [adminIsp, token]); }, [adminIsp, token]);
useEffect(() => { useEffect(() => {
if (!token || workingPoints.length === 0) { if (workingPoints.length === 0) {
return; return;
} }
@@ -984,7 +1003,7 @@ export default function App() {
return; return;
} }
Promise.all(missingIsps.map((isp) => api.getAliases(token, isp))) Promise.all(missingIsps.map((isp) => api.getAliases(token || undefined, isp)))
.then((results) => { .then((results) => {
setMetadataCache((current) => { setMetadataCache((current) => {
const next = { ...current }; const next = { ...current };
@@ -998,12 +1017,12 @@ export default function App() {
}, [metadataCache, token, workingPoints]); }, [metadataCache, token, workingPoints]);
useEffect(() => { useEffect(() => {
if (!token || dashboard.widgets.length === 0) { if (dashboard.widgets.length === 0) {
setDashboardData({}); setDashboardData({});
return; return;
} }
api.loadDashboardData(token, { widgets: dashboard.widgets }) api.loadDashboardData(token || undefined, { widgets: dashboard.widgets })
.then((result) => setDashboardData(Object.fromEntries(result.widgets.map((widget) => [widget.id, widget])))) .then((result) => setDashboardData(Object.fromEntries(result.widgets.map((widget) => [widget.id, widget]))))
.catch((error) => setBootError(error.message)); .catch((error) => setBootError(error.message));
}, [dashboard, token]); }, [dashboard, token]);
@@ -1045,11 +1064,6 @@ export default function App() {
}, [canEditAliases, canManageSources, token]); }, [canEditAliases, canManageSources, token]);
useEffect(() => { useEffect(() => {
if (!token) {
return undefined;
}
let ignore = false;
const refreshIsps = () => { const refreshIsps = () => {
refreshIspCatalog().catch(() => undefined); refreshIspCatalog().catch(() => undefined);
}; };
@@ -1064,7 +1078,6 @@ export default function App() {
window.addEventListener("focus", refreshIsps); window.addEventListener("focus", refreshIsps);
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
ignore = true;
window.clearInterval(timer); window.clearInterval(timer);
window.removeEventListener("focus", refreshIsps); window.removeEventListener("focus", refreshIsps);
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
@@ -1106,6 +1119,7 @@ export default function App() {
setManualDatapointDraft((current) => ({ setManualDatapointDraft((current) => ({
...current, ...current,
dataType: getDefaultPointDataType(currentSource.protocol || "modbus-tcp"), dataType: getDefaultPointDataType(currentSource.protocol || "modbus-tcp"),
unit: current.unit || getDefaultUnitForKind(current.kind || "analog"),
})); }));
}, [currentSource]); }, [currentSource]);
@@ -1204,7 +1218,8 @@ export default function App() {
}; };
const addPoint = (pointIndex, isp = selectedIsp) => { const addPoint = (pointIndex, isp = selectedIsp) => {
if (!isp || workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) { const pointMeta = metadataCache[isp]?.points?.[String(pointIndex)];
if (!isp || !normalizeDisplayText(pointMeta?.alias).trim() || workingPoints.some((point) => point.isp === isp && point.pointIndex === pointIndex)) {
return; return;
} }
@@ -1900,7 +1915,7 @@ export default function App() {
} }
if (passwordDraft.nextPassword !== passwordDraft.repeatPassword) { if (passwordDraft.nextPassword !== passwordDraft.repeatPassword) {
setBootError("Die neuen Passw?rter stimmen nicht ?berein."); setBootError("Die neuen Passwörter stimmen nicht überein.");
return; return;
} }
@@ -1910,16 +1925,12 @@ export default function App() {
nextPassword: passwordDraft.nextPassword, nextPassword: passwordDraft.nextPassword,
}); });
setPasswordDraft({ currentPassword: "", nextPassword: "", repeatPassword: "" }); setPasswordDraft({ currentPassword: "", nextPassword: "", repeatPassword: "" });
setSuccessMessage("Passwort ge?ndert."); setSuccessMessage("Passwort geändert.");
} catch (error) { } catch (error) {
setBootError(error.message); setBootError(error.message);
} }
}; };
if (!token) {
return <LoginScreen onLogin={handleLogin} error={loginError} busy={busy} />;
}
if (!sessionReady) { if (!sessionReady) {
return <div className="login-screen"><div className="panel">Sitzung wird geladen ...</div></div>; return <div className="login-screen"><div className="panel">Sitzung wird geladen ...</div></div>;
} }
@@ -1942,7 +1953,7 @@ export default function App() {
</div> </div>
) : null} ) : null}
</div> </div>
<IconButton title={sidebarPinned ? "Seitenleiste l?sen" : "Seitenleiste anheften"} onClick={() => setSidebarPinned((current) => !current)}> <IconButton title={sidebarPinned ? "Seitenleiste lösen" : "Seitenleiste anheften"} onClick={() => setSidebarPinned((current) => !current)}>
<PinIcon pinned={sidebarPinned} /> <PinIcon pinned={sidebarPinned} />
</IconButton> </IconButton>
</div> </div>
@@ -1958,6 +1969,9 @@ export default function App() {
if (view.id === "admin") { if (view.id === "admin") {
return canAccessAdmin; return canAccessAdmin;
} }
if (view.id === "settings") {
return isAuthenticated;
}
return true; return true;
}).map((view) => ( }).map((view) => (
<button <button
@@ -1974,14 +1988,21 @@ export default function App() {
{!sidebarCollapsed ? ( {!sidebarCollapsed ? (
<div className="sidebar-footer"> <div className="sidebar-footer">
{isAuthenticated ? (
<>
<div className="user-card"> <div className="user-card">
<strong>{getUserDisplayName(user)}</strong> <strong>{getUserDisplayName(user)}</strong>
<span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span> <span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span>
</div> </div>
<button className="ghost-button compact-button sidebar-logout" type="button" onClick={handleLogout}>Abmelden</button> <button className="ghost-button compact-button sidebar-logout" type="button" onClick={handleLogout}>Abmelden</button>
</>
) : (
<button className="ghost-button compact-button sidebar-login" type="button" onClick={() => setActiveView("settings")}>Anmelden</button>
)}
<SmallFootnote /> <SmallFootnote />
</div> </div>
) : null} ) : null}
</aside> </aside>
<main className="content"> <main className="content">
@@ -2147,11 +2168,47 @@ export default function App() {
{activeView === "trends" ? ( {activeView === "trends" ? (
<> <>
{!trendFocus ? ( {!trendFocus ? (
<section className="grid two compact-grid"> <section className="trend-layout">
<div className="trend-main">
{trendResult ? (
<TrendChart
result={trendResult}
title="Trendansicht"
expanded={trendFocus}
onToggleExpanded={() => setTrendFocus((current) => !current)}
csvDelimiter={csvDelimiter}
onCsvDelimiterChange={setCsvDelimiter}
allowExports
allowFocusMode
/>
) : (
<div className="panel empty-state slim-empty">
<h3>Trendansicht</h3>
<p>Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.</p>
</div>
)}
{latestValues.length ? (
<section className="latest-grid">
{latestValues.map((value) => (
<div key={value.key} className="stat-card latest-card" style={{ borderLeftColor: resolveLatestColor(value) }}>
<div>
<small>{normalizeDisplayText(value.displayName)}</small>
<strong>{normalizeDisplayText(value.alias)}</strong>
<span className="value-stamp">{formatTimestampLabel(value.timestamp)}</span>
</div>
<div className="stat-value">{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}<span>{normalizeDisplayUnit(value.unit)}</span></div>
</div>
))}
</section>
) : null}
</div>
<div className="trend-side">
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header"> <div className="panel-header compact-header">
<div> <div>
<h3>Trendquelle</h3> <h3>Datenpunktauswahl</h3>
<p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p> <p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p>
</div> </div>
</div> </div>
@@ -2168,7 +2225,7 @@ export default function App() {
<div className="panel-header compact-header"> <div className="panel-header compact-header">
<div> <div>
<h3>Aktuelle Punkte</h3> <h3>Aktuelle Punkte</h3>
<p>Mehrere ISPs können gemeinsam angezeigt und gespeichert werden.</p> <p>Mehrere ISPs können gemeinsam angezeigt werden.</p>
</div> </div>
<button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button> <button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button>
</div> </div>
@@ -2217,6 +2274,8 @@ 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>
{isAuthenticated ? (
<>
<label> <label>
<span>Gespeicherte Auswahl</span> <span>Gespeicherte Auswahl</span>
<select <select
@@ -2254,11 +2313,12 @@ export default function App() {
</label> </label>
<button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button> <button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button>
</>
) : null}
</div>
</div> </div>
</section> </section>
) : null} ) : trendResult ? (
{trendResult ? (
<TrendChart <TrendChart
result={trendResult} result={trendResult}
title="Trendansicht" title="Trendansicht"
@@ -2272,24 +2332,9 @@ export default function App() {
) : ( ) : (
<div className="panel empty-state slim-empty"> <div className="panel empty-state slim-empty">
<h3>Trendansicht</h3> <h3>Trendansicht</h3>
<p>Nach dem Laden erscheinen hier Graph, Exporte und Fokusansicht.</p> <p>Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.</p>
</div> </div>
)} )}
{latestValues.length && !trendFocus ? (
<section className="latest-grid">
{latestValues.map((value) => (
<div key={value.key} className="stat-card latest-card" style={{ borderLeftColor: resolveLatestColor(value) }}>
<div>
<small>{normalizeDisplayText(value.displayName)}</small>
<strong>{normalizeDisplayText(value.alias)}</strong>
<span className="value-stamp">{formatTimestampLabel(value.timestamp)}</span>
</div>
<div className="stat-value">{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}<span>{normalizeDisplayUnit(value.unit)}</span></div>
</div>
))}
</section>
) : null}
</> </>
) : null} ) : null}
@@ -2380,7 +2425,7 @@ export default function App() {
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} /> <input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} /> <UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
<ScaleField value={String(point.factor ?? "1")} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { factor: nextValue })} /> <ScaleField value={String(point.factor ?? "1")} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { factor: nextValue })} />
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value })}> <select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value, unit: point.unit && normalizeDisplayUnit(point.unit) !== "?C" ? point.unit : getDefaultUnitForKind(event.target.value) })}>
<option value="analog">analog</option> <option value="analog">analog</option>
<option value="digital">digital</option> <option value="digital">digital</option>
</select> </select>
@@ -2501,7 +2546,7 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button> <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null} {source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null} {source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>L?schen</button> <button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
</div> </div>
</button> </button>
); );
@@ -2516,7 +2561,7 @@ export default function App() {
<h3>Datenpunkte & Werkzeuge</h3> <h3>Datenpunkte & Werkzeuge</h3>
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p> <p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
</div> </div>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")}</small> <small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}</small>
</div> </div>
<div className="protocol-grid source-tools-grid"> <div className="protocol-grid source-tools-grid">
@@ -2543,8 +2588,8 @@ export default function App() {
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null} {sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
</div> </div>
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null} {sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Ger?t(e) gefunden` : "F?r mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null} {sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">F?r 192.168.153.50 ist Input Register / FC4 best?tigt. Den Registertyp w?hlst du pro Datenpunkt ?ber Datenart.</small> : null} {sourceToolProtocol === "modbus-tcp" ? <small className="muted">Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart.</small> : null}
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null} {sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div> </div>
</div> </div>
@@ -2567,7 +2612,7 @@ export default function App() {
</label> </label>
<label> <label>
<span>Typ</span> <span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}> <select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value, unit: current.unit && normalizeDisplayUnit(current.unit) !== "?C" ? current.unit : getDefaultUnitForKind(event.target.value) }))}>
<option value="analog">analog</option> <option value="analog">analog</option>
<option value="digital">digital</option> <option value="digital">digital</option>
</select> </select>
@@ -2600,7 +2645,7 @@ export default function App() {
</label> </label>
<label className="span-two"> <label className="span-two">
<span>Bedingung / Notiz</span> <span>Bedingung / Notiz</span>
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage l?uft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} /> <input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage läuft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
</label> </label>
</div> </div>
<div className="button-row wrap-tight"> <div className="button-row wrap-tight">
@@ -2620,7 +2665,7 @@ export default function App() {
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3> <h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p> <p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
</div> </div>
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schlie?en</button> <button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
</div> </div>
<div className="form-grid compact-forms"> <div className="form-grid compact-forms">
@@ -2646,7 +2691,7 @@ export default function App() {
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} /> <input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
</label> </label>
<label> <label>
<span>Ger?te-ID</span> <span>Geräte-ID</span>
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} /> <input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
</label> </label>
<label className="span-two"> <label className="span-two">
@@ -2666,11 +2711,11 @@ export default function App() {
</label> </label>
</div> </div>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf.</small> <small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
<div className="button-row wrap-tight"> <div className="button-row wrap-tight">
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button> <button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle l?schen</button> : null} {selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
</div> </div>
</div> </div>
</div> </div>
@@ -2746,7 +2791,7 @@ export default function App() {
</div> </div>
</div> </div>
)) : null} )) : null}
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null} {!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
</div> </div>
</div> </div>
@@ -2916,7 +2961,7 @@ export default function App() {
<h3>Letzte Scans</h3> <h3>Letzte Scans</h3>
<div className="selection-list compact"> <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>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 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>
</div> </div>
@@ -2941,6 +2986,17 @@ export default function App() {
) : null} ) : null}
{activeView === "settings" ? ( {activeView === "settings" ? (
!isAuthenticated ? (
<section className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Anmeldung</h3>
<p>Mit Login werden Adminmenü und Einstellungen freigeschaltet.</p>
</div>
</div>
<LoginScreen onLogin={handleLogin} error={loginError} busy={busy} inline />
</section>
) : (
<section className="grid two compact-grid"> <section className="grid two compact-grid">
<div className="panel slim-panel"> <div className="panel slim-panel">
<div className="panel-header compact-header"> <div className="panel-header compact-header">
@@ -2960,7 +3016,7 @@ export default function App() {
<select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}> <select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}>
<option value=";">Semikolon ;</option> <option value=";">Semikolon ;</option>
<option value=",">Komma ,</option> <option value=",">Komma ,</option>
<option value="\t">Tabulator</option> <option value=" ">Tabulator</option>
</select> </select>
</label> </label>
@@ -2995,7 +3051,9 @@ export default function App() {
<button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button> <button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button>
</div> </div>
</section> </section>
)
) : null} ) : null}
</main> </main>
</div> </div>
); );
+17 -11
View File
@@ -57,6 +57,11 @@ function getAnalogDomain(rows, visibleSeries) {
return [min - padding, max + padding]; return [min - padding, max + padding];
} }
function formatTrendNumber(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : "--";
}
export default function TrendChart({ export default function TrendChart({
result, result,
title = "Trendansicht", title = "Trendansicht",
@@ -112,15 +117,6 @@ export default function TrendChart({
const yDomain = zoomDomain?.y || baseYDomain; const yDomain = zoomDomain?.y || baseYDomain;
const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length; const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length;
if (!rows.length) {
return (
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
<h3>{title}</h3>
<p>Keine Daten im gewählten Zeitraum.</p>
</div>
);
}
const mapDragToDomain = (clientX, clientY) => { const mapDragToDomain = (clientX, clientY) => {
const rect = wrapperRef.current?.getBoundingClientRect(); const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) { if (!rect) {
@@ -343,6 +339,15 @@ export default function TrendChart({
await exportGraphic(); await exportGraphic();
}; };
if (!rows.length) {
return (
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
<h3>{title}</h3>
<p>Keine Daten im gewählten Zeitraum.</p>
</div>
);
}
return ( return (
<div className={expanded ? "panel chart-panel focus-mode" : compact ? "panel chart-panel compact-chart" : "panel chart-panel"}> <div className={expanded ? "panel chart-panel focus-mode" : compact ? "panel chart-panel compact-chart" : "panel chart-panel"}>
<div className="panel-header compact-header chart-toolbar"> <div className="panel-header compact-header chart-toolbar">
@@ -378,7 +383,7 @@ export default function TrendChart({
</> </>
) : null} ) : null}
{zoomDomain ? <button className="ghost-button" type="button" onClick={resetZoom}>Zoom zurück</button> : null} {zoomDomain ? <button className="ghost-button" type="button" onClick={resetZoom}>Zoom zurück</button> : null}
{allowFocusMode ? <button className="ghost-button" type="button" onClick={onToggleExpanded}>{expanded ? "Normalansicht" : "Fokus"}</button> : null} {allowFocusMode ? <button className="ghost-button" type="button" onClick={onToggleExpanded}>{expanded ? "Normalansicht" : "Vollbild"}</button> : null}
</div> </div>
</div> </div>
@@ -400,7 +405,7 @@ export default function TrendChart({
minTickGap={28} minTickGap={28}
stroke="var(--muted)" stroke="var(--muted)"
/> />
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} /> : null} {analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} tickFormatter={formatTrendNumber} /> : null}
{digitalSeries.length ? ( {digitalSeries.length ? (
<YAxis <YAxis
yAxisId="digital" yAxisId="digital"
@@ -414,6 +419,7 @@ export default function TrendChart({
) : null} ) : null}
<Tooltip <Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")} labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
formatter={(value, name) => [formatTrendNumber(value), name]}
contentStyle={{ contentStyle={{
background: "var(--surface-strong)", background: "var(--surface-strong)",
border: "1px solid var(--border-strong)", border: "1px solid var(--border-strong)",
+26 -1
View File
@@ -382,6 +382,12 @@ span {
padding-inline: 0.7rem; padding-inline: 0.7rem;
} }
.sidebar-login {
justify-self: start;
border-radius: 999px;
padding-inline: 0.7rem;
}
.content { .content {
padding: 0.55rem 0.65rem 0.7rem; padding: 0.55rem 0.65rem 0.7rem;
display: grid; display: grid;
@@ -638,6 +644,24 @@ span {
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
} }
.trend-layout {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.95fr);
gap: 0.65rem;
align-items: start;
}
.trend-main,
.trend-side {
display: grid;
gap: 0.65rem;
align-content: start;
}
.trend-side {
min-width: 0;
}
.stat-card, .stat-card,
.gauge-card, .gauge-card,
.user-row { .user-row {
@@ -864,7 +888,8 @@ span {
.alias-row, .alias-row,
.protocol-grid, .protocol-grid,
.inline-fields, .inline-fields,
.inline-fields.multi-line { .inline-fields.multi-line,
.trend-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }