This commit is contained in:
jhartworks
2026-06-25 14:35:57 +02:00
parent bf1214239f
commit a12ef41d1b
116 changed files with 978 additions and 59769 deletions
+335 -1
View File
@@ -8,9 +8,12 @@
"name": "seltd-web",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.6",
"jspdf": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1"
"recharts": "^2.15.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.1",
@@ -1273,6 +1276,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1294,6 +1304,37 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1342,6 +1383,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -1363,6 +1416,39 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1372,6 +1458,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1379,6 +1474,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1540,6 +1686,13 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.376",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
@@ -1632,6 +1785,21 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1657,6 +1825,20 @@
"node": ">=6.9.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -1698,6 +1880,24 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.5.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1771,6 +1971,13 @@
"node": ">=0.10.0"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1838,6 +2045,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1945,6 +2162,23 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -2019,6 +2253,48 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -2073,6 +2349,25 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
@@ -2171,6 +2466,45 @@
}
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+2 -1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"cors": "^2.8.6",
"jspdf": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -19,4 +20,4 @@
"@vitejs/plugin-react": "^4.4.1",
"vite": "^6.0.7"
}
}
}
+138 -13
View File
@@ -42,6 +42,16 @@ const defaultWidgetDraft = {
max: 100,
};
const PERMISSION_OPTIONS = [
{ id: "view_dashboard", label: "Dashboard anzeigen" },
{ id: "view_trends", label: "Trends anzeigen" },
{ id: "edit_aliases", label: "Aliase bearbeiten" },
{ id: "manage_sources", label: "Quellen verwalten" },
{ id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Debug anzeigen" },
];
const pointColors = [
"#d62839",
"#2563eb",
@@ -127,6 +137,16 @@ function formatValue(value) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "--";
}
function formatCollectorValue(value) {
if (value === null || value === undefined || value === "") {
return "kein Wert";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return Number.isFinite(Number(value)) ? Number(value).toLocaleString("de-DE") : String(value);
}
function formatTimestampLabel(timestamp) {
return timestamp ? new Date(timestamp).toLocaleString("de-DE") : "Aktueller Wert";
}
@@ -471,6 +491,19 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint })
);
}
function hasPermission(user, permission) {
if (!user) {
return false;
}
if (user.role === "admin") {
return true;
}
return Array.isArray(user.permissions) && user.permissions.includes(permission);
}
export default function App() {
const [token, setToken] = useState(() => localStorage.getItem("seltd-token") || "");
const [activeView, setActiveView] = useState("dashboard");
@@ -516,7 +549,8 @@ export default function App() {
const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol) => ({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(protocol), kind: "analog", scale: "1", writeMode: "", condition: "" });
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
@@ -1170,7 +1204,7 @@ export default function App() {
const startNewSource = () => {
setIsCreatingSource(true);
setSelectedSourceId("");
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
setManualDatapointDraft(emptyManualDatapointDraft(defaultSourceDraft.protocol));
setSourceDraft({
...defaultSourceDraft,
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
@@ -1283,7 +1317,7 @@ export default function App() {
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(sourceDraft.protocol), kind: "analog" });
setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol));
setSuccessMessage("Datenpunkt angelegt.");
} catch (error) {
setBootError(error.message);
@@ -1328,13 +1362,20 @@ export default function App() {
};
const runOpcuaScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
const result = await api.scanOpcua({
host: sourceDraft.host,
port: sourceDraft.port || "4840",
sourceId: activeSourceId,
});
setScanResults((current) => ({ ...current, opcua: result }));
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("OPC-UA-Scan abgeschlossen.");
} catch (error) {
@@ -1343,6 +1384,11 @@ export default function App() {
};
const runBacnetScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
const hosts = sourceDraft.host
@@ -1353,8 +1399,10 @@ export default function App() {
hosts,
port: sourceDraft.port || "47808",
deviceId: sourceDraft.deviceId,
sourceId: activeSourceId,
});
setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen.");
} catch (error) {
@@ -1362,6 +1410,29 @@ export default function App() {
}
};
const updateCollectorPoint = async (datapoint, changes) => {
resetFlash();
try {
await api.updateCollectorDatapoint(datapoint.id, changes);
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("Datenpunkt aktualisiert.");
} catch (error) {
setBootError(error.message);
}
};
const readCollectorPoint = async (datapoint) => {
resetFlash();
try {
await api.readCollectorDatapoint(datapoint.id);
await refreshCollectorData();
setSuccessMessage("Live-Wert gelesen.");
} catch (error) {
setBootError(error.message);
}
};
const createUser = async () => {
resetFlash();
try {
@@ -2062,7 +2133,7 @@ export default function App() {
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="200" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
@@ -2079,6 +2150,22 @@ export default function App() {
<span>Einheit</span>
<UnitField value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
<label>
<span>Faktor</span>
<input type="number" step="0.0001" value={manualDatapointDraft.scale} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, scale: event.target.value }))} />
</label>
<label>
<span>Schreibmodus</span>
<select value={manualDatapointDraft.writeMode} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, writeMode: event.target.value }))}>
<option value="">Default der Quelle</option>
<option value="interval">Jeden Poll</option>
<option value="cov">Nur Änderung (COV)</option>
</select>
</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 }))} />
</label>
</div>
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
</div>
@@ -2090,7 +2177,7 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port oben</button>
</div>
<small className="muted">Verwendet Host und Port aus der Quelle oberhalb des Speicher-Buttons.</small>
{scanResults.opcua ? <small className="muted">{scanResults.opcua.reachable ? String(scanResults.opcua.endpoints.length) + " Endpunkt(e) gefunden" : "Host nicht erreichbar"}</small> : null}
{scanResults.opcua ? <small className="muted">{scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar"}</small> : null}
</div>
<div className="protocol-block">
@@ -2099,26 +2186,64 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port oben</button>
</div>
<small className="muted">Für mehrere Hosts einfach im Feld oben Komma, Leerzeichen oder Semikolon verwenden.</small>
{scanResults.bacnet ? <small className="muted">{scanResults.bacnet.devices?.length || 0} Gerät(e) geprüft</small> : null}
{scanResults.bacnet ? <small className="muted">{scanResults.bacnet.devices?.length || 0} Gerät(e), {scanResults.bacnet.points?.length || 0} Objekt(e)</small> : null}
</div>
</div>
{(scanResults.opcua?.nodes?.length || scanResults.bacnet?.points?.length) ? (
<div className="datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Scan-Ergebnis</h3>
<p>{scanResults.opcua?.nodes?.length || scanResults.bacnet?.points?.length || 0} gefundene Datenpunkte oder Objekte.</p>
</div>
</div>
<div className="scan-result-list">
{(scanResults.opcua?.nodes || []).filter((node) => node.hasValue).slice(0, 120).map((node) => (
<div key={node.nodeId} className="scan-result-row">
<div>
<strong>{node.displayName || node.browseName}</strong>
<span>{node.path || node.nodeId}</span>
</div>
<span>{formatCollectorValue(node.currentValue)}</span>
</div>
))}
{(scanResults.bacnet?.points || []).slice(0, 120).map((point) => (
<div key={point.id} className="scan-result-row">
<div>
<strong>{point.alias || point.name}</strong>
<span>{point.host} · {point.dataType}{point.objectInstance}</span>
</div>
<span>{formatCollectorValue(point.currentValue)}</span>
</div>
))}
</div>
</div>
) : null}
{collectorSourcePoints.length ? (
<div className="datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Datenpunkte</h3>
<p>{collectorSourcePoints.length} importierte oder erkannte Punkte.</p>
<p>{collectorSourcePoints.length} importierte, gescannte oder manuelle Punkte. Aktivierte Punkte werden geloggt.</p>
</div>
</div>
<div className={collectorSourcePoints.length <= 2 ? "selection-list compact" : "selection-list"}>
{collectorSourcePoints.slice(0, 24).map((item) => (
<div key={item.id} className="selection-card source-card preview-card">
<div>
<div className="datapoint-table">
{collectorSourcePoints.slice(0, 2000).map((item) => (
<div key={item.id} className={item.enabled === false ? "datapoint-row inactive" : "datapoint-row active"}>
<div className="datapoint-main">
<strong>{item.alias || item.name}</strong>
<span>{item.address || item.nodeId || item.dataType || item.protocol}</span>
<span>Wert {item.pointIndex} · {item.address || item.nodeId || item.dataType || item.protocol} · {item.writeMode === "cov" ? "COV" : "Intervall"}</span>
{item.condition ? <small>{item.condition}</small> : null}
</div>
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? " " + item.unit : ""}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Wert lesen</button>
<button className={item.enabled === false ? "primary-button compact-button" : "ghost-button compact-button"} type="button" onClick={() => updateCollectorPoint(item, { enabled: item.enabled === false })}>
{item.enabled === false ? "Logging an" : "Logging aus"}
</button>
</div>
<span>{item.unit || "°C"}</span>
</div>
))}
</div>
+2
View File
@@ -83,6 +83,8 @@ export const api = {
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
createCollectorDatapoint: (body) => collectorRequest("/datapoints/manual", { method: "POST", body }),
updateCollectorDatapoint: (id, body) => collectorRequest(`/datapoints/${encodeURIComponent(id)}`, { method: "PATCH", body }),
readCollectorDatapoint: (id) => collectorRequest(`/datapoints/${encodeURIComponent(id)}/read`, { method: "POST" }),
getCollectorDebug: () => collectorRequest("/debug"),
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
+46
View File
@@ -873,3 +873,49 @@ span {
.scan-result-list,
.datapoint-table {
display: grid;
gap: 0.35rem;
max-height: 420px;
overflow: auto;
}
.scan-result-row,
.datapoint-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.5rem;
align-items: center;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
background: color-mix(in srgb, var(--surface) 92%, transparent);
}
.datapoint-row.inactive {
opacity: 0.68;
}
.datapoint-main {
display: grid;
gap: 0.12rem;
min-width: 0;
}
.datapoint-main span,
.scan-result-row span,
.datapoint-main small {
color: var(--muted);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datapoint-value {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}