From e34626f15b41091f0fd0c8f0ba3bf3dab1276baa Mon Sep 17 00:00:00 2001 From: jhartworks Date: Tue, 30 Jun 2026 09:42:55 +0200 Subject: [PATCH] beta. Modbus fixed and tested --- .gitignore | 1 + README.md | 829 ++++++++++++++++++++++++++++-- collector/src/index.js | 195 +++++-- docker-compose.yml | 4 +- web/src/App.jsx | 506 ++++++++++++------ web/src/api.js | 1 + web/src/components/TrendChart.jsx | 46 +- web/src/styles.css | 19 +- 8 files changed, 1355 insertions(+), 246 deletions(-) diff --git a/.gitignore b/.gitignore index f44ecb1..69b287f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist npm-debug.log* .scratch/runtime data/ +web/src diff --git a/README.md b/README.md index 2a2fac6..a9d9cae 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,798 @@ -# SE Local Trenddata. +# SE Local Trenddata -Modernisierte Trendoberfläche für Wago-Bestandsdatenbanken mit React, Node.js, MariaDB, Redis, Dockhand und vorbereitetem Collector-Service. +SE Local Trenddata ist eine webbasierte Trend-, Dashboard- und Quellenverwaltung fuer lokale Anlagen- und WAGO-Daten. Das Projekt besteht aus einem React-Frontend, einer Node.js/Express-API, MariaDB, Redis und einem Collector-Service fuer Protokollquellen wie Modbus TCP, OPC UA, BACnet IP und KNX IP. -## Enthalten -- React-Frontend mit System-, Hell- und Dunkelmodus -- Username-Login, Rollen, Passwortwechsel und Benutzerverwaltung -- Alias-Verwaltung für `Wert1` bis `Wert200` je Trendtabelle -- CSV-Import und CSV-Export für Aliasdaten -- Gespeicherte Auswahlen über mehrere ISPs hinweg -- Dashboard mit Graph-, Zahl- und Zeigermanometer-Widgets -- Fokusansicht für den Trendgraph statt Browser-Vollbild -- Export des Trendgraphs als CSV, XLSX, PNG, JPG, SVG und PDF -- SQL-Import für vorhandene Langzeitdaten -- Redis für Metadaten, Auswahlen, Dashboard und Benutzerpräferenzen -- MariaDB-Init für `isp01` bis `isp20` plus `app_users` -- Dockhand im Compose-Stack -- Collector-Gerüst für spätere Modbus-, OPC-UA-, BACnet- und KNX-Anbindung +Das Ziel ist, bestehende breite Trendtabellen wie `isp01`, `isp02` usw. weiter nutzen zu koennen und zusaetzlich neue Quellen sauber ueber eine Weboberflaeche anzulegen, zu scannen, zu loggen und im Dashboard oder in der Trendansicht auszuwerten. -## Starten -1. Optional `.env.example` nach `.env` kopieren und Werte anpassen. -2. `docker compose up -d --build` -3. Frontend: `http://localhost:5173` -4. API: `http://localhost:18080/api` -5. Dockhand: `http://localhost:3000` -6. Collector: `http://localhost:18110/health` +--- -## Standard-Login -- Username: `admin` -- Passwort: `ChangeMe123!` +## Inhaltsverzeichnis -## Datenbank-Kompatibilität -- Bestehende Anlagen können weiter mit `root` und Passwort `SE3112` auf die MariaDB gehen. -- Die API versucht zusätzlich als Fallback noch das ältere Root-Passwort `root`, damit vorhandene Entwicklungs-Volumes nicht sofort umgebaut werden müssen. +- [Projektaufbau](#projektaufbau) +- [Architektur](#architektur) +- [Services und Ports](#services-und-ports) +- [Schnellstart mit Docker Compose](#schnellstart-mit-docker-compose) +- [Lokale Entwicklung](#lokale-entwicklung) +- [Konfiguration](#konfiguration) +- [Login, Benutzer und Rollen](#login-benutzer-und-rollen) +- [Frontend-Funktionen](#frontend-funktionen) +- [API-Funktionen](#api-funktionen) +- [Collector-Funktionen](#collector-funktionen) +- [Datenbank und Speicher](#datenbank-und-speicher) +- [WAGO- oder SD-Karten-Betrieb](#wago--oder-sd-karten-betrieb) +- [Backup und Restore](#backup-und-restore) +- [Deployment-Hinweise](#deployment-hinweise) +- [Troubleshooting](#troubleshooting) +- [Sicherheits-Checkliste](#sicherheits-checkliste) -## Wago / SD-Karte -- Für Wago-Systeme liegt mit [docker-compose.wago.yml](/C:/Projects/SeLTD/docker-compose.wago.yml) ein Override bereit. -- Start auf Wago mit SD-Karte: `docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build` -- Dabei landen die großen Daten unter `/media/sd/seltd/...`. +--- -## Hinweise -- Vorhandene SQL-Dumps können im Admin-Bereich direkt importiert werden. -- Leere Trendtabellen tauchen nicht im Trend-Dropdown auf, aber weiter im Admin-Bereich. -- Der Collector ist bewusst erst als technisches Gerüst enthalten; Protokoll-Scan und Feldbus-Importe sind noch nicht vollständig implementiert. -- Für Dockhand wurde der Container ergänzt. Eine sichere automatische Vorbelegung eines lokalen Admin-Users war ohne offizielle Initialisierungsdokumentation nicht belastbar genug und wurde deshalb nicht blind in die SQLite-Datenbank geschrieben. +## Projektaufbau + +```text +SeLTD/ +├── .env.example +├── docker-compose.yml +├── docker-compose.wago.yml +├── docker/ +│ └── redis/ +│ └── redis.conf +├── api/ +│ ├── Dockerfile +│ ├── package.json +│ └── src/ +│ ├── index.js +│ ├── auth.js +│ ├── config.js +│ ├── db.js +│ ├── redis.js +│ └── lib/ +│ ├── isp.js +│ └── store.js +├── collector/ +│ ├── Dockerfile +│ ├── package.json +│ └── src/ +│ └── index.js +└── web/ + ├── Dockerfile + ├── package.json + ├── vite.config.js + ├── public/ + │ └── logo.png + └── src/ + ├── App.jsx + ├── api.js + ├── main.jsx + ├── styles.css + └── components/ + ├── Gauge.jsx + └── TrendChart.jsx +``` + +### Hauptbereiche + +| Bereich | Aufgabe | +| --- | --- | +| `web/` | React/Vite-Frontend fuer Login, Dashboard, Trends, Admin-Bereich, Quellen, Rollen und Benutzer | +| `api/` | Express-API fuer Auth, Rollen, Aliase, Trendabfragen, Dashboard, SQL-Import und Benutzerverwaltung | +| `collector/` | HTTP-Service fuer Protokollquellen, Datenpunkte, Scans, Imports und zyklisches Logging | +| `mariadb` | Persistente Trenddaten und Benutzer-Tabelle | +| `redis` | Metadaten, Aliase, Rollen, Einheiten, Dashboards, Auswahlen und User-Praeferenzen | +| `dockhand` | Docker-Verwaltung ueber Weboberflaeche | +| `cadvisor` | Container-Metriken fuer Monitoring | + +--- + +## Architektur + +```text +Browser + │ + │ http://localhost:5173 + ▼ +React/Vite Frontend (web) + │ + ├── API-Aufrufe: http://localhost:18080/api/... + │ ▼ + │ Express API (api, intern Port 8080) + │ ├── MariaDB: Benutzer, Trendtabellen, SQL-Daten + │ └── Redis: Rollen, Einheiten, Aliase, Dashboard, Auswahlen + │ + └── Collector-Aufrufe: http://localhost:18110/... + ▼ + Collector-Service + ├── sources.json unter /app/data + ├── Modbus TCP / OPC UA / BACnet / KNX + └── schreibt aktive Datenpunkte in MariaDB-Trendtabellen +``` + +Wichtig: Die API laeuft im Container intern auf Port `8080`, wird aber nach aussen auf `18080` gemappt. Das Frontend muss deshalb im Browser auf `http://localhost:18080/api` zugreifen. + +--- + +## Services und Ports + +| Service | Container | Interner Port | Host-Port | Zweck | +| --- | --- | ---: | ---: | --- | +| Web | `seltd-web` | 5173 | 5173 | React/Vite-Oberflaeche | +| API | `seltd-api` | 8080 | 18080 | REST-API | +| Collector | `seltd-collector` | 18110 | 18110 | Quellen, Datenpunkte, Scans, Polling | +| MariaDB | `seltd-mariadb` | 3306 | 3306 | Trend- und Benutzerdaten | +| Redis | `seltd-redis` | 6379 | 6379 | Metadaten und App-State | +| Dockhand | `dockhand` | 3000 | 3000 | Docker-Webverwaltung | +| cAdvisor | `cadvisor_ltd` | 8080 | 9090 | Container-Metriken | + +--- + +## Schnellstart mit Docker Compose + +### 1. Repository vorbereiten + +```powershell +cd C:\Projects\SeLTD +copy .env.example .env +``` + +Passe danach mindestens diese Werte an: + +```env +JWT_SECRET=bitte-langen-zufaelligen-wert-setzen +ADMIN_USERNAME=admin +ADMIN_PASSWORD=ChangeMe123! +MARIADB_ROOT_PASSWORD=SE3112 +CORS_ORIGIN=http://localhost:5173 +VITE_API_PORT=18080 +``` + +### 2. Stack bauen und starten + +```powershell +docker compose up -d --build +``` + +Bei groesseren Aenderungen am Code oder wenn der Browser alten Code anzeigt: + +```powershell +docker compose down +docker compose build --no-cache web api collector +docker compose up -d +``` + +### 3. Aufrufen + +| Anwendung | URL | +| --- | --- | +| Frontend | `http://localhost:5173` | +| API Healthcheck | `http://localhost:18080/api/health` | +| Collector Healthcheck | `http://localhost:18110/health` | +| Dockhand | `http://localhost:3000` | +| cAdvisor | `http://localhost:9090` | + +### 4. Standard-Login + +```text +Username: admin +Passwort: ChangeMe123! +``` + +Das Passwort sollte nach dem ersten Login geaendert werden. + +--- + +## Lokale Entwicklung + +### Frontend lokal starten + +```powershell +cd C:\Projects\SeLTD\web +npm install +npm run dev +``` + +Das Frontend laeuft dann auf: + +```text +http://localhost:5173 +``` + +### API lokal starten + +```powershell +cd C:\Projects\SeLTD\api +npm install +npm run dev +``` + +Die API liest ihre Konfiguration aus `.env`. Wenn du die API lokal ohne Docker laufen laesst, muss `DB_HOST` auf den erreichbaren MariaDB-Host zeigen. + +### Collector lokal starten + +```powershell +cd C:\Projects\SeLTD\collector +npm install +npm start +``` + +Der Collector braucht Zugriff auf MariaDB und schreibt seine lokale Konfiguration in `collector/data/sources.json`. + +--- + +## Konfiguration + +Die wichtigsten Variablen liegen in `.env`. + +| Variable | Beispiel | Beschreibung | +| --- | --- | --- | +| `JWT_SECRET` | `change-me` | Signatur fuer Login-Tokens. In Produktion unbedingt aendern. | +| `ADMIN_USERNAME` | `admin` | Initialer Admin-Username | +| `ADMIN_EMAIL` | `admin@example.com` | Initiale Admin-E-Mail | +| `ADMIN_PASSWORD` | `ChangeMe123!` | Initiales Admin-Passwort | +| `ADMIN_NAME` | `System Admin` | Anzeigename des Admins | +| `MARIADB_ROOT_PASSWORD` | `SE3112` | MariaDB-Root-Passwort | +| `DB_PASSWORD_FALLBACK` | `root` | Fallback fuer alte Entwicklungsvolumes | +| `CORS_ORIGIN` | `http://localhost:5173` | Erlaubter Browser-Ursprung fuer API-Aufrufe | +| `VITE_API_PORT` | `18080` | API-Port, den das Frontend im Browser nutzt | +| `POLL_TICK_MS` | `1000` | Collector-Tick fuer Polling-Pruefung | + +### CORS + +Wenn das Frontend lokal auf `http://localhost:5173` laeuft, muss in der API stehen: + +```env +CORS_ORIGIN=http://localhost:5173 +``` + +Bei mehreren erlaubten Origins kann eine kommaseparierte Liste genutzt werden: + +```env +CORS_ORIGIN=http://localhost:5173,https://trend.example.local +``` + +--- + +## Login, Benutzer und Rollen + +Die API legt beim Start automatisch einen Admin-Benutzer an oder aktualisiert den vorhandenen Admin. + +### Systemrollen + +| Rolle | Rechte | +| --- | --- | +| `viewer` | Dashboard anzeigen, Trendansicht anzeigen | +| `technician` | Dashboard, Trends, Aliase, Quellen, Debug | +| `admin` | Alle Rechte inkl. Benutzer- und Rollenverwaltung | + +### Rechtekatalog + +| Recht | Bedeutung | +| --- | --- | +| `view_dashboard` | Dashboard anzeigen und speichern | +| `view_trends` | Trends anzeigen, Auswahlen nutzen | +| `edit_aliases` | Aliase und Einheiten bearbeiten | +| `manage_sources` | Quellen und Datenpunkte verwalten | +| `manage_users` | Benutzer anlegen und bearbeiten | +| `manage_roles` | Rollen und Rechte verwalten | +| `view_debug` | Collector-Debug sehen | + +Benutzer, Rollen und Passwoerter werden ueber den Admin-Bereich verwaltet. + +--- + +## Frontend-Funktionen + +### Login + +- Username-basierter Login +- JWT-Token wird im Browser gespeichert +- Passwortwechsel ueber Einstellungen +- Rollenbasierte Navigation + +### Dashboard + +- Widgets aus aktueller Auswahl oder gespeicherter Auswahl +- Widget-Typen: + - Graph + - Zahl + - Zeigermanometer +- Dashboard wird pro Benutzer gespeichert + +### Trendansicht + +- Datenpunkte aus mehreren Trendtabellen kombinierbar +- Auswahl speicherbar und optional fuer andere Benutzer freigebbar +- Zeitraeume als Presets von 5 Minuten bis 30 Tage +- Eigener Start-/Endzeitraum moeglich +- Aktuelle Werte werden separat angezeigt +- Fokusmodus fuer den Graphen + +### Trend-Export + +Der Trendgraph kann exportiert werden als: + +- CSV +- XLSX +- PNG +- JPG +- SVG +- PDF + +### Admin: Aliase + +- Anzeigename und Beschreibung pro Trendtabelle +- Alias, Einheit, Faktor, Typ, Min/Max und Farbe pro Wert +- CSV-Import und CSV-Export +- Encoding-/Grad-Celsius-Bereinigung + +### Admin: Einheiten + +- Systemeinheiten wie `°C`, `%`, `K`, `V`, `A`, `Pa` +- Eigene Einheiten wie `bar`, `m³/h`, `Hz` usw. +- Einheitenauswahl bei Aliasen und Datenpunkten + +### Admin: Quellen + +- Protokollquellen anlegen und bearbeiten +- Unterstuetzte Protokolle in der UI: + - Modbus TCP + - OPC UA + - BACnet IP + - KNX IP +- Poll-Intervall und Schreibmodus konfigurierbar +- Scan-Ergebnisse und importierte Punkte koennen ins Logging uebernommen werden +- Manuelle Datenpunkte koennen erstellt und bearbeitet werden + +--- + +## API-Funktionen + +Die API laeuft unter: + +```text +http://localhost:18080/api +``` + +### Authentifizierung + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `POST` | `/api/auth/login` | Login mit Username/Passwort | +| `GET` | `/api/auth/me` | Aktuellen Benutzer lesen | +| `POST` | `/api/auth/change-password` | Passwort aendern | + +### Trends und ISPs + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `GET` | `/api/isps` | Verfuegbare Trendtabellen lesen | +| `GET` | `/api/isps/:isp/aliases` | Alias-Metadaten lesen | +| `PUT` | `/api/isps/:isp/aliases` | Alias-Metadaten speichern | +| `POST` | `/api/trend-tables` | Neue Trendtabelle anlegen | +| `POST` | `/api/trends/query` | Trenddaten abfragen | +| `POST` | `/api/points/latest` | Letzte Werte lesen | + +### Einheiten + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `GET` | `/api/units` | Einheiten lesen | +| `POST` | `/api/units` | Einheit anlegen | +| `PATCH` | `/api/units/:symbol` | Einheit bearbeiten | + +### Benutzer und Rollen + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `GET` | `/api/users` | Benutzerliste | +| `POST` | `/api/users` | Benutzer anlegen | +| `PATCH` | `/api/users/:id` | Benutzer bearbeiten | +| `GET` | `/api/roles` | Rollenliste inkl. Rechtekatalog | +| `POST` | `/api/roles` | Rolle anlegen | +| `PATCH` | `/api/roles/:name` | Rolle bearbeiten | + +### Auswahlen und Dashboard + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `GET` | `/api/selections` | Gespeicherte Auswahlen lesen | +| `POST` | `/api/selections` | Auswahl speichern | +| `PUT` | `/api/selections/:selectionId` | Auswahl aktualisieren | +| `DELETE` | `/api/selections/:selectionId` | Auswahl loeschen | +| `GET` | `/api/dashboard` | Dashboard laden | +| `PUT` | `/api/dashboard` | Dashboard speichern | +| `POST` | `/api/dashboard/data` | Widget-Daten laden | +| `GET` | `/api/preferences` | Benutzerpraeferenzen laden | +| `PUT` | `/api/preferences` | Benutzerpraeferenzen speichern | + +--- + +## Collector-Funktionen + +Der Collector laeuft unter: + +```text +http://localhost:18110 +``` + +Er verwaltet Quellen und Datenpunkte in einer JSON-Datei (`/app/data/sources.json`) und schreibt aktive Punkte zyklisch in MariaDB. + +### Collector-Endpunkte + +| Methode | Pfad | Beschreibung | +| --- | --- | --- | +| `GET` | `/health` | Healthcheck | +| `GET` | `/capabilities` | Unterstuetzte Protokolle/Funktionen | +| `GET` | `/sources` | Quellen lesen | +| `POST` | `/sources` | Quelle anlegen | +| `PUT` | `/sources/:id` | Quelle bearbeiten | +| `DELETE` | `/sources/:id` | Quelle loeschen | +| `GET` | `/datapoints` | Datenpunkte lesen | +| `POST` | `/datapoints/manual` | Datenpunkt manuell anlegen | +| `PATCH` | `/datapoints/:id` | Datenpunkt bearbeiten, aktivieren, deaktivieren | +| `POST` | `/datapoints/:id/read` | Live-Wert lesen | +| `POST` | `/imports/modbus-csv` | Modbus-CSV importieren | +| `POST` | `/imports/knx-xml` | KNX-XML importieren | +| `POST` | `/scan/opcua` | OPC-UA-Scan starten | +| `POST` | `/scan/bacnet` | BACnet-Scan starten | +| `GET` | `/debug` | Debugdaten lesen | + +### Schreibmodus + +| Modus | Bedeutung | +| --- | --- | +| `interval` | Jeder Poll wird geschrieben | +| `cov` | Nur bei Wertaenderung wird geschrieben | + +### Aktive vs. inaktive Datenpunkte + +- Inaktive Datenpunkte sind gefunden/importiert, werden aber nicht geloggt. +- Aktive Datenpunkte werden vom Collector gepollt und in die zugehoerige Trendtabelle geschrieben. +- In der Weboberflaeche entspricht das der Trennung zwischen „Gefunden / importiert“ und „Wird geloggt“. + +--- + +## Datenbank und Speicher + +### MariaDB + +MariaDB speichert: + +- `app_users` +- bestehende `isp...` Tabellen +- neue `trend_...` Tabellen +- Registry-Tabellen fuer Quellen und Datenpunkte, falls vom Collector/API genutzt + +Die API kann bestehende breite Tabellen mit `Wert1`, `Wert2`, ... lesen. Neue lokale Trendtabellen koennen bis zu 2000 Werte unterstuetzen. + +### Redis + +Redis speichert: + +- Rollen +- Einheiten +- Aliase und Tabellen-Metadaten +- Auswahlen +- Dashboard-Konfiguration +- Benutzerpraeferenzen + +Redis sollte deshalb ebenfalls gebackupt werden, nicht nur MariaDB. + +--- + +## WAGO- oder SD-Karten-Betrieb + +Fuer Systeme, bei denen persistente Daten auf SD-Karte liegen sollen, gibt es ein Override: + +```powershell +docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build +``` + +Dabei werden grosse Daten nach `/media/sd/seltd/...` gelegt: + +```text +/media/sd/seltd/mariadb +/media/sd/seltd/redis +/media/sd/seltd/collector +/media/sd/seltd/dockhand +``` + +Vorher sicherstellen: + +```bash +mkdir -p /media/sd/seltd/mariadb /media/sd/seltd/redis /media/sd/seltd/collector /media/sd/seltd/dockhand +``` + +--- + +## Backup und Restore + +### MariaDB Backup + +```powershell +docker exec seltd-mariadb mariadb-dump -uroot -pSE3112 --all-databases > seltd-mariadb-backup.sql +``` + +Restore: + +```powershell +type .\seltd-mariadb-backup.sql | docker exec -i seltd-mariadb mariadb -uroot -pSE3112 +``` + +### Redis Backup + +Redis nutzt ein Docker-Volume. Fuer ein einfaches Dateibackup: + +```powershell +docker run --rm -v seltd_redis_data:/data -v ${PWD}:/backup alpine tar czf /backup/seltd-redis-backup.tar.gz -C /data . +``` + +### Collector Backup + +```powershell +docker run --rm -v seltd_collector_data:/data -v ${PWD}:/backup alpine tar czf /backup/seltd-collector-backup.tar.gz -C /data . +``` + +--- + +## Deployment-Hinweise + +### Nach Code-Aenderungen + +Wenn Frontend oder API geaendert wurden: + +```powershell +docker compose build --no-cache web api collector +docker compose up -d +``` + +Wenn nur `.env` geaendert wurde: + +```powershell +docker compose up -d +``` + +### Browsercache + +Bei alten JavaScript-Fehlern nach einem Fix: + +```text +Strg + F5 +``` + +oder Vite/Docker neu bauen. + +### Nginx Proxy Manager + +Wenn das Frontend hinter Nginx laeuft, muessen die API- und Collector-URLs sauber erreichbar sein. Entweder: + +- Frontend, API und Collector unter eigenen Subdomains veroeffentlichen, oder +- Nginx so konfigurieren, dass `/api` zur API und `/collector` zum Collector weitergeleitet wird. + +In diesem Fall kann `VITE_API_URL` statt `VITE_API_PORT` gesetzt werden: + +```env +VITE_API_URL=https://trend-api.example.local/api +VITE_COLLECTOR_URL=https://trend-collector.example.local +``` + +--- + +## Troubleshooting + +### Fehler: `NetworkError when attempting to fetch resource` + +Ursachen: + +- API-Container laeuft nicht +- falscher Port im Frontend +- CORS nicht korrekt +- Browser ruft alte Version auf + +Pruefen: + +```powershell +docker ps +curl http://localhost:18080/api/health +``` + +Wenn Frontend lokal auf `localhost:5173` laeuft: + +```env +CORS_ORIGIN=http://localhost:5173 +``` + +Danach API neu starten: + +```powershell +docker compose restart api +``` + +### Fehler: Browser zeigt alte React-Fehler nach Fix + +Sehr wahrscheinlich wurde das Docker-Image nicht neu gebaut. + +```powershell +docker compose down +docker compose build --no-cache web api collector +docker compose up -d +``` + +Danach im Browser `Strg + F5`. + +### Fehler: `ReferenceError: editingCollectorPointId is not defined` + +In `web/src/App.jsx` muss folgender State vorhanden sein: + +```jsx +const [editingCollectorPointId, setEditingCollectorPointId] = useState(""); +``` + +Wenn die Zeile vorhanden ist, der Fehler aber weiterhin erscheint, laeuft noch altes Build-/Container-Material. + +### Fehler: `SyntaxError: missing ) after argument list` in `isp.js` + +Bei SQL-Template-Strings muessen Backticks im String escaped werden: + +```js +missing.push(`ADD COLUMN \`${columnName}\` TEXT NULL`); +``` + +### Fehler: `Rollup failed to resolve import "jspdf"` + +Abhaengigkeiten im Frontend installieren: + +```powershell +cd web +npm install jspdf xlsx recharts +``` + +Bei Docker danach neu bauen. + +### Source-Map-Warnungen im Browser + +Warnungen wie diese sind in der Regel nicht kritisch: + +```text +Source map error: JSON.parse: unexpected character... +``` + +Sie kommen oft aus Devtools/React-Devtools oder externen Source-Maps und koennen ignoriert werden, solange keine roten Laufzeitfehler auftreten. + +### MariaDB Login oder DB-Verbindung fehlgeschlagen + +Logs ansehen: + +```powershell +docker logs seltd-api --tail 100 +docker logs seltd-mariadb --tail 100 +``` + +Pruefen, ob MariaDB healthy ist: + +```powershell +docker ps +``` + +### Redis-Probleme + +```powershell +docker exec seltd-redis redis-cli ping +``` + +Erwartet: + +```text +PONG +``` + +--- + +## Nuetzliche Befehle + +### Alle Container anzeigen + +```powershell +docker compose ps +``` + +### Logs live verfolgen + +```powershell +docker compose logs -f +``` + +Nur API: + +```powershell +docker logs seltd-api -f +``` + +Nur Frontend: + +```powershell +docker logs seltd-web -f +``` + +Nur Collector: + +```powershell +docker logs seltd-collector -f +``` + +### Komplett neu starten + +```powershell +docker compose restart +``` + +### Komplett neu bauen + +```powershell +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +### Volumes loeschen + +Achtung: Dadurch gehen Daten verloren. + +```powershell +docker compose down -v +``` + +--- + +## Sicherheits-Checkliste + +Vor produktiver Nutzung: + +- [ ] `JWT_SECRET` auf langen zufaelligen Wert setzen +- [ ] Admin-Passwort aendern +- [ ] MariaDB-Root-Passwort aendern +- [ ] `CORS_ORIGIN` nicht auf `*` lassen, sondern feste Domain setzen +- [ ] MariaDB-Port `3306` nicht ungeschuetzt ins Internet veroeffentlichen +- [ ] Redis-Port `6379` nicht ungeschuetzt ins Internet veroeffentlichen +- [ ] Dockhand absichern oder nur intern bereitstellen +- [ ] Backups fuer MariaDB, Redis und Collector-Daten einrichten +- [ ] Reverse Proxy mit HTTPS verwenden + +--- + +## Bekannte Hinweise + +- Leere Trendtabellen erscheinen nicht in der Trendauswahl, aber im Admin-Bereich. +- Bestehende breite `isp...` Tabellen bleiben kompatibel. +- Neue lokale Tabellen koennen als `trend_...` Tabellen angelegt werden. +- Der Collector speichert seine Quellen/Datenpunkte aktuell dateibasiert in `sources.json`. +- Fuer vollstaendige Protokollintegration muessen je nach Feldbus noch Details wie Adressierung, Datentyp, Byteorder und Authentifizierung sauber projektspezifisch gepflegt werden. + +--- + +## Kurzfassung fuer Betrieb + +```powershell +cd C:\Projects\SeLTD +copy .env.example .env +notepad .env +docker compose up -d --build +``` + +Dann aufrufen: + +```text +http://localhost:5173 +``` + +Login: + +```text +admin / ChangeMe123! +``` + +Bei Code-Aenderungen: + +```powershell +docker compose build --no-cache web api collector +docker compose up -d +``` diff --git a/collector/src/index.js b/collector/src/index.js index 0ea48b6..b01965e 100644 --- a/collector/src/index.js +++ b/collector/src/index.js @@ -242,8 +242,8 @@ function parseModbusCsv(payload) { protocol: "modbus-tcp", address, pointIndex, - name: get("name", `Register ${index + 1}`), - alias: get("alias", get("name", `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}`, unit: normalizeUnit(get("unit")), dataType: get("datatype", "holding-register"), kind: normalizePointKind(get("kind", get("type", "analog"))), @@ -282,8 +282,8 @@ function parseKnxXml(payload) { }; const address = getAttr("Address") || getAttr("address"); - const name = getAttr("Name") || getAttr("name") || `KNX ${index + 1}`; - const dpt = getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || ""; + const name = normalizeText(getAttr("Name") || getAttr("name") || `KNX ${index + 1}`) || `KNX ${index + 1}`; + const dpt = normalizeText(getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || ""); if (address) { datapoints.push({ @@ -462,29 +462,71 @@ function tryTcpConnect(host, portNumber, timeout = 1200) { } function inferModbusAddress(datapoint) { - const rawAddress = Number(String(datapoint.address || datapoint.register || "").replace(/[^0-9]/g, "")); + const addressText = String(datapoint.address || datapoint.register || "").trim(); + const rawAddress = Number(addressText.replace(/[^0-9]/g, "")); const dataType = String(datapoint.dataType || "").toLowerCase(); const pointIndex = normalizePointIndex(datapoint.pointIndex, 1); const raw = Number.isFinite(rawAddress) && rawAddress > 0 ? rawAddress : pointIndex; let functionCode = dataType.includes("coil") ? 1 : dataType.includes("discrete") ? 2 : dataType.includes("input") ? 4 : 3; let address = Math.max(0, raw - 1); - if (raw >= 40001) { - functionCode = 3; + if (functionCode === 3 && raw >= 40001 && raw <= 49999) { address = raw - 40001; - } else if (raw >= 30001) { - functionCode = 4; + } else if (functionCode === 4 && raw >= 30001 && raw <= 39999) { address = raw - 30001; - } else if (raw >= 10001) { - functionCode = 2; + } else if (functionCode === 2 && raw >= 10001 && raw <= 19999) { address = raw - 10001; - } else if (raw <= 9999 && functionCode > 2) { + } else if (!addressText.match(/^[134]\d{4,}$/) && raw <= 9999 && functionCode > 2) { address = raw - 1; } return { functionCode, address }; } +function swapModbusWords(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4) { + return buffer; + } + return Buffer.from([buffer[2], buffer[3], buffer[0], buffer[1]]); +} + +function resolveModbusReadDetails(datapoint) { + const dataType = String(datapoint.dataType || "").toLowerCase(); + const scale = Number(datapoint.scale || 1) || 1; + const quantity = /(?:float32|real|int32|uint32|dint|udint)/.test(dataType) ? 2 : 1; + const wantsSwap = /(?:swap|swapped|cdab|badc)/.test(dataType); + + return { + quantity, + decode(responseBuffer, functionCode) { + if (functionCode === 1 || functionCode === 2) { + return (responseBuffer.readUInt8(0) & 0x01) ? 1 : 0; + } + + if (responseBuffer.length < quantity * 2) { + throw new Error("Modbus Antwort enth\u00e4lt zu wenige Register."); + } + + const baseBuffer = responseBuffer.subarray(0, quantity * 2); + const buffer = wantsSwap && quantity === 2 ? swapModbusWords(baseBuffer) : baseBuffer; + + if (/(?:float32|real)/.test(dataType)) { + return buffer.readFloatBE(0) * scale; + } + if (/(?:uint32|udint)/.test(dataType)) { + return buffer.readUInt32BE(0) * scale; + } + if (/(?:int32|dint)/.test(dataType)) { + return buffer.readInt32BE(0) * scale; + } + if (/(?:int16|signed)/.test(dataType)) { + return buffer.readInt16BE(0) * scale; + } + return buffer.readUInt16BE(0) * scale; + }, + }; +} + function readModbusTcp(source, datapoint) { return new Promise((resolve, reject) => { const host = String(source.host || "").trim(); @@ -495,6 +537,7 @@ function readModbusTcp(source, datapoint) { } const { functionCode, address } = inferModbusAddress(datapoint); + const { quantity, decode } = resolveModbusReadDetails(datapoint); const unitId = Number(source.deviceId || 1) & 0xff; const currentTransaction = transactionId; transactionId = transactionId >= 0xffff ? 1 : transactionId + 1; @@ -506,7 +549,7 @@ function readModbusTcp(source, datapoint) { request.writeUInt8(unitId, 6); request.writeUInt8(functionCode, 7); request.writeUInt16BE(address, 8); - request.writeUInt16BE(1, 10); + request.writeUInt16BE(quantity, 10); const socket = new net.Socket(); const chunks = []; @@ -528,6 +571,11 @@ function readModbusTcp(source, datapoint) { socket.setTimeout(1800); socket.once("timeout", () => finish(new Error("Modbus Timeout."))); socket.once("error", (error) => finish(error)); + socket.once("close", (hadError) => { + if (!settled) { + finish(new Error(hadError ? "Modbus Verbindung wurde fehlerhaft geschlossen." : "Modbus Verbindung wurde ohne Antwort geschlossen.")); + } + }); socket.on("data", (chunk) => { chunks.push(chunk); const response = Buffer.concat(chunks); @@ -551,29 +599,23 @@ function readModbusTcp(source, datapoint) { return; } - const scale = Number(datapoint.scale || 1) || 1; - if (functionCode === 1 || functionCode === 2) { - const digital = (response.readUInt8(9) & 0x01) ? 1 : 0; - finish(null, digital); - return; - } - - if (response.length < 11) { + const byteCount = Number(response.readUInt8(8) || 0); + const startOffset = 9; + if (response.length < startOffset + byteCount) { finish(new Error("Modbus Antwort zu kurz.")); return; } - const dataType = String(datapoint.dataType || "").toLowerCase(); - const raw = dataType.includes("int16") || dataType.includes("signed") - ? response.readInt16BE(9) - : response.readUInt16BE(9); - finish(null, raw * scale); + try { + finish(null, decode(response.subarray(startOffset, startOffset + byteCount), functionCode)); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } }); socket.connect(portNumber, host, () => socket.write(request)); }); } - async function loadOptionalPackage(packageName) { try { const module = await import(packageName); @@ -870,11 +912,87 @@ async function readBacnetValue(source, datapoint) { }); } +function normalizeKnxDatapointType(dataType) { + const raw = String(dataType || "").trim(); + if (!raw) { + return ""; + } + const match = raw.match(/(?:dpt\s*)?(\d+)(?:[.-](\d+))?/i); + if (!match) { + return raw.toLowerCase(); + } + return match[2] ? `${match[1]}.${match[2]}` : match[1]; +} + +function decodeKnxPayload(value, dataType) { + if (value === undefined || value === null) { + return null; + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + if (typeof value === "number") { + return value; + } + + if (typeof value === "object" && !Buffer.isBuffer(value)) { + if (Array.isArray(value.data)) { + return decodeKnxPayload(Buffer.from(value.data), dataType); + } + if (Array.isArray(value.buffer)) { + return decodeKnxPayload(Buffer.from(value.buffer), dataType); + } + if ("value" in value && value.value !== value) { + return decodeKnxPayload(value.value, dataType); + } + } + + const normalizedType = normalizeKnxDatapointType(dataType); + const mainType = normalizedType.split(/[.-]/)[0]; + const buffer = Buffer.isBuffer(value) + ? value + : value instanceof Uint8Array + ? Buffer.from(value) + : Array.isArray(value) + ? Buffer.from(value) + : Buffer.from(String(value), "binary"); + + if (mainType === "1") { + return (buffer[buffer.length - 1] & 0x01) ? 1 : 0; + } + if (mainType === "5") { + return buffer[buffer.length - 1] || 0; + } + if (mainType === "9" && buffer.length >= 2) { + const hi = buffer[0]; + const lo = buffer[1]; + const sign = (hi & 0x80) ? -1 : 1; + const exponent = (hi & 0x78) >> 3; + let mantissa = ((hi & 0x07) << 8) | lo; + if (sign === -1) { + mantissa = -(~(mantissa - 1) & 0x07ff); + } + return 0.01 * mantissa * Math.pow(2, exponent); + } + if (mainType === "13" && buffer.length >= 4) { + return buffer.readInt32BE(0); + } + if (mainType === "14" && buffer.length >= 4) { + return buffer.readFloatBE(0); + } + + const numeric = Number(String(value).replace(",", ".")); + if (Number.isFinite(numeric)) { + return numeric; + } + return repairText(String(value)); +} + async function readKnxValue(source, datapoint) { const knx = await loadOptionalPackage("knx"); const groupAddress = String(datapoint.address || "").trim(); if (!groupAddress) { - throw new Error(`KNX Gruppenadresse fehlt für ${datapoint.alias || datapoint.name}.`); + throw new Error(`KNX Gruppenadresse fehlt f\u00fcr ${datapoint.alias || datapoint.name}.`); } return await new Promise((resolve, reject) => { @@ -890,8 +1008,13 @@ async function readKnxValue(source, datapoint) { if (error) { reject(error); } else { - const numeric = Number(value); - resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value || "")); + const decoded = decodeKnxPayload(value, datapoint.dataType); + if (typeof decoded === "number" && Number.isFinite(decoded)) { + const scale = Number(datapoint.scale || 1) || 1; + resolve(decoded * scale); + } else { + resolve(decoded === null ? "" : decoded); + } } }; @@ -917,7 +1040,6 @@ async function readKnxValue(source, datapoint) { }); }); } - function matchesLogCondition(condition, value) { const text = String(condition || "").trim(); if (!text) { @@ -1399,7 +1521,7 @@ async function pollSource(source, datapoints) { updateSourceState(source.id, { lastPollAt: new Date().toISOString(), lastPollStatus: "unsupported", - lastPollError: `${source.protocol} wird vom Collector nicht unterst?tzt.`, + lastPollError: `${source.protocol} wird vom Collector nicht unterst\u00fctzt.`, }); } catch (error) { updateSourceState(source.id, { @@ -1577,10 +1699,14 @@ const server = createServer(async (req, res) => { store.datapoints[index] = { ...store.datapoints[index], enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled, - writeMode: body.writeMode ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode, + writeMode: typeof body.writeMode === "string" ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode, condition: typeof body.condition === "string" ? body.condition.trim() : store.datapoints[index].condition, pointIndex: body.pointIndex ? normalizePointIndex(body.pointIndex, store.datapoints[index].pointIndex) : store.datapoints[index].pointIndex, alias: typeof body.alias === "string" && body.alias.trim() ? body.alias.trim() : store.datapoints[index].alias, + name: typeof body.name === "string" && body.name.trim() ? body.name.trim() : store.datapoints[index].name, + 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, kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind, scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale, @@ -1740,3 +1866,6 @@ server.listen(port, () => { }); + + + diff --git a/docker-compose.yml b/docker-compose.yml index 3dd6e1d..1521293 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,7 +99,7 @@ services: dockhand: image: fnsys/dockhand:latest - container_name: dockhand + container_name: seltd-dockhand restart: unless-stopped ports: - "3000:3000" @@ -108,7 +108,7 @@ services: - dockhand_data:/app/data cadvisor: image: ghcr.io/google/cadvisor:v0.60.2 - container_name: cadvisor + container_name: seltd-cadvisor restart: unless-stopped privileged: true diff --git a/web/src/App.jsx b/web/src/App.jsx index 94e9abb..bf4832e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -20,8 +20,43 @@ const adminSections = [ { id: "debug", label: "Debug" }, ]; +const TREND_PRESET_OPTIONS = [ + { value: "0.0034722222", label: "5 min" }, + { value: "0.0069444444", label: "10 min" }, + { value: "0.0208333333", label: "30 min" }, + { value: "0.0416666667", label: "1 h" }, + { value: "0.0833333333", label: "2 h" }, + { value: "0.25", label: "6 h" }, + { value: "0.5", label: "12 h" }, + { value: "1", label: "24 h" }, + { value: "2", label: "2 Tage" }, + { value: "3", label: "3 Tage" }, + { value: "7", label: "7 Tage" }, + { value: "14", label: "14 Tage" }, + { value: "30", label: "30 Tage" }, +]; + +const SCALE_PRESET_OPTIONS = ["1", "0.1", "0.01", "0.001", "10", "100", "1000"]; + +const MODBUS_DATA_TYPE_OPTIONS = [ + { value: "coil", label: "Coil" }, + { value: "discrete-input", label: "Discrete Input" }, + { value: "input-register", label: "Input Register (uint16)" }, + { value: "input-register-int16", label: "Input Register (int16)" }, + { value: "input-register-int32", label: "Input Register (int32)" }, + { value: "input-register-uint32", label: "Input Register (uint32)" }, + { value: "input-register-float32", label: "Input Register (float32)" }, + { value: "input-register-float32-swap", label: "Input Register (float32, Word-Swap)" }, + { value: "holding-register", label: "Holding Register (uint16)" }, + { value: "holding-register-int16", label: "Holding Register (int16)" }, + { value: "holding-register-int32", label: "Holding Register (int32)" }, + { value: "holding-register-uint32", label: "Holding Register (uint32)" }, + { value: "holding-register-float32", label: "Holding Register (float32)" }, + { value: "holding-register-float32-swap", label: "Holding Register (float32, Word-Swap)" }, +]; + const emptyTrendRange = { - presetDays: 1, + presetDays: "1", from: "", to: "", maxRows: 4000, @@ -129,6 +164,13 @@ function getDefaultPointDataType(protocol) { return "holding-register"; } +function getPointDataTypeOptions(protocol) { + if (protocol === "modbus-tcp") { + return MODBUS_DATA_TYPE_OPTIONS; + } + return []; +} + function getOpcuaScanDatapointId(sourceId, node) { return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : ""; } @@ -427,6 +469,33 @@ function UnitField({ units, value, onChange }) { ); } +function ScaleField({ value, onChange }) { + const normalizedValue = String(value ?? "1"); + const selectedValue = SCALE_PRESET_OPTIONS.includes(normalizedValue) ? normalizedValue : "__custom__"; + + return ( +
+ + {selectedValue === "__custom__" ? ( + onChange(event.target.value)} /> + ) : null} +
+ ); +} + function ColorField({ value, onChange }) { const normalizedValue = hslToHexString(value || ""); const pickerValue = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(normalizedValue || "") ? normalizedValue : "#d62839"; @@ -475,8 +544,8 @@ function IconButton({ title, onClick, children, danger = false }) { } function LoginScreen({ onLogin, error, busy }) { - const [username, setUsername] = useState("admin"); - const [password, setPassword] = useState("ChangeMe123!"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const handleSubmit = async (event) => { event.preventDefault(); @@ -637,6 +706,7 @@ export default function App() { const [units, setUnits] = useState(DEFAULT_UNIT_OPTIONS); const [unitDraft, setUnitDraft] = useState({ symbol: "", label: "" }); const [editingUnitSymbol, setEditingUnitSymbol] = useState(""); + const [editingCollectorPointId, setEditingCollectorPointId] = useState(""); const [selectedSourceId, setSelectedSourceId] = useState(""); const [isCreatingSource, setIsCreatingSource] = useState(false); const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft); @@ -681,6 +751,40 @@ export default function App() { const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]); const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]); const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]); + const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol; + + const upsertIspEntry = (entry) => { + if (!entry?.tableName) { + return; + } + + setIsps((current) => { + const map = new Map(current.map((item) => [item.tableName, item])); + const previous = map.get(entry.tableName) || {}; + map.set(entry.tableName, { + ...previous, + ...entry, + tableName: entry.tableName, + displayName: entry.displayName || previous.displayName || entry.tableName, + description: entry.description ?? previous.description ?? "", + storage: entry.storage || previous.storage || (String(entry.tableName).startsWith("trend_") ? "narrow" : "wide"), + pointCount: entry.pointCount ?? previous.pointCount ?? 0, + rowCount: entry.rowCount ?? previous.rowCount ?? 0, + latestTimestamp: entry.latestTimestamp ?? previous.latestTimestamp ?? null, + }); + return Array.from(map.values()).sort((left, right) => left.tableName.localeCompare(right.tableName)); + }); + }; + + const refreshIspCatalog = async () => { + if (!token) { + return []; + } + const result = await api.getIsps(token); + const nextIsps = result.isps || []; + setIsps(nextIsps); + return nextIsps; + }; const filteredOpcuaNodes = useMemo( () => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [ node.displayName, @@ -767,7 +871,7 @@ export default function App() { setThemeMode(pref.theme || "system"); setCsvDelimiter(pref.csvDelimiter || ";"); setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true); - setIsps(ispResult.isps); + setIsps(ispResult.isps || []); setSelections(selectionResult.selections); setDashboard(dashboardResult); setActiveView("dashboard"); @@ -940,6 +1044,33 @@ export default function App() { .catch((error) => setBootError(error.message)); }, [canEditAliases, canManageSources, token]); + useEffect(() => { + if (!token) { + return undefined; + } + + let ignore = false; + const refreshIsps = () => { + refreshIspCatalog().catch(() => undefined); + }; + + const handleVisibilityChange = () => { + if (!document.hidden) { + refreshIsps(); + } + }; + + const timer = window.setInterval(refreshIsps, 5000); + window.addEventListener("focus", refreshIsps); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + ignore = true; + window.clearInterval(timer); + window.removeEventListener("focus", refreshIsps); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [token]); + useEffect(() => { if (!token || !(canEditAliases || canManageSources || canViewDebug)) { setCollectorCapabilities(null); @@ -1382,8 +1513,7 @@ export default function App() { try { const sql = await file.text(); await api.importSql(token, { filename: file.name, sql }); - const refreshed = await api.getIsps(token); - setIsps(refreshed.isps); + await refreshIspCatalog(); setSuccessMessage("SQL-Datei importiert."); } catch (error) { setBootError(error.message || "SQL-Datei konnte nicht importiert werden."); @@ -1401,8 +1531,15 @@ export default function App() { resetFlash(); try { const result = await api.createTrendTable(token, newTableDraft); - const refreshed = await api.getIsps(token); - setIsps(refreshed.isps); + upsertIspEntry({ + tableName: result.tableName, + displayName: result.metadata?.displayName || newTableDraft.displayName || result.tableName, + description: result.metadata?.description || "", + storage: "narrow", + pointCount: 0, + rowCount: 0, + }); + await refreshIspCatalog().catch(() => undefined); setAdminIsp(result.tableName); setNewTableDraft({ tableName: "", displayName: "" }); setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`); @@ -1475,10 +1612,17 @@ export default function App() { displayName: payload.displayName, }); + upsertIspEntry({ + tableName: payload.tableName, + displayName: payload.displayName, + description: "", + storage: "narrow", + pointCount: 0, + rowCount: 0, + }); await refreshCollectorData(); await refreshCollectorDebug().catch(() => undefined); - const refreshed = await api.getIsps(token); - setIsps(refreshed.isps); + await refreshIspCatalog().catch(() => undefined); setIsCreatingSource(false); setSelectedSourceId(saved.id); setSourceModalOpen(false); @@ -1520,6 +1664,26 @@ export default function App() { downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;"); }; + const resetManualDatapointEditor = (protocol = sourceDraft.protocol || defaultSourceDraft.protocol) => { + setEditingCollectorPointId(""); + setManualDatapointDraft(emptyManualDatapointDraft(protocol)); + }; + + const editCollectorPoint = (datapoint) => { + setEditingCollectorPointId(datapoint.id); + setManualDatapointDraft({ + alias: datapoint.alias || datapoint.name || "", + address: datapoint.address || datapoint.nodeId || "", + pointIndex: String(datapoint.pointIndex || ""), + unit: normalizeDisplayUnit(datapoint.unit || "°C"), + dataType: datapoint.dataType || getDefaultPointDataType(sourceDraft.protocol), + kind: datapoint.kind || "analog", + scale: String(datapoint.scale ?? "1"), + writeMode: datapoint.writeMode === "cov" ? "cov" : datapoint.writeMode || "", + condition: datapoint.condition || "", + }); + }; + const saveManualCollectorDatapoint = async () => { const activeSourceId = selectedSourceId || sourceDraft.id; if (!activeSourceId) { @@ -1529,7 +1693,7 @@ export default function App() { resetFlash(); try { - await api.createCollectorDatapoint({ + const payload = { sourceId: activeSourceId, protocol: sourceDraft.protocol, alias: manualDatapointDraft.alias, @@ -1543,11 +1707,17 @@ export default function App() { scale: manualDatapointDraft.scale, writeMode: manualDatapointDraft.writeMode, condition: manualDatapointDraft.condition, - }); + }; + + if (editingCollectorPointId) { + await api.updateCollectorDatapoint(editingCollectorPointId, payload); + } else { + await api.createCollectorDatapoint(payload); + } await refreshCollectorData(); await refreshCollectorDebug().catch(() => undefined); - setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol)); - setSuccessMessage("Datenpunkt angelegt."); + resetManualDatapointEditor(sourceDraft.protocol); + setSuccessMessage(editingCollectorPointId ? "Datenpunkt aktualisiert." : "Datenpunkt angelegt."); } catch (error) { setBootError(error.message); } @@ -1864,8 +2034,10 @@ export default function App() { {widgetDraft.source === "saved" ? (