beta. Modbus fixed and tested

This commit is contained in:
jhartworks
2026-06-30 09:42:55 +02:00
parent 2ce3be068e
commit e34626f15b
8 changed files with 1355 additions and 246 deletions
+1
View File
@@ -4,3 +4,4 @@ dist
npm-debug.log*
.scratch/runtime
data/
web/src
+791 -38
View File
@@ -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
```
+162 -33
View File
@@ -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, () => {
});
+2 -2
View File
@@ -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
+349 -157
View File
@@ -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 (
<div className="unit-field">
<select
value={selectedValue}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === "__custom__") {
onChange(SCALE_PRESET_OPTIONS.includes(normalizedValue) ? "" : normalizedValue);
return;
}
onChange(nextValue);
}}
>
{SCALE_PRESET_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
<option value="__custom__">Benutzerdefiniert</option>
</select>
{selectedValue === "__custom__" ? (
<input value={value || ""} placeholder="Eigener Faktor" onChange={(event) => onChange(event.target.value)} />
) : null}
</div>
);
}
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() {
</select>
</label>
<label>
<span>Tage</span>
<input type="number" min="1" max="365" value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))} />
<span>Zeitraum</span>
<select value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
{widgetDraft.source === "saved" ? (
<label className="span-two">
@@ -2022,8 +2194,10 @@ export default function App() {
<div className="form-grid compact-forms">
<label>
<span>Tage</span>
<input type="number" min="1" max="365" value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))} />
<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>
@@ -2205,7 +2379,7 @@ export default function App() {
<div className="alias-index">Wert {point.pointIndex}</div>
<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 })} />
<input type="number" step="0.01" value={point.factor} onChange={(event) => updateAliasPoint(String(point.pointIndex), { factor: event.target.value })} />
<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 })}>
<option value="analog">analog</option>
<option value="digital">digital</option>
@@ -2294,45 +2468,149 @@ export default function App() {
</div>
</div>
<div className="protocol-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Protokollquellen</h3>
<p>{collectorCapabilities?.protocols?.join(" · ") || "Modbus TCP · OPC UA · BACnet IP · KNX IP"}</p>
<div className="source-admin-top">
<div className="protocol-panel source-overview-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Protokollquellen</h3>
<p>{collectorCapabilities?.protocols?.join(" | ") || "Modbus TCP | OPC UA | BACnet IP | KNX IP"}</p>
</div>
</div>
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
{collectorSources.map((source) => {
const isActive = source.id === selectedSourceId;
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
return (
<button
key={source.id}
type="button"
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
onClick={() => {
setIsCreatingSource(false);
setSelectedSourceId(source.id);
}}
>
<div className="source-tile-copy">
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
<span>{`${source.protocol} | ${normalizeDisplayText(source.tableName)}`}</span>
<small>{`${String(source.pollIntervalSeconds || 60)}s | ${pointCount} DP`}</small>
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
</div>
<div className="source-tile-actions">
<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>
</div>
</button>
);
})}
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. ?ber <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
</div>
</div>
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
{collectorSources.map((source) => {
const isActive = source.id === selectedSourceId;
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
return (
<button
key={source.id}
type="button"
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
onClick={() => {
setIsCreatingSource(false);
setSelectedSourceId(source.id);
}}
>
<div className="source-tile-copy">
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
<span>{`${source.protocol} · ${normalizeDisplayText(source.tableName)}`}</span>
<small>{`${String(source.pollIntervalSeconds || 60)}s · ${pointCount} DP`}</small>
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
</div>
<div className="source-tile-actions">
<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>
</div>
</button>
);
})}
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. Über <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
<div className="protocol-panel source-settings-panel">
<div className="panel-header compact-header nested-gap">
<div>
<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>
</div>
<div className="protocol-grid source-tools-grid">
<div className="protocol-block">
<strong>Import / Vorlagen</strong>
<div className="button-row wrap-tight">
<label className="ghost-button compact-button file-button">
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
</label>
</div>
<small className="muted">SQL-Import bleibt oben. Bei Modbus kommt der Function Code jetzt aus dem Dropdown Datenart am Datenpunkt.</small>
</div>
<div className="protocol-block">
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
<div className="button-row wrap-tight">
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
</div>
{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 === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div>
</div>
{canManageSources ? (
<div className="protocol-block manual-point-card source-settings-card">
<strong>Einzelnen Datenpunkt anlegen</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{getManualAddressLabel(sourceToolProtocol)}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
{sourceToolProtocol === "modbus-tcp" ? (
<select value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))}>
{getPointDataTypeOptions(sourceToolProtocol).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : (
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
)}
</label>
<label>
<span>Einheit</span>
<UnitField units={availableUnits} value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
<label>
<span>Faktor</span>
<ScaleField value={String(manualDatapointDraft.scale ?? "1")} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, scale: nextValue }))} />
</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 bei ?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>
<div className="button-row wrap-tight">
{editingCollectorPointId ? <button className="ghost-button compact-button" type="button" onClick={() => resetManualDatapointEditor(sourceToolProtocol)}>Abbrechen</button> : null}
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>{editingCollectorPointId ? "Datenpunkt speichern" : "Datenpunkt anlegen"}</button>
</div>
</div>
) : null}
</div>
</div>
{sourceModalOpen ? (
<div className="modal-backdrop" onClick={closeSourceEditor}>
@@ -2342,7 +2620,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">
@@ -2368,7 +2646,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">
@@ -2379,7 +2657,7 @@ export default function App() {
<span>Schreibmodus</span>
<select value={sourceDraft.writeMode} onChange={(event) => setSourceDraft((current) => ({ ...current, writeMode: event.target.value }))}>
<option value="interval">Jeden Poll schreiben</option>
<option value="cov">Nur bei Änderung (COV)</option>
<option value="cov">Nur bei ?nderung (COV)</option>
</select>
</label>
<label>
@@ -2388,11 +2666,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>
@@ -2414,46 +2692,6 @@ export default function App() {
onChange={(event) => setCollectorPointFilter(event.target.value)}
/>
<div className="protocol-grid source-tools-grid">
<div className="protocol-block">
<strong>Importe</strong>
<div className="button-row wrap-tight">
<label className="ghost-button compact-button file-button">
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
</label>
</div>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}</small>
</div>
{sourceDraft.protocol === "opc-ua" ? (
<div className="protocol-block">
<strong>OPC UA</strong>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button>
</div>
<small className="muted">Verwendet Host und Port der oben ausgewählten Quelle.</small>
{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>
) : null}
{sourceDraft.protocol === "bacnet-ip" ? (
<div className="protocol-block">
<strong>BACnet IP</strong>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button>
</div>
<small className="muted">Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden.</small>
{scanResults.bacnet ? <small className="muted">{`${scanResults.bacnet.devices?.length || 0} Gerät(e)`}</small> : null}
</div>
) : null}
</div>
<div className="scan-result-list">
{filteredOpcuaNodes.slice(0, 160).map((node, index) => {
const pointId = getOpcuaScanDatapointId(selectedSourceId, node);
@@ -2482,7 +2720,7 @@ export default function App() {
<div key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive">
<div className="datapoint-main">
<strong>{normalizeDisplayText(point.alias || point.name)}</strong>
<span>{`${normalizeDisplayText(point.host)} · ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
<span>{`${normalizeDisplayText(point.host)} ? ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
</div>
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
@@ -2503,11 +2741,12 @@ export default function App() {
<span className="datapoint-value">{item.pointIndex}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
<button className="primary-button compact-button" type="button" disabled={item.enabled === true} onClick={() => updateCollectorPoint(item, { enabled: true })}>{item.enabled === true ? "Im Logging" : "Ins Logging"}</button>
</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>
@@ -2519,69 +2758,18 @@ export default function App() {
</div>
</div>
{canManageSources ? (
<div className="protocol-block manual-point-card">
<strong>Einzelnen Datenpunkt anlegen</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{getManualAddressLabel(sourceDraft.protocol)}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
</label>
<label>
<span>Einheit</span>
<UnitField units={availableUnits} 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 bei Ä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>
) : null}
<div className="datapoint-table">
{loggedCollectorPoints.length ? loggedCollectorPoints.slice(0, 2000).map((item) => (
<div key={item.id} className="datapoint-row active">
<div className="datapoint-main">
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` · ${normalizeDisplayText(item.condition)}` : ""}`}</small>
<span>{`Wert ${item.pointIndex} ? ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` ? ${normalizeDisplayText(item.condition)}` : ""}`}</small>
</div>
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
<button className="ghost-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { enabled: false })}>Rausnehmen</button>
</div>
</div>
@@ -2590,9 +2778,7 @@ export default function App() {
</div>
</div>
</div>
</div>
) : null}
{adminSection === "users" && canManageUsers ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
@@ -2817,3 +3003,9 @@ export default function App() {
+1
View File
@@ -29,6 +29,7 @@ async function request(baseUrl, path, options = {}) {
const response = await fetch(baseUrl + path, {
headers,
method: options.method || "GET",
cache: options.cache || "no-store",
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
});
+31 -15
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { jsPDF } from "jspdf";
import * as XLSX from "xlsx";
import {
@@ -144,6 +144,7 @@ export default function TrendChart({
if (event.button !== 0) {
return;
}
event.preventDefault();
const mapped = mapDragToDomain(event.clientX, event.clientY);
if (!mapped) {
return;
@@ -151,36 +152,54 @@ export default function TrendChart({
setDragBox({ start: mapped, end: mapped });
};
const handleMouseMove = (event) => {
if (!dragBox) {
return;
}
const mapped = mapDragToDomain(event.clientX, event.clientY);
const updateDragBox = (clientX, clientY) => {
const mapped = mapDragToDomain(clientX, clientY);
if (!mapped) {
return;
}
setDragBox((current) => current ? { ...current, end: mapped } : current);
};
const handleMouseUp = () => {
if (!dragBox) {
const finalizeDragBox = (box) => {
if (!box) {
return;
}
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
const deltaX = Math.abs(box.end.xPixel - box.start.xPixel);
const deltaY = Math.abs(box.end.yPixel - box.start.yPixel);
if (deltaX < 12 || deltaY < 12) {
setDragBox(null);
return;
}
setZoomDomain({
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
x: [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)],
y: [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)],
});
setDragBox(null);
};
useEffect(() => {
if (!dragBox) {
return undefined;
}
const handleWindowMouseMove = (event) => {
updateDragBox(event.clientX, event.clientY);
};
const handleWindowMouseUp = () => {
finalizeDragBox(dragBox);
};
window.addEventListener("mousemove", handleWindowMouseMove);
window.addEventListener("mouseup", handleWindowMouseUp);
return () => {
window.removeEventListener("mousemove", handleWindowMouseMove);
window.removeEventListener("mouseup", handleWindowMouseUp);
};
}, [dragBox, xDomain, yDomain]);
const resetZoom = () => {
setZoomDomain(null);
};
@@ -367,9 +386,6 @@ export default function TrendChart({
ref={wrapperRef}
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div ref={chartHostRef} className="chart-host">
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
+18 -1
View File
@@ -920,6 +920,22 @@ span {
white-space: nowrap;
}
.source-admin-top {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
gap: 0.65rem;
align-items: start;
}
.source-overview-panel,
.source-settings-panel {
min-height: 100%;
}
.source-settings-card {
margin-top: 0.1rem;
}
.source-split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -995,7 +1011,7 @@ span {
}
.source-tools-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.manual-point-card {
@@ -1030,6 +1046,7 @@ span {
}
@media (max-width: 980px) {
.source-admin-top,
.source-split,
.source-tools-grid,
.source-tile,