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:
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user