From 2b9ff12a9fa72c26f091835a2d0dd780be3bcb3a Mon Sep 17 00:00:00 2001 From: jhartworks Date: Wed, 1 Jul 2026 11:58:42 +0200 Subject: [PATCH] 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. --- .env | 1 - .env.example | 10 - api/src/auth.js | 19 ++ api/src/index.js | 41 ++- api/src/lib/isp.js | 23 +- collector/src/index.js | 27 +- docker-compose.yml | 2 +- docker/mariadb/init/01-init.sql | 5 + web/src/App.jsx | 508 +++++++++++++++++------------- web/src/components/TrendChart.jsx | 28 +- web/src/styles.css | 27 +- 11 files changed, 412 insertions(+), 279 deletions(-) delete mode 100644 .env.example diff --git a/.env b/.env index 0961510..127b150 100644 --- a/.env +++ b/.env @@ -7,4 +7,3 @@ MARIADB_ROOT_PASSWORD=SE3112 DB_PASSWORD_FALLBACK=root CORS_ORIGIN=* VITE_API_PORT=18080 -CORS_ORIGIN=http://localhost:5173 diff --git a/.env.example b/.env.example deleted file mode 100644 index 0961510..0000000 --- a/.env.example +++ /dev/null @@ -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 diff --git a/api/src/auth.js b/api/src/auth.js index e07c918..e969255 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -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) { return (req, res, next) => { if (!req.user || !roles.includes(req.user.role)) { diff --git a/api/src/index.js b/api/src/index.js index 101fdb3..7368eab 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,7 +1,7 @@ import bcrypt from "bcryptjs"; import cors from "cors"; 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 { pool } from "./db.js"; import { @@ -88,7 +88,7 @@ function assertPoints(points) { } 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) { @@ -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) { return async (req, res, next) => { 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() { await pool.query( ` @@ -384,7 +400,7 @@ async function resolveIspPointCount(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 response = await Promise.all( isps.map(async (isp) => { @@ -400,7 +416,7 @@ app.get("/api/isps", authenticateToken, wrap(async (_req, res) => { 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 metadata = await ensureIspMetadata(pool, redis, req.params.isp, pointCount); res.json(metadata); @@ -694,20 +710,20 @@ app.delete("/api/selections/:selectionId", authenticateToken, requirePermission( res.status(204).send(); })); -app.post("/api/trends/query", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { - const points = await resolveSelectionPoints(req.body, req.user); +app.post("/api/trends/query", authenticateTokenOptional, wrap(async (req, res) => { + const points = await resolveSelectionPoints(req.body, resolveViewerUser(req)); const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(req.body.range)); res.json(trendData); })); -app.post("/api/points/latest", authenticateToken, requirePermission("view_trends"), wrap(async (req, res) => { - const points = await resolveSelectionPoints(req.body, req.user); +app.post("/api/points/latest", authenticateTokenOptional, wrap(async (req, res) => { + const points = await resolveSelectionPoints(req.body, resolveViewerUser(req)); const values = await fetchLatestValues(pool, redis, points); res.json({ values }); })); -app.get("/api/dashboard", authenticateToken, requirePermission("view_dashboard"), wrap(async (req, res) => { - const dashboard = await getDashboard(redis, req.user.sub); +app.get("/api/dashboard", authenticateTokenOptional, wrap(async (req, res) => { + const dashboard = await getDashboard(redis, req.user?.sub || "public"); res.json(dashboard); })); @@ -716,12 +732,13 @@ app.put("/api/dashboard", authenticateToken, requirePermission("view_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 results = []; + const viewer = resolveViewerUser(req); 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") { const trendData = await fetchTrendSeries(pool, redis, points, normalizeRange(widget.range)); diff --git a/api/src/lib/isp.js b/api/src/lib/isp.js index 3044291..88cc632 100644 --- a/api/src/lib/isp.js +++ b/api/src/lib/isp.js @@ -113,14 +113,19 @@ function createRandomPointColor(seed) { 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(); 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))) { - return "\u00B0C"; + return normalizedKind === "digital" ? "bool" : "\u00B0C"; } return raw; @@ -388,7 +393,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO parsed.points[pointKey] = { ...defaultPoint, alias: initialAlias, - unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit), + unit: normalizeEngineeringUnit(sourceSeed?.unit || defaultPoint.unit, sourceSeed?.kind || defaultPoint.kind), kind: sourceSeed?.kind || defaultPoint.kind, }; changed = true; @@ -407,13 +412,13 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO point.kind = sourceSeed.kind; changed = true; } - const normalizedUnit = normalizeEngineeringUnit(point.unit); + const normalizedUnit = normalizeEngineeringUnit(point.unit, point.kind || sourceSeed?.kind || defaultPoint.kind); if (normalizedUnit !== point.unit) { point.unit = normalizedUnit; changed = true; } - if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit)) && sourceSeed?.unit) { - const seededUnit = normalizeEngineeringUnit(sourceSeed.unit); + if ((!point.unit || point.unit === normalizeEngineeringUnit(defaultPoint.unit, defaultPoint.kind)) && sourceSeed?.unit) { + const seededUnit = normalizeEngineeringUnit(sourceSeed.unit, sourceSeed.kind || point.kind || defaultPoint.kind); if (point.unit !== seededUnit) { point.unit = seededUnit; changed = true; @@ -463,7 +468,7 @@ export async function ensureIspMetadata(pool, redis, isp, pointCount = LEGACY_PO metadata.points[pointKey] = { ...metadata.points[pointKey], 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, }; }); @@ -506,7 +511,7 @@ export async function getPointDescriptors(pool, redis, points) { key: buildPointKey(isp, pointIndex), displayName: metadata.displayName, alias: pointMeta.alias, - unit: normalizeEngineeringUnit(pointMeta.unit), + unit: normalizeEngineeringUnit(pointMeta.unit, pointMeta.kind), factor: pointMeta.factor, kind: pointMeta.kind, min: pointMeta.min, diff --git a/collector/src/index.js b/collector/src/index.js index b01965e..2a5f35f 100644 --- a/collector/src/index.js +++ b/collector/src/index.js @@ -156,16 +156,25 @@ function normalizeUnit(value) { const raw = normalizeText(value); const collapsed = raw.replace(/\s+/g, ""); if (!collapsed) { - return "\u00B0C"; + return "°C"; } - if (/^\?C$/i.test(collapsed) || /^\u00B0C$/i.test(collapsed)) { - return "\u00B0C"; + if (/^(?:\?C|\u00B0C)$/i.test(collapsed)) { + return "°C"; } 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) { const numeric = Number(value); return Number.isInteger(numeric) && numeric >= 1 && numeric <= LOCAL_POINT_LIMIT ? numeric : fallback; @@ -244,7 +253,7 @@ function parseModbusCsv(payload) { pointIndex, 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}`, - unit: normalizeUnit(get("unit")), + unit: normalizePointUnit(get("unit"), get("kind", get("type", "analog"))), dataType: get("datatype", "holding-register"), kind: normalizePointKind(get("kind", get("type", "analog"))), enabled: payload.enabled === true, @@ -294,7 +303,7 @@ function parseKnxXml(payload) { pointIndex: index + 1, name, alias: name, - unit: normalizeUnit(""), + unit: normalizePointUnit("", dpt.startsWith("1.") ? "digital" : "analog"), dataType: dpt || "group-address", kind: dpt.startsWith("1.") ? "digital" : "analog", enabled: payload.enabled === true, @@ -331,7 +340,7 @@ function createManualDatapoint(store, payload) { pointIndex, name, alias, - unit: normalizeUnit(payload.unit), + unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled !== false, writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), @@ -365,7 +374,7 @@ function createScanDatapoint(source, payload, index) { pointIndex, name: normalizeText(payload.name || alias) || alias, alias, - unit: normalizeUnit(payload.unit), + unit: normalizePointUnit(payload.unit, payload.kind), kind: normalizePointKind(payload.kind), enabled: payload.enabled === true, writeMode: normalizeWriteMode(payload.writeMode || source.writeMode), @@ -413,7 +422,7 @@ function buildScannedDatapoints(store, source, rawPoints, toPayload) { pointIndex: existing.pointIndex, alias: existing.alias || scanned.alias, 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, enabled: existing.enabled === true, 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, 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, - 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, scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale, }; diff --git a/docker-compose.yml b/docker-compose.yml index 1521293..8938fd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: MARIADB_ROOT_HOST: mariadb MARIADB_DATABASE: wago MARIADB_USER: wago - MARIADB_PASSWORD: ${MARIADB_PASSWORD:-SE3112} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:-3112} ports: - "3306:3306" volumes: diff --git a/docker/mariadb/init/01-init.sql b/docker/mariadb/init/01-init.sql index 0f48cf0..768ce7c 100644 --- a/docker/mariadb/init/01-init.sql +++ b/docker/mariadb/init/01-init.sql @@ -61,3 +61,8 @@ CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'SE3112'; GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION; 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; diff --git a/web/src/App.jsx b/web/src/App.jsx index bf4832e..e5b52c2 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -102,12 +102,13 @@ const pointColors = [ ]; const DEFAULT_UNIT_OPTIONS = [ - { symbol: "\u00B0C", label: "Grad Celsius", isSystem: true }, + { symbol: "°C", label: "Grad Celsius", isSystem: true }, { symbol: "%", label: "Prozent", isSystem: true }, { symbol: "K", label: "Kelvin", isSystem: true }, { symbol: "V", label: "Volt", isSystem: true }, { symbol: "A", label: "Ampere", 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 CUSTOM_UNIT_VALUE = "__custom__"; @@ -164,6 +165,10 @@ function getDefaultPointDataType(protocol) { return "holding-register"; } +function getDefaultUnitForKind(kind) { + return kind === "digital" ? "bool" : "°C"; +} + function getPointDataTypeOptions(protocol) { if (protocol === "modbus-tcp") { 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 [password, setPassword] = useState(""); @@ -553,7 +558,7 @@ function LoginScreen({ onLogin, error, busy }) { }; return ( -
+
SE Logo @@ -609,6 +614,7 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint }) const search = filterText.trim().toLowerCase(); return getOrderedPointEntries(metadata) + .filter((point) => normalizeDisplayText(point.alias).trim().length > 0) .filter((point) => { if (!search) { return true; @@ -711,7 +717,7 @@ export default function App() { const [isCreatingSource, setIsCreatingSource] = useState(false); const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); 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 [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft()); 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 selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null; - const canViewDashboard = hasPermission(user, "view_dashboard"); - const canViewTrends = hasPermission(user, "view_trends"); - const canEditAliases = hasPermission(user, "edit_aliases"); - const canManageSources = hasPermission(user, "manage_sources"); - const canManageUsers = hasPermission(user, "manage_users"); - const canManageRoles = hasPermission(user, "manage_roles"); - const canViewDebug = hasPermission(user, "view_debug"); + const isAuthenticated = Boolean(token); + const canViewDashboard = !isAuthenticated || hasPermission(user, "view_dashboard"); + const canViewTrends = !isAuthenticated || hasPermission(user, "view_trends"); + const canEditAliases = isAuthenticated && hasPermission(user, "edit_aliases"); + const canManageSources = isAuthenticated && hasPermission(user, "manage_sources"); + const canManageUsers = isAuthenticated && hasPermission(user, "manage_users"); + const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles"); + const canViewDebug = isAuthenticated && hasPermission(user, "view_debug"); const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug; const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases"; const sidebarCollapsed = !sidebarPinned && !sidebarHover; @@ -777,10 +784,7 @@ export default function App() { }; const refreshIspCatalog = async () => { - if (!token) { - return []; - } - const result = await api.getIsps(token); + const result = await api.getIsps(token || undefined); const nextIsps = result.isps || []; setIsps(nextIsps); return nextIsps; @@ -843,18 +847,31 @@ export default function App() { }, [activeView, adminSection]); useEffect(() => { - if (!token) { - setSessionReady(true); - setPreferencesReady(false); - return; - } - let ignore = false; async function bootstrap() { setSessionReady(false); resetFlash(); 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([ api.me(token), api.getPreferences(token), @@ -879,10 +896,12 @@ export default function App() { } catch (error) { if (!ignore) { setBootError(error.message); - setLoginError(error.message); - setToken(""); - setUser(null); - localStorage.removeItem("seltd-token"); + if (token) { + setLoginError(error.message); + setToken(""); + setUser(null); + localStorage.removeItem("seltd-token"); + } } } finally { if (!ignore) { @@ -952,11 +971,11 @@ export default function App() { }, [csvDelimiter, preferencesReady, sidebarPinned, themeMode, token]); useEffect(() => { - if (!token || !selectedIsp || metadataCache[selectedIsp]) { + if (!selectedIsp || metadataCache[selectedIsp]) { return; } - api.getAliases(token, selectedIsp) + api.getAliases(token || undefined, selectedIsp) .then((result) => setMetadataCache((current) => ({ ...current, [selectedIsp]: result }))) .catch((error) => setBootError(error.message)); }, [metadataCache, selectedIsp, token]); @@ -975,7 +994,7 @@ export default function App() { }, [adminIsp, token]); useEffect(() => { - if (!token || workingPoints.length === 0) { + if (workingPoints.length === 0) { return; } @@ -984,7 +1003,7 @@ export default function App() { return; } - Promise.all(missingIsps.map((isp) => api.getAliases(token, isp))) + Promise.all(missingIsps.map((isp) => api.getAliases(token || undefined, isp))) .then((results) => { setMetadataCache((current) => { const next = { ...current }; @@ -998,12 +1017,12 @@ export default function App() { }, [metadataCache, token, workingPoints]); useEffect(() => { - if (!token || dashboard.widgets.length === 0) { + if (dashboard.widgets.length === 0) { setDashboardData({}); 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])))) .catch((error) => setBootError(error.message)); }, [dashboard, token]); @@ -1045,11 +1064,6 @@ export default function App() { }, [canEditAliases, canManageSources, token]); useEffect(() => { - if (!token) { - return undefined; - } - - let ignore = false; const refreshIsps = () => { refreshIspCatalog().catch(() => undefined); }; @@ -1064,7 +1078,6 @@ export default function App() { window.addEventListener("focus", refreshIsps); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { - ignore = true; window.clearInterval(timer); window.removeEventListener("focus", refreshIsps); document.removeEventListener("visibilitychange", handleVisibilityChange); @@ -1106,6 +1119,7 @@ export default function App() { setManualDatapointDraft((current) => ({ ...current, dataType: getDefaultPointDataType(currentSource.protocol || "modbus-tcp"), + unit: current.unit || getDefaultUnitForKind(current.kind || "analog"), })); }, [currentSource]); @@ -1204,7 +1218,8 @@ export default function App() { }; 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; } @@ -1900,7 +1915,7 @@ export default function App() { } if (passwordDraft.nextPassword !== passwordDraft.repeatPassword) { - setBootError("Die neuen Passw?rter stimmen nicht ?berein."); + setBootError("Die neuen Passwörter stimmen nicht überein."); return; } @@ -1910,16 +1925,12 @@ export default function App() { nextPassword: passwordDraft.nextPassword, }); setPasswordDraft({ currentPassword: "", nextPassword: "", repeatPassword: "" }); - setSuccessMessage("Passwort ge?ndert."); + setSuccessMessage("Passwort geändert."); } catch (error) { setBootError(error.message); } }; - if (!token) { - return ; - } - if (!sessionReady) { return
Sitzung wird geladen ...
; } @@ -1942,7 +1953,7 @@ export default function App() {
) : null}
- setSidebarPinned((current) => !current)}> + setSidebarPinned((current) => !current)}>
@@ -1958,6 +1969,9 @@ export default function App() { if (view.id === "admin") { return canAccessAdmin; } + if (view.id === "settings") { + return isAuthenticated; + } return true; }).map((view) => ( + {isAuthenticated ? ( + <> +
+ {getUserDisplayName(user)} + {user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"} +
+ + + ) : ( + + )} ) : null} +
@@ -2147,118 +2168,157 @@ export default function App() { {activeView === "trends" ? ( <> {!trendFocus ? ( -
-
-
-
-

Trendquelle

-

Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.

+
+
+ {trendResult ? ( + setTrendFocus((current) => !current)} + csvDelimiter={csvDelimiter} + onCsvDelimiterChange={setCsvDelimiter} + allowExports + allowFocusMode + /> + ) : ( +
+

Trendansicht

+

Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.

-
- - + )} + + {latestValues.length ? ( +
+ {latestValues.map((value) => ( +
+
+ {normalizeDisplayText(value.displayName)} + {normalizeDisplayText(value.alias)} + {formatTimestampLabel(value.timestamp)} +
+
{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}{normalizeDisplayUnit(value.unit)}
+
+ ))} +
+ ) : null}
-
-
-
-

Aktuelle Punkte

-

Mehrere ISPs können gemeinsam angezeigt und gespeichert werden.

+
+
+
+
+

Datenpunktauswahl

+

Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.

+
- -
- -
- {workingPoints.map((point) => { - const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)]; - const color = resolvePointColor(point, metadataCache); - return ( - - ); - })} -
- -
- - - +
-
- +
+
+
+

Aktuelle Punkte

+

Mehrere ISPs können gemeinsam angezeigt werden.

+
+ +
+ +
+ {workingPoints.map((point) => { + const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)]; + const color = resolvePointColor(point, metadataCache); + return ( + + ); + })} +
+ +
+ + + + +
+ +
+ +
+ + {isAuthenticated ? ( + <> + + +
+ + +
+ + + + + + ) : null}
- - - -
- - -
- - - -
- ) : null} - - {trendResult ? ( + ) : trendResult ? (

Trendansicht

-

Nach dem Laden erscheinen hier Graph, Exporte und Fokusansicht.

+

Nach dem Laden erscheinen hier Graph, Exporte und Vollbildansicht.

)} - - {latestValues.length && !trendFocus ? ( -
- {latestValues.map((value) => ( -
-
- {normalizeDisplayText(value.displayName)} - {normalizeDisplayText(value.alias)} - {formatTimestampLabel(value.timestamp)} -
-
{value.value !== null ? formatValue(value.value) : normalizeDisplayText(value.raw, "--")}{normalizeDisplayUnit(value.unit)}
-
- ))} -
- ) : null} ) : null} @@ -2380,7 +2425,7 @@ export default function App() { updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} /> updateAliasPoint(String(point.pointIndex), { unit: nextValue })} /> updateAliasPoint(String(point.pointIndex), { factor: nextValue })} /> - updateAliasPoint(String(point.pointIndex), { kind: event.target.value, unit: point.unit && normalizeDisplayUnit(point.unit) !== "?C" ? point.unit : getDefaultUnitForKind(event.target.value) })}> @@ -2501,7 +2546,7 @@ export default function App() { {source.protocol === "opc-ua" ? : null} {source.protocol === "bacnet-ip" ? : null} - + ); @@ -2516,7 +2561,7 @@ export default function App() {

Datenpunkte & Werkzeuge

Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.

- Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")} + Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}
@@ -2543,8 +2588,8 @@ export default function App() { {sourceToolProtocol === "bacnet-ip" ? : null}
{sourceToolProtocol === "opc-ua" ? {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."} : null} - {sourceToolProtocol === "bacnet-ip" ? {scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Ger?t(e) gefunden` : "F?r mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."} : null} - {sourceToolProtocol === "modbus-tcp" ? F?r 192.168.153.50 ist Input Register / FC4 best?tigt. Den Registertyp w?hlst du pro Datenpunkt ?ber Datenart. : null} + {sourceToolProtocol === "bacnet-ip" ? {scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."} : null} + {sourceToolProtocol === "modbus-tcp" ? Für 192.168.153.50 ist Input Register / FC4 bestätigt. Den Registertyp wählst du pro Datenpunkt über Datenart. : null} {sourceToolProtocol === "knx-ip" ? KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links. : null} @@ -2567,7 +2612,7 @@ export default function App() {
@@ -2620,7 +2665,7 @@ export default function App() {

{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}

Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.

- +
@@ -2646,7 +2691,7 @@ export default function App() { setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
- Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf. + Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.
- {selectedSourceId ? : null} + {selectedSourceId ? : null}
@@ -2746,7 +2791,7 @@ export default function App() { )) : null} - {!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ?

{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}

: null} + {!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ?

{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}

: null} @@ -2916,7 +2961,7 @@ export default function App() {

Letzte Scans

OPC UA{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}
-
BACnet{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Ger?te
+
BACnet{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte
@@ -2941,61 +2986,74 @@ export default function App() { ) : null} {activeView === "settings" ? ( -
-
+ !isAuthenticated ? ( +
-

Darstellung

-

Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.

+

Anmeldung

+

Mit Login werden Adminmenü und Einstellungen freigeschaltet.

- - - - - - -
- -
-
-
-

Passwort ändern

-

Aktuelles Passwort bestätigen und danach ein neues setzen.

+ +
+ ) : ( +
+
+
+
+

Darstellung

+

Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.

+
+ + + + + +
-
- - - +
+
+
+

Passwort ändern

+

Aktuelles Passwort bestätigen und danach ein neues setzen.

+
+
+ +
+ + + +
+
- -
-
+ + ) ) : null} +
); diff --git a/web/src/components/TrendChart.jsx b/web/src/components/TrendChart.jsx index de98f68..95e22f9 100644 --- a/web/src/components/TrendChart.jsx +++ b/web/src/components/TrendChart.jsx @@ -57,6 +57,11 @@ function getAnalogDomain(rows, visibleSeries) { return [min - padding, max + padding]; } +function formatTrendNumber(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric.toFixed(2) : "--"; +} + export default function TrendChart({ result, title = "Trendansicht", @@ -112,15 +117,6 @@ export default function TrendChart({ const yDomain = zoomDomain?.y || baseYDomain; const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length; - if (!rows.length) { - return ( -
-

{title}

-

Keine Daten im gewählten Zeitraum.

-
- ); - } - const mapDragToDomain = (clientX, clientY) => { const rect = wrapperRef.current?.getBoundingClientRect(); if (!rect) { @@ -343,6 +339,15 @@ export default function TrendChart({ await exportGraphic(); }; + if (!rows.length) { + return ( +
+

{title}

+

Keine Daten im gewählten Zeitraum.

+
+ ); + } + return (
@@ -378,7 +383,7 @@ export default function TrendChart({ ) : null} {zoomDomain ? : null} - {allowFocusMode ? : null} + {allowFocusMode ? : null}
@@ -400,7 +405,7 @@ export default function TrendChart({ minTickGap={28} stroke="var(--muted)" /> - {analogSeries.length ? : null} + {analogSeries.length ? : null} {digitalSeries.length ? ( new Date(value).toLocaleString("de-DE")} + formatter={(value, name) => [formatTrendNumber(value), name]} contentStyle={{ background: "var(--surface-strong)", border: "1px solid var(--border-strong)", diff --git a/web/src/styles.css b/web/src/styles.css index ac19949..6ccad05 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -382,6 +382,12 @@ span { padding-inline: 0.7rem; } +.sidebar-login { + justify-self: start; + border-radius: 999px; + padding-inline: 0.7rem; +} + .content { padding: 0.55rem 0.65rem 0.7rem; display: grid; @@ -638,6 +644,24 @@ span { 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, .gauge-card, .user-row { @@ -864,7 +888,8 @@ span { .alias-row, .protocol-grid, .inline-fields, - .inline-fields.multi-line { + .inline-fields.multi-line, + .trend-layout { grid-template-columns: 1fr; } }