beta. Modbus fixed and tested
This commit is contained in:
@@ -4,3 +4,4 @@ dist
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.scratch/runtime
|
.scratch/runtime
|
||||||
data/
|
data/
|
||||||
|
web/src
|
||||||
|
|||||||
@@ -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
|
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.
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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
|
## Inhaltsverzeichnis
|
||||||
- Username: `admin`
|
|
||||||
- Passwort: `ChangeMe123!`
|
|
||||||
|
|
||||||
## Datenbank-Kompatibilität
|
- [Projektaufbau](#projektaufbau)
|
||||||
- Bestehende Anlagen können weiter mit `root` und Passwort `SE3112` auf die MariaDB gehen.
|
- [Architektur](#architektur)
|
||||||
- Die API versucht zusätzlich als Fallback noch das ältere Root-Passwort `root`, damit vorhandene Entwicklungs-Volumes nicht sofort umgebaut werden müssen.
|
- [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
|
## Projektaufbau
|
||||||
- Vorhandene SQL-Dumps können im Admin-Bereich direkt importiert werden.
|
|
||||||
- Leere Trendtabellen tauchen nicht im Trend-Dropdown auf, aber weiter im Admin-Bereich.
|
```text
|
||||||
- Der Collector ist bewusst erst als technisches Gerüst enthalten; Protokoll-Scan und Feldbus-Importe sind noch nicht vollständig implementiert.
|
SeLTD/
|
||||||
- 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.
|
├── .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
@@ -242,8 +242,8 @@ function parseModbusCsv(payload) {
|
|||||||
protocol: "modbus-tcp",
|
protocol: "modbus-tcp",
|
||||||
address,
|
address,
|
||||||
pointIndex,
|
pointIndex,
|
||||||
name: get("name", `Register ${index + 1}`),
|
name: normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
|
||||||
alias: get("alias", get("name", `Register ${index + 1}`)),
|
alias: normalizeText(get("alias", get("name", `Register ${index + 1}`))) || normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
|
||||||
unit: normalizeUnit(get("unit")),
|
unit: normalizeUnit(get("unit")),
|
||||||
dataType: get("datatype", "holding-register"),
|
dataType: get("datatype", "holding-register"),
|
||||||
kind: normalizePointKind(get("kind", get("type", "analog"))),
|
kind: normalizePointKind(get("kind", get("type", "analog"))),
|
||||||
@@ -282,8 +282,8 @@ function parseKnxXml(payload) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const address = getAttr("Address") || getAttr("address");
|
const address = getAttr("Address") || getAttr("address");
|
||||||
const name = getAttr("Name") || getAttr("name") || `KNX ${index + 1}`;
|
const name = normalizeText(getAttr("Name") || getAttr("name") || `KNX ${index + 1}`) || `KNX ${index + 1}`;
|
||||||
const dpt = getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || "";
|
const dpt = normalizeText(getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || "");
|
||||||
|
|
||||||
if (address) {
|
if (address) {
|
||||||
datapoints.push({
|
datapoints.push({
|
||||||
@@ -462,29 +462,71 @@ function tryTcpConnect(host, portNumber, timeout = 1200) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferModbusAddress(datapoint) {
|
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 dataType = String(datapoint.dataType || "").toLowerCase();
|
||||||
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
||||||
const raw = Number.isFinite(rawAddress) && rawAddress > 0 ? rawAddress : pointIndex;
|
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 functionCode = dataType.includes("coil") ? 1 : dataType.includes("discrete") ? 2 : dataType.includes("input") ? 4 : 3;
|
||||||
let address = Math.max(0, raw - 1);
|
let address = Math.max(0, raw - 1);
|
||||||
|
|
||||||
if (raw >= 40001) {
|
if (functionCode === 3 && raw >= 40001 && raw <= 49999) {
|
||||||
functionCode = 3;
|
|
||||||
address = raw - 40001;
|
address = raw - 40001;
|
||||||
} else if (raw >= 30001) {
|
} else if (functionCode === 4 && raw >= 30001 && raw <= 39999) {
|
||||||
functionCode = 4;
|
|
||||||
address = raw - 30001;
|
address = raw - 30001;
|
||||||
} else if (raw >= 10001) {
|
} else if (functionCode === 2 && raw >= 10001 && raw <= 19999) {
|
||||||
functionCode = 2;
|
|
||||||
address = raw - 10001;
|
address = raw - 10001;
|
||||||
} else if (raw <= 9999 && functionCode > 2) {
|
} else if (!addressText.match(/^[134]\d{4,}$/) && raw <= 9999 && functionCode > 2) {
|
||||||
address = raw - 1;
|
address = raw - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { functionCode, address };
|
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) {
|
function readModbusTcp(source, datapoint) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const host = String(source.host || "").trim();
|
const host = String(source.host || "").trim();
|
||||||
@@ -495,6 +537,7 @@ function readModbusTcp(source, datapoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { functionCode, address } = inferModbusAddress(datapoint);
|
const { functionCode, address } = inferModbusAddress(datapoint);
|
||||||
|
const { quantity, decode } = resolveModbusReadDetails(datapoint);
|
||||||
const unitId = Number(source.deviceId || 1) & 0xff;
|
const unitId = Number(source.deviceId || 1) & 0xff;
|
||||||
const currentTransaction = transactionId;
|
const currentTransaction = transactionId;
|
||||||
transactionId = transactionId >= 0xffff ? 1 : transactionId + 1;
|
transactionId = transactionId >= 0xffff ? 1 : transactionId + 1;
|
||||||
@@ -506,7 +549,7 @@ function readModbusTcp(source, datapoint) {
|
|||||||
request.writeUInt8(unitId, 6);
|
request.writeUInt8(unitId, 6);
|
||||||
request.writeUInt8(functionCode, 7);
|
request.writeUInt8(functionCode, 7);
|
||||||
request.writeUInt16BE(address, 8);
|
request.writeUInt16BE(address, 8);
|
||||||
request.writeUInt16BE(1, 10);
|
request.writeUInt16BE(quantity, 10);
|
||||||
|
|
||||||
const socket = new net.Socket();
|
const socket = new net.Socket();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@@ -528,6 +571,11 @@ function readModbusTcp(source, datapoint) {
|
|||||||
socket.setTimeout(1800);
|
socket.setTimeout(1800);
|
||||||
socket.once("timeout", () => finish(new Error("Modbus Timeout.")));
|
socket.once("timeout", () => finish(new Error("Modbus Timeout.")));
|
||||||
socket.once("error", (error) => finish(error));
|
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) => {
|
socket.on("data", (chunk) => {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
const response = Buffer.concat(chunks);
|
const response = Buffer.concat(chunks);
|
||||||
@@ -551,29 +599,23 @@ function readModbusTcp(source, datapoint) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = Number(datapoint.scale || 1) || 1;
|
const byteCount = Number(response.readUInt8(8) || 0);
|
||||||
if (functionCode === 1 || functionCode === 2) {
|
const startOffset = 9;
|
||||||
const digital = (response.readUInt8(9) & 0x01) ? 1 : 0;
|
if (response.length < startOffset + byteCount) {
|
||||||
finish(null, digital);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.length < 11) {
|
|
||||||
finish(new Error("Modbus Antwort zu kurz."));
|
finish(new Error("Modbus Antwort zu kurz."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataType = String(datapoint.dataType || "").toLowerCase();
|
try {
|
||||||
const raw = dataType.includes("int16") || dataType.includes("signed")
|
finish(null, decode(response.subarray(startOffset, startOffset + byteCount), functionCode));
|
||||||
? response.readInt16BE(9)
|
} catch (error) {
|
||||||
: response.readUInt16BE(9);
|
finish(error instanceof Error ? error : new Error(String(error)));
|
||||||
finish(null, raw * scale);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.connect(portNumber, host, () => socket.write(request));
|
socket.connect(portNumber, host, () => socket.write(request));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptionalPackage(packageName) {
|
async function loadOptionalPackage(packageName) {
|
||||||
try {
|
try {
|
||||||
const module = await import(packageName);
|
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) {
|
async function readKnxValue(source, datapoint) {
|
||||||
const knx = await loadOptionalPackage("knx");
|
const knx = await loadOptionalPackage("knx");
|
||||||
const groupAddress = String(datapoint.address || "").trim();
|
const groupAddress = String(datapoint.address || "").trim();
|
||||||
if (!groupAddress) {
|
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) => {
|
return await new Promise((resolve, reject) => {
|
||||||
@@ -890,8 +1008,13 @@ async function readKnxValue(source, datapoint) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
} else {
|
} else {
|
||||||
const numeric = Number(value);
|
const decoded = decodeKnxPayload(value, datapoint.dataType);
|
||||||
resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value || ""));
|
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) {
|
function matchesLogCondition(condition, value) {
|
||||||
const text = String(condition || "").trim();
|
const text = String(condition || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -1399,7 +1521,7 @@ async function pollSource(source, datapoints) {
|
|||||||
updateSourceState(source.id, {
|
updateSourceState(source.id, {
|
||||||
lastPollAt: new Date().toISOString(),
|
lastPollAt: new Date().toISOString(),
|
||||||
lastPollStatus: "unsupported",
|
lastPollStatus: "unsupported",
|
||||||
lastPollError: `${source.protocol} wird vom Collector nicht unterst?tzt.`,
|
lastPollError: `${source.protocol} wird vom Collector nicht unterst\u00fctzt.`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSourceState(source.id, {
|
updateSourceState(source.id, {
|
||||||
@@ -1577,10 +1699,14 @@ const server = createServer(async (req, res) => {
|
|||||||
store.datapoints[index] = {
|
store.datapoints[index] = {
|
||||||
...store.datapoints[index],
|
...store.datapoints[index],
|
||||||
enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled,
|
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,
|
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,
|
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,
|
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,
|
unit: body.unit ? normalizeUnit(body.unit) : store.datapoints[index].unit,
|
||||||
kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind,
|
kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind,
|
||||||
scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale,
|
scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale,
|
||||||
@@ -1740,3 +1866,6 @@ server.listen(port, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -99,7 +99,7 @@ services:
|
|||||||
|
|
||||||
dockhand:
|
dockhand:
|
||||||
image: fnsys/dockhand:latest
|
image: fnsys/dockhand:latest
|
||||||
container_name: dockhand
|
container_name: seltd-dockhand
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -108,7 +108,7 @@ services:
|
|||||||
- dockhand_data:/app/data
|
- dockhand_data:/app/data
|
||||||
cadvisor:
|
cadvisor:
|
||||||
image: ghcr.io/google/cadvisor:v0.60.2
|
image: ghcr.io/google/cadvisor:v0.60.2
|
||||||
container_name: cadvisor
|
container_name: seltd-cadvisor
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
privileged: true
|
||||||
|
|
||||||
|
|||||||
+349
-157
@@ -20,8 +20,43 @@ const adminSections = [
|
|||||||
{ id: "debug", label: "Debug" },
|
{ 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 = {
|
const emptyTrendRange = {
|
||||||
presetDays: 1,
|
presetDays: "1",
|
||||||
from: "",
|
from: "",
|
||||||
to: "",
|
to: "",
|
||||||
maxRows: 4000,
|
maxRows: 4000,
|
||||||
@@ -129,6 +164,13 @@ function getDefaultPointDataType(protocol) {
|
|||||||
return "holding-register";
|
return "holding-register";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPointDataTypeOptions(protocol) {
|
||||||
|
if (protocol === "modbus-tcp") {
|
||||||
|
return MODBUS_DATA_TYPE_OPTIONS;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function getOpcuaScanDatapointId(sourceId, node) {
|
function getOpcuaScanDatapointId(sourceId, node) {
|
||||||
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
|
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 }) {
|
function ColorField({ value, onChange }) {
|
||||||
const normalizedValue = hslToHexString(value || "");
|
const normalizedValue = hslToHexString(value || "");
|
||||||
const pickerValue = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(normalizedValue || "") ? normalizedValue : "#d62839";
|
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 }) {
|
function LoginScreen({ onLogin, error, busy }) {
|
||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("ChangeMe123!");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -637,6 +706,7 @@ export default function App() {
|
|||||||
const [units, setUnits] = useState(DEFAULT_UNIT_OPTIONS);
|
const [units, setUnits] = useState(DEFAULT_UNIT_OPTIONS);
|
||||||
const [unitDraft, setUnitDraft] = useState({ symbol: "", label: "" });
|
const [unitDraft, setUnitDraft] = useState({ symbol: "", label: "" });
|
||||||
const [editingUnitSymbol, setEditingUnitSymbol] = useState("");
|
const [editingUnitSymbol, setEditingUnitSymbol] = useState("");
|
||||||
|
const [editingCollectorPointId, setEditingCollectorPointId] = useState("");
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||||
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
||||||
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
|
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
|
||||||
@@ -681,6 +751,40 @@ export default function App() {
|
|||||||
const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]);
|
const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]);
|
||||||
const loggedCollectorPoints = 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 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(
|
const filteredOpcuaNodes = useMemo(
|
||||||
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
|
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
|
||||||
node.displayName,
|
node.displayName,
|
||||||
@@ -767,7 +871,7 @@ export default function App() {
|
|||||||
setThemeMode(pref.theme || "system");
|
setThemeMode(pref.theme || "system");
|
||||||
setCsvDelimiter(pref.csvDelimiter || ";");
|
setCsvDelimiter(pref.csvDelimiter || ";");
|
||||||
setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true);
|
setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true);
|
||||||
setIsps(ispResult.isps);
|
setIsps(ispResult.isps || []);
|
||||||
setSelections(selectionResult.selections);
|
setSelections(selectionResult.selections);
|
||||||
setDashboard(dashboardResult);
|
setDashboard(dashboardResult);
|
||||||
setActiveView("dashboard");
|
setActiveView("dashboard");
|
||||||
@@ -940,6 +1044,33 @@ export default function App() {
|
|||||||
.catch((error) => setBootError(error.message));
|
.catch((error) => setBootError(error.message));
|
||||||
}, [canEditAliases, canManageSources, token]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
|
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
|
||||||
setCollectorCapabilities(null);
|
setCollectorCapabilities(null);
|
||||||
@@ -1382,8 +1513,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const sql = await file.text();
|
const sql = await file.text();
|
||||||
await api.importSql(token, { filename: file.name, sql });
|
await api.importSql(token, { filename: file.name, sql });
|
||||||
const refreshed = await api.getIsps(token);
|
await refreshIspCatalog();
|
||||||
setIsps(refreshed.isps);
|
|
||||||
setSuccessMessage("SQL-Datei importiert.");
|
setSuccessMessage("SQL-Datei importiert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBootError(error.message || "SQL-Datei konnte nicht importiert werden.");
|
setBootError(error.message || "SQL-Datei konnte nicht importiert werden.");
|
||||||
@@ -1401,8 +1531,15 @@ export default function App() {
|
|||||||
resetFlash();
|
resetFlash();
|
||||||
try {
|
try {
|
||||||
const result = await api.createTrendTable(token, newTableDraft);
|
const result = await api.createTrendTable(token, newTableDraft);
|
||||||
const refreshed = await api.getIsps(token);
|
upsertIspEntry({
|
||||||
setIsps(refreshed.isps);
|
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);
|
setAdminIsp(result.tableName);
|
||||||
setNewTableDraft({ tableName: "", displayName: "" });
|
setNewTableDraft({ tableName: "", displayName: "" });
|
||||||
setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`);
|
setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`);
|
||||||
@@ -1475,10 +1612,17 @@ export default function App() {
|
|||||||
displayName: payload.displayName,
|
displayName: payload.displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
upsertIspEntry({
|
||||||
|
tableName: payload.tableName,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
description: "",
|
||||||
|
storage: "narrow",
|
||||||
|
pointCount: 0,
|
||||||
|
rowCount: 0,
|
||||||
|
});
|
||||||
await refreshCollectorData();
|
await refreshCollectorData();
|
||||||
await refreshCollectorDebug().catch(() => undefined);
|
await refreshCollectorDebug().catch(() => undefined);
|
||||||
const refreshed = await api.getIsps(token);
|
await refreshIspCatalog().catch(() => undefined);
|
||||||
setIsps(refreshed.isps);
|
|
||||||
setIsCreatingSource(false);
|
setIsCreatingSource(false);
|
||||||
setSelectedSourceId(saved.id);
|
setSelectedSourceId(saved.id);
|
||||||
setSourceModalOpen(false);
|
setSourceModalOpen(false);
|
||||||
@@ -1520,6 +1664,26 @@ export default function App() {
|
|||||||
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
|
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 saveManualCollectorDatapoint = async () => {
|
||||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||||
if (!activeSourceId) {
|
if (!activeSourceId) {
|
||||||
@@ -1529,7 +1693,7 @@ export default function App() {
|
|||||||
|
|
||||||
resetFlash();
|
resetFlash();
|
||||||
try {
|
try {
|
||||||
await api.createCollectorDatapoint({
|
const payload = {
|
||||||
sourceId: activeSourceId,
|
sourceId: activeSourceId,
|
||||||
protocol: sourceDraft.protocol,
|
protocol: sourceDraft.protocol,
|
||||||
alias: manualDatapointDraft.alias,
|
alias: manualDatapointDraft.alias,
|
||||||
@@ -1543,11 +1707,17 @@ export default function App() {
|
|||||||
scale: manualDatapointDraft.scale,
|
scale: manualDatapointDraft.scale,
|
||||||
writeMode: manualDatapointDraft.writeMode,
|
writeMode: manualDatapointDraft.writeMode,
|
||||||
condition: manualDatapointDraft.condition,
|
condition: manualDatapointDraft.condition,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (editingCollectorPointId) {
|
||||||
|
await api.updateCollectorDatapoint(editingCollectorPointId, payload);
|
||||||
|
} else {
|
||||||
|
await api.createCollectorDatapoint(payload);
|
||||||
|
}
|
||||||
await refreshCollectorData();
|
await refreshCollectorData();
|
||||||
await refreshCollectorDebug().catch(() => undefined);
|
await refreshCollectorDebug().catch(() => undefined);
|
||||||
setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol));
|
resetManualDatapointEditor(sourceDraft.protocol);
|
||||||
setSuccessMessage("Datenpunkt angelegt.");
|
setSuccessMessage(editingCollectorPointId ? "Datenpunkt aktualisiert." : "Datenpunkt angelegt.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBootError(error.message);
|
setBootError(error.message);
|
||||||
}
|
}
|
||||||
@@ -1864,8 +2034,10 @@ export default function App() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Tage</span>
|
<span>Zeitraum</span>
|
||||||
<input type="number" min="1" max="365" value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))} />
|
<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>
|
</label>
|
||||||
{widgetDraft.source === "saved" ? (
|
{widgetDraft.source === "saved" ? (
|
||||||
<label className="span-two">
|
<label className="span-two">
|
||||||
@@ -2022,8 +2194,10 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="form-grid compact-forms">
|
<div className="form-grid compact-forms">
|
||||||
<label>
|
<label>
|
||||||
<span>Tage</span>
|
<span>Zeitraum</span>
|
||||||
<input type="number" min="1" max="365" value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))} />
|
<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>
|
||||||
<label>
|
<label>
|
||||||
<span>Max. Zeilen</span>
|
<span>Max. Zeilen</span>
|
||||||
@@ -2205,7 +2379,7 @@ export default function App() {
|
|||||||
<div className="alias-index">Wert {point.pointIndex}</div>
|
<div className="alias-index">Wert {point.pointIndex}</div>
|
||||||
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
|
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
|
||||||
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
|
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
|
||||||
<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 })}>
|
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value })}>
|
||||||
<option value="analog">analog</option>
|
<option value="analog">analog</option>
|
||||||
<option value="digital">digital</option>
|
<option value="digital">digital</option>
|
||||||
@@ -2294,45 +2468,149 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="protocol-panel">
|
<div className="source-admin-top">
|
||||||
<div className="panel-header compact-header nested-gap">
|
<div className="protocol-panel source-overview-panel">
|
||||||
<div>
|
<div className="panel-header compact-header nested-gap">
|
||||||
<h3>Protokollquellen</h3>
|
<div>
|
||||||
<p>{collectorCapabilities?.protocols?.join(" · ") || "Modbus TCP · OPC UA · BACnet IP · KNX IP"}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
|
<div className="protocol-panel source-settings-panel">
|
||||||
{collectorSources.map((source) => {
|
<div className="panel-header compact-header nested-gap">
|
||||||
const isActive = source.id === selectedSourceId;
|
<div>
|
||||||
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
|
<h3>Datenpunkte & Werkzeuge</h3>
|
||||||
return (
|
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
|
||||||
<button
|
</div>
|
||||||
key={source.id}
|
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")}</small>
|
||||||
type="button"
|
</div>
|
||||||
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
|
|
||||||
onClick={() => {
|
<div className="protocol-grid source-tools-grid">
|
||||||
setIsCreatingSource(false);
|
<div className="protocol-block">
|
||||||
setSelectedSourceId(source.id);
|
<strong>Import / Vorlagen</strong>
|
||||||
}}
|
<div className="button-row wrap-tight">
|
||||||
>
|
<label className="ghost-button compact-button file-button">
|
||||||
<div className="source-tile-copy">
|
<span>Modbus CSV</span>
|
||||||
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
|
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
|
||||||
<span>{`${source.protocol} · ${normalizeDisplayText(source.tableName)}`}</span>
|
</label>
|
||||||
<small>{`${String(source.pollIntervalSeconds || 60)}s · ${pointCount} DP`}</small>
|
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
|
||||||
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
|
<label className="ghost-button compact-button file-button">
|
||||||
</div>
|
<span>KNX XML</span>
|
||||||
<div className="source-tile-actions">
|
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
|
||||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
</label>
|
||||||
{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}
|
</div>
|
||||||
{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}
|
<small className="muted">SQL-Import bleibt oben. Bei Modbus kommt der Function Code jetzt aus dem Dropdown Datenart am Datenpunkt.</small>
|
||||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
</div>
|
||||||
</div>
|
|
||||||
</button>
|
<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">
|
||||||
{!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}
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sourceModalOpen ? (
|
{sourceModalOpen ? (
|
||||||
<div className="modal-backdrop" onClick={closeSourceEditor}>
|
<div className="modal-backdrop" onClick={closeSourceEditor}>
|
||||||
@@ -2342,7 +2620,7 @@ export default function App() {
|
|||||||
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
|
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
|
||||||
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
|
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
|
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schlie?en</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid compact-forms">
|
<div className="form-grid compact-forms">
|
||||||
@@ -2368,7 +2646,7 @@ export default function App() {
|
|||||||
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
|
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Geräte-ID</span>
|
<span>Ger?te-ID</span>
|
||||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="span-two">
|
<label className="span-two">
|
||||||
@@ -2379,7 +2657,7 @@ export default function App() {
|
|||||||
<span>Schreibmodus</span>
|
<span>Schreibmodus</span>
|
||||||
<select value={sourceDraft.writeMode} onChange={(event) => setSourceDraft((current) => ({ ...current, writeMode: event.target.value }))}>
|
<select value={sourceDraft.writeMode} onChange={(event) => setSourceDraft((current) => ({ ...current, writeMode: event.target.value }))}>
|
||||||
<option value="interval">Jeden Poll schreiben</option>
|
<option value="interval">Jeden Poll schreiben</option>
|
||||||
<option value="cov">Nur bei Änderung (COV)</option>
|
<option value="cov">Nur bei ?nderung (COV)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -2388,11 +2666,11 @@ export default function App() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
|
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf.</small>
|
||||||
|
|
||||||
<div className="button-row wrap-tight">
|
<div className="button-row wrap-tight">
|
||||||
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
|
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
|
||||||
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
|
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle l?schen</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2414,46 +2692,6 @@ export default function App() {
|
|||||||
onChange={(event) => setCollectorPointFilter(event.target.value)}
|
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">
|
<div className="scan-result-list">
|
||||||
{filteredOpcuaNodes.slice(0, 160).map((node, index) => {
|
{filteredOpcuaNodes.slice(0, 160).map((node, index) => {
|
||||||
const pointId = getOpcuaScanDatapointId(selectedSourceId, node);
|
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 key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive">
|
||||||
<div className="datapoint-main">
|
<div className="datapoint-main">
|
||||||
<strong>{normalizeDisplayText(point.alias || point.name)}</strong>
|
<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>
|
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
|
||||||
</div>
|
</div>
|
||||||
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
|
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
|
||||||
@@ -2503,11 +2741,12 @@ export default function App() {
|
|||||||
<span className="datapoint-value">{item.pointIndex}</span>
|
<span className="datapoint-value">{item.pointIndex}</span>
|
||||||
<div className="button-row wrap-tight">
|
<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={() => 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)) : null}
|
)) : null}
|
||||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2519,69 +2758,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="datapoint-table">
|
||||||
{loggedCollectorPoints.length ? loggedCollectorPoints.slice(0, 2000).map((item) => (
|
{loggedCollectorPoints.length ? loggedCollectorPoints.slice(0, 2000).map((item) => (
|
||||||
<div key={item.id} className="datapoint-row active">
|
<div key={item.id} className="datapoint-row active">
|
||||||
<div className="datapoint-main">
|
<div className="datapoint-main">
|
||||||
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
||||||
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
|
<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>
|
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` ? ${normalizeDisplayText(item.condition)}` : ""}`}</small>
|
||||||
</div>
|
</div>
|
||||||
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
|
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
|
||||||
<div className="button-row wrap-tight">
|
<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={() => 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>
|
<button className="ghost-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { enabled: false })}>Rausnehmen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2590,9 +2778,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{adminSection === "users" && canManageUsers ? (
|
{adminSection === "users" && canManageUsers ? (
|
||||||
<div className="panel slim-panel">
|
<div className="panel slim-panel">
|
||||||
<div className="panel-header compact-header nested-gap">
|
<div className="panel-header compact-header nested-gap">
|
||||||
@@ -2817,3 +3003,9 @@ export default function App() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async function request(baseUrl, path, options = {}) {
|
|||||||
const response = await fetch(baseUrl + path, {
|
const response = await fetch(baseUrl + path, {
|
||||||
headers,
|
headers,
|
||||||
method: options.method || "GET",
|
method: options.method || "GET",
|
||||||
|
cache: options.cache || "no-store",
|
||||||
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
|
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { jsPDF } from "jspdf";
|
import { jsPDF } from "jspdf";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import {
|
import {
|
||||||
@@ -144,6 +144,7 @@ export default function TrendChart({
|
|||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
||||||
if (!mapped) {
|
if (!mapped) {
|
||||||
return;
|
return;
|
||||||
@@ -151,36 +152,54 @@ export default function TrendChart({
|
|||||||
setDragBox({ start: mapped, end: mapped });
|
setDragBox({ start: mapped, end: mapped });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (event) => {
|
const updateDragBox = (clientX, clientY) => {
|
||||||
if (!dragBox) {
|
const mapped = mapDragToDomain(clientX, clientY);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
|
||||||
if (!mapped) {
|
if (!mapped) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDragBox((current) => current ? { ...current, end: mapped } : current);
|
setDragBox((current) => current ? { ...current, end: mapped } : current);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const finalizeDragBox = (box) => {
|
||||||
if (!dragBox) {
|
if (!box) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
|
const deltaX = Math.abs(box.end.xPixel - box.start.xPixel);
|
||||||
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
|
const deltaY = Math.abs(box.end.yPixel - box.start.yPixel);
|
||||||
if (deltaX < 12 || deltaY < 12) {
|
if (deltaX < 12 || deltaY < 12) {
|
||||||
setDragBox(null);
|
setDragBox(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoomDomain({
|
setZoomDomain({
|
||||||
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
|
x: [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)],
|
||||||
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
|
y: [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)],
|
||||||
});
|
});
|
||||||
setDragBox(null);
|
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 = () => {
|
const resetZoom = () => {
|
||||||
setZoomDomain(null);
|
setZoomDomain(null);
|
||||||
};
|
};
|
||||||
@@ -367,9 +386,6 @@ export default function TrendChart({
|
|||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
|
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
>
|
>
|
||||||
<div ref={chartHostRef} className="chart-host">
|
<div ref={chartHostRef} className="chart-host">
|
||||||
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
|
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
|
||||||
|
|||||||
+18
-1
@@ -920,6 +920,22 @@ span {
|
|||||||
white-space: nowrap;
|
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 {
|
.source-split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
@@ -995,7 +1011,7 @@ span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.source-tools-grid {
|
.source-tools-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.manual-point-card {
|
.manual-point-card {
|
||||||
@@ -1030,6 +1046,7 @@ span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.source-admin-top,
|
||||||
.source-split,
|
.source-split,
|
||||||
.source-tools-grid,
|
.source-tools-grid,
|
||||||
.source-tile,
|
.source-tile,
|
||||||
|
|||||||
Reference in New Issue
Block a user