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

- Changed default MariaDB password in docker-compose.yml from 'SE3112' to '3112'.
- Added user creation and permissions for 'wago' in the MariaDB initialization script.
- Updated unit options in App.jsx to include a digital/bool option.
- Enhanced LoginScreen component to support inline display and improved error handling.
- Improved point filtering logic in PointLibrary and adjusted default unit handling.
- Refined authentication checks for dashboard and admin permissions.
- Simplified API calls by removing unnecessary token checks.
- Enhanced TrendChart component with better data handling and formatting.
- Updated CSS styles for improved layout and responsiveness in trend views and sidebar.
This commit is contained in:
jhartworks
2026-07-01 11:58:42 +02:00
parent e34626f15b
commit 2b9ff12a9f
11 changed files with 412 additions and 279 deletions
-1
View File
@@ -7,4 +7,3 @@ MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=*
VITE_API_PORT=18080
CORS_ORIGIN=http://localhost:5173
-10
View File
@@ -1,10 +0,0 @@
JWT_SECRET=change-me
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=System Admin
MARIADB_ROOT_PASSWORD=SE3112
DB_PASSWORD_FALLBACK=root
CORS_ORIGIN=*
VITE_API_PORT=18080
CORS_ORIGIN=http://localhost:5173
+19
View File
@@ -32,6 +32,25 @@ export function authenticateToken(req, res, next) {
}
}
export function authenticateTokenOptional(req, _res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token) {
req.user = null;
next();
return;
}
try {
req.user = jwt.verify(token, config.jwtSecret);
} catch {
req.user = null;
}
next();
}
export function requireRoles(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
+29 -12
View File
@@ -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));
+14 -9
View File
@@ -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,
+18 -9
View File
@@ -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,
};
+1 -1
View File
@@ -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:
+5
View File
@@ -61,3 +61,8 @@ CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'SE3112';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
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;
+283 -225
View File
@@ -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 (
<div className="login-screen">
<div className={inline ? "login-screen inline-login" : "login-screen"}>
<form className="login-card" onSubmit={handleSubmit}>
<div className="logo-lockup">
<img src="/logo.png" alt="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 <LoginScreen onLogin={handleLogin} error={loginError} busy={busy} />;
}
if (!sessionReady) {
return <div className="login-screen"><div className="panel">Sitzung wird geladen ...</div></div>;
}
@@ -1942,7 +1953,7 @@ export default function App() {
</div>
) : null}
</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} />
</IconButton>
</div>
@@ -1958,6 +1969,9 @@ export default function App() {
if (view.id === "admin") {
return canAccessAdmin;
}
if (view.id === "settings") {
return isAuthenticated;
}
return true;
}).map((view) => (
<button
@@ -1974,14 +1988,21 @@ export default function App() {
{!sidebarCollapsed ? (
<div className="sidebar-footer">
<div className="user-card">
<strong>{getUserDisplayName(user)}</strong>
<span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span>
</div>
<button className="ghost-button compact-button sidebar-logout" type="button" onClick={handleLogout}>Abmelden</button>
{isAuthenticated ? (
<>
<div className="user-card">
<strong>{getUserDisplayName(user)}</strong>
<span>{user?.role === "admin" ? "Systemzugang" : user?.role === "technician" ? "Technikzugang" : "Benutzerzugang"}</span>
</div>
<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 />
</div>
) : null}
</aside>
<main className="content">
@@ -2147,118 +2168,157 @@ export default function App() {
{activeView === "trends" ? (
<>
{!trendFocus ? (
<section className="grid two compact-grid">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Trendquelle</h3>
<p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p>
<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>
</div>
<label>
<span>ISP</span>
<select value={selectedIsp} onChange={(event) => setSelectedIsp(event.target.value)}>
{trendIsps.map((isp) => <option key={isp.tableName} value={isp.tableName}>{normalizeDisplayText(isp.displayName, isp.tableName)}</option>)}
</select>
</label>
<PointLibrary metadata={selectedMetadata} filterText={pointFilter} onFilterTextChange={setPointFilter} onAddPoint={addPoint} />
)}
{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="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Aktuelle Punkte</h3>
<p>Mehrere ISPs können gemeinsam angezeigt und gespeichert werden.</p>
<div className="trend-side">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Datenpunktauswahl</h3>
<p>Im Dropdown erscheinen nur Tabellen, die bereits Daten enthalten.</p>
</div>
</div>
<button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button>
</div>
<div className="selection-chips">
{workingPoints.map((point) => {
const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)];
const color = resolvePointColor(point, metadataCache);
return (
<button
key={`${point.isp}-${point.pointIndex}`}
type="button"
className="chip"
onClick={() => removePoint(point)}
style={{ borderLeftColor: color, background: `${color}12` }}
>
<strong>{normalizeDisplayText(metadataCache[point.isp]?.displayName, point.isp)}</strong>
<span>{normalizeDisplayText(meta?.alias, `Wert ${point.pointIndex}`)}</span>
</button>
);
})}
</div>
<div className="form-grid compact-forms">
<label>
<span>Zeitraum</span>
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
<span>ISP</span>
<select value={selectedIsp} onChange={(event) => setSelectedIsp(event.target.value)}>
{trendIsps.map((isp) => <option key={isp.tableName} value={isp.tableName}>{normalizeDisplayText(isp.displayName, isp.tableName)}</option>)}
</select>
</label>
<label>
<span>Max. Zeilen</span>
<input type="number" min="100" max="10000" value={trendRange.maxRows} onChange={(event) => setTrendRange((current) => ({ ...current, maxRows: event.target.value }))} />
</label>
<label>
<span>Start</span>
<input type="datetime-local" value={trendRange.from} onChange={(event) => setTrendRange((current) => ({ ...current, from: event.target.value }))} />
</label>
<label>
<span>Ende</span>
<input type="datetime-local" value={trendRange.to} onChange={(event) => setTrendRange((current) => ({ ...current, to: event.target.value }))} />
</label>
<PointLibrary metadata={selectedMetadata} filterText={pointFilter} onFilterTextChange={setPointFilter} onAddPoint={addPoint} />
</div>
<div className="button-row">
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Aktuelle Punkte</h3>
<p>Mehrere ISPs können gemeinsam angezeigt werden.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={startNewSelection}>Neue Auswahl</button>
</div>
<div className="selection-chips">
{workingPoints.map((point) => {
const meta = metadataCache[point.isp]?.points?.[String(point.pointIndex)];
const color = resolvePointColor(point, metadataCache);
return (
<button
key={`${point.isp}-${point.pointIndex}`}
type="button"
className="chip"
onClick={() => removePoint(point)}
style={{ borderLeftColor: color, background: `${color}12` }}
>
<strong>{normalizeDisplayText(metadataCache[point.isp]?.displayName, point.isp)}</strong>
<span>{normalizeDisplayText(meta?.alias, `Wert ${point.pointIndex}`)}</span>
</button>
);
})}
</div>
<div className="form-grid compact-forms">
<label>
<span>Zeitraum</span>
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label>
<span>Max. Zeilen</span>
<input type="number" min="100" max="10000" value={trendRange.maxRows} onChange={(event) => setTrendRange((current) => ({ ...current, maxRows: event.target.value }))} />
</label>
<label>
<span>Start</span>
<input type="datetime-local" value={trendRange.from} onChange={(event) => setTrendRange((current) => ({ ...current, from: event.target.value }))} />
</label>
<label>
<span>Ende</span>
<input type="datetime-local" value={trendRange.to} onChange={(event) => setTrendRange((current) => ({ ...current, to: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
</div>
{isAuthenticated ? (
<>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
<input value={selectionDraft.name} onChange={(event) => setSelectionDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Beschreibung</span>
<input value={selectionDraft.description} onChange={(event) => setSelectionDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
</div>
<label className="checkbox-field">
<input type="checkbox" checked={selectionDraft.shared} onChange={(event) => setSelectionDraft((current) => ({ ...current, shared: event.target.checked }))} />
<span>Auswahl für andere Nutzer freigeben</span>
</label>
<button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button>
</>
) : null}
</div>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
<input value={selectionDraft.name} onChange={(event) => setSelectionDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Beschreibung</span>
<input value={selectionDraft.description} onChange={(event) => setSelectionDraft((current) => ({ ...current, description: event.target.value }))} />
</label>
</div>
<label className="checkbox-field">
<input type="checkbox" checked={selectionDraft.shared} onChange={(event) => setSelectionDraft((current) => ({ ...current, shared: event.target.checked }))} />
<span>Auswahl für andere Nutzer freigeben</span>
</label>
<button className="primary-button compact-button" type="button" onClick={saveCurrentSelection}>Auswahl speichern</button>
</div>
</section>
) : null}
{trendResult ? (
) : trendResult ? (
<TrendChart
result={trendResult}
title="Trendansicht"
@@ -2272,24 +2332,9 @@ export default function App() {
) : (
<div className="panel empty-state slim-empty">
<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>
)}
{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}
@@ -2380,7 +2425,7 @@ export default function App() {
<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 })} />
<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="digital">digital</option>
</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>
{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}
<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>
</button>
);
@@ -2516,7 +2561,7 @@ export default function App() {
<h3>Datenpunkte & Werkzeuge</h3>
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
</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 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}
</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 === "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 === "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 === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div>
</div>
@@ -2567,7 +2612,7 @@ export default function App() {
</label>
<label>
<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="digital">digital</option>
</select>
@@ -2600,7 +2645,7 @@ export default function App() {
</label>
<label className="span-two">
<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>
</div>
<div className="button-row wrap-tight">
@@ -2620,7 +2665,7 @@ export default function App() {
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
</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 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 }))} />
</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 }))} />
</label>
<label className="span-two">
@@ -2666,11 +2711,11 @@ export default function App() {
</label>
</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">
<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>
@@ -2746,7 +2791,7 @@ export default function App() {
</div>
</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}
{!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>
@@ -2916,7 +2961,7 @@ export default function App() {
<h3>Letzte Scans</h3>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>OPC UA</strong><span>{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Ger?te</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte</span></div>
</div>
</div>
</div>
@@ -2941,61 +2986,74 @@ export default function App() {
) : null}
{activeView === "settings" ? (
<section className="grid two compact-grid">
<div className="panel slim-panel">
!isAuthenticated ? (
<section className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Darstellung</h3>
<p>Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.</p>
<h3>Anmeldung</h3>
<p>Mit Login werden Adminmenü und Einstellungen freigeschaltet.</p>
</div>
</div>
<label>
<span>Farbschema</span>
<ThemeSelect value={themeMode} onChange={setThemeMode} />
</label>
<label>
<span>CSV-Trennzeichen</span>
<select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value="\t">Tabulator</option>
</select>
</label>
<label className="checkbox-field">
<input type="checkbox" checked={sidebarPinned} onChange={(event) => setSidebarPinned(event.target.checked)} />
<span>Seitenleiste standardmäßig angeheftet lassen</span>
</label>
</div>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Passwort ändern</h3>
<p>Aktuelles Passwort bestätigen und danach ein neues setzen.</p>
<LoginScreen onLogin={handleLogin} error={loginError} busy={busy} inline />
</section>
) : (
<section className="grid two compact-grid">
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Darstellung</h3>
<p>Farbschema und Bedienung werden pro Benutzer in Redis gespeichert.</p>
</div>
</div>
<label>
<span>Farbschema</span>
<ThemeSelect value={themeMode} onChange={setThemeMode} />
</label>
<label>
<span>CSV-Trennzeichen</span>
<select value={csvDelimiter} onChange={(event) => setCsvDelimiter(event.target.value)}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value=" ">Tabulator</option>
</select>
</label>
<label className="checkbox-field">
<input type="checkbox" checked={sidebarPinned} onChange={(event) => setSidebarPinned(event.target.checked)} />
<span>Seitenleiste standardmäßig angeheftet lassen</span>
</label>
</div>
<div className="form-grid compact-forms">
<label>
<span>Aktuelles Passwort</span>
<input type="password" value={passwordDraft.currentPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, currentPassword: event.target.value }))} />
</label>
<label>
<span>Neues Passwort</span>
<input type="password" value={passwordDraft.nextPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, nextPassword: event.target.value }))} />
</label>
<label className="span-two">
<span>Neues Passwort wiederholen</span>
<input type="password" value={passwordDraft.repeatPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, repeatPassword: event.target.value }))} />
</label>
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
<h3>Passwort ändern</h3>
<p>Aktuelles Passwort bestätigen und danach ein neues setzen.</p>
</div>
</div>
<div className="form-grid compact-forms">
<label>
<span>Aktuelles Passwort</span>
<input type="password" value={passwordDraft.currentPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, currentPassword: event.target.value }))} />
</label>
<label>
<span>Neues Passwort</span>
<input type="password" value={passwordDraft.nextPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, nextPassword: event.target.value }))} />
</label>
<label className="span-two">
<span>Neues Passwort wiederholen</span>
<input type="password" value={passwordDraft.repeatPassword} onChange={(event) => setPasswordDraft((current) => ({ ...current, repeatPassword: event.target.value }))} />
</label>
</div>
<button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button>
</div>
<button className="primary-button compact-button" type="button" onClick={changePassword}>Passwort speichern</button>
</div>
</section>
</section>
)
) : null}
</main>
</div>
);
+17 -11
View File
@@ -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 (
<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 rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) {
@@ -343,6 +339,15 @@ export default function TrendChart({
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 (
<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">
@@ -378,7 +383,7 @@ export default function TrendChart({
</>
) : 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>
@@ -400,7 +405,7 @@ export default function TrendChart({
minTickGap={28}
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 ? (
<YAxis
yAxisId="digital"
@@ -414,6 +419,7 @@ export default function TrendChart({
) : null}
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
formatter={(value, name) => [formatTrendNumber(value), name]}
contentStyle={{
background: "var(--surface-strong)",
border: "1px solid var(--border-strong)",
+26 -1
View File
@@ -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;
}
}