beta. Modbus fixed and tested
This commit is contained in:
@@ -4,3 +4,4 @@ dist
|
||||
npm-debug.log*
|
||||
.scratch/runtime
|
||||
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
|
||||
- React-Frontend mit System-, Hell- und Dunkelmodus
|
||||
- Username-Login, Rollen, Passwortwechsel und Benutzerverwaltung
|
||||
- Alias-Verwaltung für `Wert1` bis `Wert200` je Trendtabelle
|
||||
- CSV-Import und CSV-Export für Aliasdaten
|
||||
- Gespeicherte Auswahlen über mehrere ISPs hinweg
|
||||
- Dashboard mit Graph-, Zahl- und Zeigermanometer-Widgets
|
||||
- Fokusansicht für den Trendgraph statt Browser-Vollbild
|
||||
- Export des Trendgraphs als CSV, XLSX, PNG, JPG, SVG und PDF
|
||||
- SQL-Import für vorhandene Langzeitdaten
|
||||
- Redis für Metadaten, Auswahlen, Dashboard und Benutzerpräferenzen
|
||||
- MariaDB-Init für `isp01` bis `isp20` plus `app_users`
|
||||
- Dockhand im Compose-Stack
|
||||
- Collector-Gerüst für spätere Modbus-, OPC-UA-, BACnet- und KNX-Anbindung
|
||||
Das Ziel ist, bestehende breite Trendtabellen wie `isp01`, `isp02` usw. weiter nutzen zu koennen und zusaetzlich neue Quellen sauber ueber eine Weboberflaeche anzulegen, zu scannen, zu loggen und im Dashboard oder in der Trendansicht auszuwerten.
|
||||
|
||||
## Starten
|
||||
1. Optional `.env.example` nach `.env` kopieren und Werte anpassen.
|
||||
2. `docker compose up -d --build`
|
||||
3. Frontend: `http://localhost:5173`
|
||||
4. API: `http://localhost:18080/api`
|
||||
5. Dockhand: `http://localhost:3000`
|
||||
6. Collector: `http://localhost:18110/health`
|
||||
---
|
||||
|
||||
## Standard-Login
|
||||
- Username: `admin`
|
||||
- Passwort: `ChangeMe123!`
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
## Datenbank-Kompatibilität
|
||||
- Bestehende Anlagen können weiter mit `root` und Passwort `SE3112` auf die MariaDB gehen.
|
||||
- Die API versucht zusätzlich als Fallback noch das ältere Root-Passwort `root`, damit vorhandene Entwicklungs-Volumes nicht sofort umgebaut werden müssen.
|
||||
- [Projektaufbau](#projektaufbau)
|
||||
- [Architektur](#architektur)
|
||||
- [Services und Ports](#services-und-ports)
|
||||
- [Schnellstart mit Docker Compose](#schnellstart-mit-docker-compose)
|
||||
- [Lokale Entwicklung](#lokale-entwicklung)
|
||||
- [Konfiguration](#konfiguration)
|
||||
- [Login, Benutzer und Rollen](#login-benutzer-und-rollen)
|
||||
- [Frontend-Funktionen](#frontend-funktionen)
|
||||
- [API-Funktionen](#api-funktionen)
|
||||
- [Collector-Funktionen](#collector-funktionen)
|
||||
- [Datenbank und Speicher](#datenbank-und-speicher)
|
||||
- [WAGO- oder SD-Karten-Betrieb](#wago--oder-sd-karten-betrieb)
|
||||
- [Backup und Restore](#backup-und-restore)
|
||||
- [Deployment-Hinweise](#deployment-hinweise)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Sicherheits-Checkliste](#sicherheits-checkliste)
|
||||
|
||||
## Wago / SD-Karte
|
||||
- Für Wago-Systeme liegt mit [docker-compose.wago.yml](/C:/Projects/SeLTD/docker-compose.wago.yml) ein Override bereit.
|
||||
- Start auf Wago mit SD-Karte: `docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build`
|
||||
- Dabei landen die großen Daten unter `/media/sd/seltd/...`.
|
||||
---
|
||||
|
||||
## Hinweise
|
||||
- Vorhandene SQL-Dumps können im Admin-Bereich direkt importiert werden.
|
||||
- Leere Trendtabellen tauchen nicht im Trend-Dropdown auf, aber weiter im Admin-Bereich.
|
||||
- Der Collector ist bewusst erst als technisches Gerüst enthalten; Protokoll-Scan und Feldbus-Importe sind noch nicht vollständig implementiert.
|
||||
- Für Dockhand wurde der Container ergänzt. Eine sichere automatische Vorbelegung eines lokalen Admin-Users war ohne offizielle Initialisierungsdokumentation nicht belastbar genug und wurde deshalb nicht blind in die SQLite-Datenbank geschrieben.
|
||||
## Projektaufbau
|
||||
|
||||
```text
|
||||
SeLTD/
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.wago.yml
|
||||
├── docker/
|
||||
│ └── redis/
|
||||
│ └── redis.conf
|
||||
├── api/
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ └── src/
|
||||
│ ├── index.js
|
||||
│ ├── auth.js
|
||||
│ ├── config.js
|
||||
│ ├── db.js
|
||||
│ ├── redis.js
|
||||
│ └── lib/
|
||||
│ ├── isp.js
|
||||
│ └── store.js
|
||||
├── collector/
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ └── src/
|
||||
│ └── index.js
|
||||
└── web/
|
||||
├── Dockerfile
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── public/
|
||||
│ └── logo.png
|
||||
└── src/
|
||||
├── App.jsx
|
||||
├── api.js
|
||||
├── main.jsx
|
||||
├── styles.css
|
||||
└── components/
|
||||
├── Gauge.jsx
|
||||
└── TrendChart.jsx
|
||||
```
|
||||
|
||||
### Hauptbereiche
|
||||
|
||||
| Bereich | Aufgabe |
|
||||
| --- | --- |
|
||||
| `web/` | React/Vite-Frontend fuer Login, Dashboard, Trends, Admin-Bereich, Quellen, Rollen und Benutzer |
|
||||
| `api/` | Express-API fuer Auth, Rollen, Aliase, Trendabfragen, Dashboard, SQL-Import und Benutzerverwaltung |
|
||||
| `collector/` | HTTP-Service fuer Protokollquellen, Datenpunkte, Scans, Imports und zyklisches Logging |
|
||||
| `mariadb` | Persistente Trenddaten und Benutzer-Tabelle |
|
||||
| `redis` | Metadaten, Aliase, Rollen, Einheiten, Dashboards, Auswahlen und User-Praeferenzen |
|
||||
| `dockhand` | Docker-Verwaltung ueber Weboberflaeche |
|
||||
| `cadvisor` | Container-Metriken fuer Monitoring |
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```text
|
||||
Browser
|
||||
│
|
||||
│ http://localhost:5173
|
||||
▼
|
||||
React/Vite Frontend (web)
|
||||
│
|
||||
├── API-Aufrufe: http://localhost:18080/api/...
|
||||
│ ▼
|
||||
│ Express API (api, intern Port 8080)
|
||||
│ ├── MariaDB: Benutzer, Trendtabellen, SQL-Daten
|
||||
│ └── Redis: Rollen, Einheiten, Aliase, Dashboard, Auswahlen
|
||||
│
|
||||
└── Collector-Aufrufe: http://localhost:18110/...
|
||||
▼
|
||||
Collector-Service
|
||||
├── sources.json unter /app/data
|
||||
├── Modbus TCP / OPC UA / BACnet / KNX
|
||||
└── schreibt aktive Datenpunkte in MariaDB-Trendtabellen
|
||||
```
|
||||
|
||||
Wichtig: Die API laeuft im Container intern auf Port `8080`, wird aber nach aussen auf `18080` gemappt. Das Frontend muss deshalb im Browser auf `http://localhost:18080/api` zugreifen.
|
||||
|
||||
---
|
||||
|
||||
## Services und Ports
|
||||
|
||||
| Service | Container | Interner Port | Host-Port | Zweck |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| Web | `seltd-web` | 5173 | 5173 | React/Vite-Oberflaeche |
|
||||
| API | `seltd-api` | 8080 | 18080 | REST-API |
|
||||
| Collector | `seltd-collector` | 18110 | 18110 | Quellen, Datenpunkte, Scans, Polling |
|
||||
| MariaDB | `seltd-mariadb` | 3306 | 3306 | Trend- und Benutzerdaten |
|
||||
| Redis | `seltd-redis` | 6379 | 6379 | Metadaten und App-State |
|
||||
| Dockhand | `dockhand` | 3000 | 3000 | Docker-Webverwaltung |
|
||||
| cAdvisor | `cadvisor_ltd` | 8080 | 9090 | Container-Metriken |
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart mit Docker Compose
|
||||
|
||||
### 1. Repository vorbereiten
|
||||
|
||||
```powershell
|
||||
cd C:\Projects\SeLTD
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
Passe danach mindestens diese Werte an:
|
||||
|
||||
```env
|
||||
JWT_SECRET=bitte-langen-zufaelligen-wert-setzen
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
MARIADB_ROOT_PASSWORD=SE3112
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
VITE_API_PORT=18080
|
||||
```
|
||||
|
||||
### 2. Stack bauen und starten
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Bei groesseren Aenderungen am Code oder wenn der Browser alten Code anzeigt:
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
docker compose build --no-cache web api collector
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Aufrufen
|
||||
|
||||
| Anwendung | URL |
|
||||
| --- | --- |
|
||||
| Frontend | `http://localhost:5173` |
|
||||
| API Healthcheck | `http://localhost:18080/api/health` |
|
||||
| Collector Healthcheck | `http://localhost:18110/health` |
|
||||
| Dockhand | `http://localhost:3000` |
|
||||
| cAdvisor | `http://localhost:9090` |
|
||||
|
||||
### 4. Standard-Login
|
||||
|
||||
```text
|
||||
Username: admin
|
||||
Passwort: ChangeMe123!
|
||||
```
|
||||
|
||||
Das Passwort sollte nach dem ersten Login geaendert werden.
|
||||
|
||||
---
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### Frontend lokal starten
|
||||
|
||||
```powershell
|
||||
cd C:\Projects\SeLTD\web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Das Frontend laeuft dann auf:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
### API lokal starten
|
||||
|
||||
```powershell
|
||||
cd C:\Projects\SeLTD\api
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Die API liest ihre Konfiguration aus `.env`. Wenn du die API lokal ohne Docker laufen laesst, muss `DB_HOST` auf den erreichbaren MariaDB-Host zeigen.
|
||||
|
||||
### Collector lokal starten
|
||||
|
||||
```powershell
|
||||
cd C:\Projects\SeLTD\collector
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Der Collector braucht Zugriff auf MariaDB und schreibt seine lokale Konfiguration in `collector/data/sources.json`.
|
||||
|
||||
---
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die wichtigsten Variablen liegen in `.env`.
|
||||
|
||||
| Variable | Beispiel | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `JWT_SECRET` | `change-me` | Signatur fuer Login-Tokens. In Produktion unbedingt aendern. |
|
||||
| `ADMIN_USERNAME` | `admin` | Initialer Admin-Username |
|
||||
| `ADMIN_EMAIL` | `admin@example.com` | Initiale Admin-E-Mail |
|
||||
| `ADMIN_PASSWORD` | `ChangeMe123!` | Initiales Admin-Passwort |
|
||||
| `ADMIN_NAME` | `System Admin` | Anzeigename des Admins |
|
||||
| `MARIADB_ROOT_PASSWORD` | `SE3112` | MariaDB-Root-Passwort |
|
||||
| `DB_PASSWORD_FALLBACK` | `root` | Fallback fuer alte Entwicklungsvolumes |
|
||||
| `CORS_ORIGIN` | `http://localhost:5173` | Erlaubter Browser-Ursprung fuer API-Aufrufe |
|
||||
| `VITE_API_PORT` | `18080` | API-Port, den das Frontend im Browser nutzt |
|
||||
| `POLL_TICK_MS` | `1000` | Collector-Tick fuer Polling-Pruefung |
|
||||
|
||||
### CORS
|
||||
|
||||
Wenn das Frontend lokal auf `http://localhost:5173` laeuft, muss in der API stehen:
|
||||
|
||||
```env
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
Bei mehreren erlaubten Origins kann eine kommaseparierte Liste genutzt werden:
|
||||
|
||||
```env
|
||||
CORS_ORIGIN=http://localhost:5173,https://trend.example.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Login, Benutzer und Rollen
|
||||
|
||||
Die API legt beim Start automatisch einen Admin-Benutzer an oder aktualisiert den vorhandenen Admin.
|
||||
|
||||
### Systemrollen
|
||||
|
||||
| Rolle | Rechte |
|
||||
| --- | --- |
|
||||
| `viewer` | Dashboard anzeigen, Trendansicht anzeigen |
|
||||
| `technician` | Dashboard, Trends, Aliase, Quellen, Debug |
|
||||
| `admin` | Alle Rechte inkl. Benutzer- und Rollenverwaltung |
|
||||
|
||||
### Rechtekatalog
|
||||
|
||||
| Recht | Bedeutung |
|
||||
| --- | --- |
|
||||
| `view_dashboard` | Dashboard anzeigen und speichern |
|
||||
| `view_trends` | Trends anzeigen, Auswahlen nutzen |
|
||||
| `edit_aliases` | Aliase und Einheiten bearbeiten |
|
||||
| `manage_sources` | Quellen und Datenpunkte verwalten |
|
||||
| `manage_users` | Benutzer anlegen und bearbeiten |
|
||||
| `manage_roles` | Rollen und Rechte verwalten |
|
||||
| `view_debug` | Collector-Debug sehen |
|
||||
|
||||
Benutzer, Rollen und Passwoerter werden ueber den Admin-Bereich verwaltet.
|
||||
|
||||
---
|
||||
|
||||
## Frontend-Funktionen
|
||||
|
||||
### Login
|
||||
|
||||
- Username-basierter Login
|
||||
- JWT-Token wird im Browser gespeichert
|
||||
- Passwortwechsel ueber Einstellungen
|
||||
- Rollenbasierte Navigation
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Widgets aus aktueller Auswahl oder gespeicherter Auswahl
|
||||
- Widget-Typen:
|
||||
- Graph
|
||||
- Zahl
|
||||
- Zeigermanometer
|
||||
- Dashboard wird pro Benutzer gespeichert
|
||||
|
||||
### Trendansicht
|
||||
|
||||
- Datenpunkte aus mehreren Trendtabellen kombinierbar
|
||||
- Auswahl speicherbar und optional fuer andere Benutzer freigebbar
|
||||
- Zeitraeume als Presets von 5 Minuten bis 30 Tage
|
||||
- Eigener Start-/Endzeitraum moeglich
|
||||
- Aktuelle Werte werden separat angezeigt
|
||||
- Fokusmodus fuer den Graphen
|
||||
|
||||
### Trend-Export
|
||||
|
||||
Der Trendgraph kann exportiert werden als:
|
||||
|
||||
- CSV
|
||||
- XLSX
|
||||
- PNG
|
||||
- JPG
|
||||
- SVG
|
||||
- PDF
|
||||
|
||||
### Admin: Aliase
|
||||
|
||||
- Anzeigename und Beschreibung pro Trendtabelle
|
||||
- Alias, Einheit, Faktor, Typ, Min/Max und Farbe pro Wert
|
||||
- CSV-Import und CSV-Export
|
||||
- Encoding-/Grad-Celsius-Bereinigung
|
||||
|
||||
### Admin: Einheiten
|
||||
|
||||
- Systemeinheiten wie `°C`, `%`, `K`, `V`, `A`, `Pa`
|
||||
- Eigene Einheiten wie `bar`, `m³/h`, `Hz` usw.
|
||||
- Einheitenauswahl bei Aliasen und Datenpunkten
|
||||
|
||||
### Admin: Quellen
|
||||
|
||||
- Protokollquellen anlegen und bearbeiten
|
||||
- Unterstuetzte Protokolle in der UI:
|
||||
- Modbus TCP
|
||||
- OPC UA
|
||||
- BACnet IP
|
||||
- KNX IP
|
||||
- Poll-Intervall und Schreibmodus konfigurierbar
|
||||
- Scan-Ergebnisse und importierte Punkte koennen ins Logging uebernommen werden
|
||||
- Manuelle Datenpunkte koennen erstellt und bearbeitet werden
|
||||
|
||||
---
|
||||
|
||||
## API-Funktionen
|
||||
|
||||
Die API laeuft unter:
|
||||
|
||||
```text
|
||||
http://localhost:18080/api
|
||||
```
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/auth/login` | Login mit Username/Passwort |
|
||||
| `GET` | `/api/auth/me` | Aktuellen Benutzer lesen |
|
||||
| `POST` | `/api/auth/change-password` | Passwort aendern |
|
||||
|
||||
### Trends und ISPs
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/isps` | Verfuegbare Trendtabellen lesen |
|
||||
| `GET` | `/api/isps/:isp/aliases` | Alias-Metadaten lesen |
|
||||
| `PUT` | `/api/isps/:isp/aliases` | Alias-Metadaten speichern |
|
||||
| `POST` | `/api/trend-tables` | Neue Trendtabelle anlegen |
|
||||
| `POST` | `/api/trends/query` | Trenddaten abfragen |
|
||||
| `POST` | `/api/points/latest` | Letzte Werte lesen |
|
||||
|
||||
### Einheiten
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/units` | Einheiten lesen |
|
||||
| `POST` | `/api/units` | Einheit anlegen |
|
||||
| `PATCH` | `/api/units/:symbol` | Einheit bearbeiten |
|
||||
|
||||
### Benutzer und Rollen
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/users` | Benutzerliste |
|
||||
| `POST` | `/api/users` | Benutzer anlegen |
|
||||
| `PATCH` | `/api/users/:id` | Benutzer bearbeiten |
|
||||
| `GET` | `/api/roles` | Rollenliste inkl. Rechtekatalog |
|
||||
| `POST` | `/api/roles` | Rolle anlegen |
|
||||
| `PATCH` | `/api/roles/:name` | Rolle bearbeiten |
|
||||
|
||||
### Auswahlen und Dashboard
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/selections` | Gespeicherte Auswahlen lesen |
|
||||
| `POST` | `/api/selections` | Auswahl speichern |
|
||||
| `PUT` | `/api/selections/:selectionId` | Auswahl aktualisieren |
|
||||
| `DELETE` | `/api/selections/:selectionId` | Auswahl loeschen |
|
||||
| `GET` | `/api/dashboard` | Dashboard laden |
|
||||
| `PUT` | `/api/dashboard` | Dashboard speichern |
|
||||
| `POST` | `/api/dashboard/data` | Widget-Daten laden |
|
||||
| `GET` | `/api/preferences` | Benutzerpraeferenzen laden |
|
||||
| `PUT` | `/api/preferences` | Benutzerpraeferenzen speichern |
|
||||
|
||||
---
|
||||
|
||||
## Collector-Funktionen
|
||||
|
||||
Der Collector laeuft unter:
|
||||
|
||||
```text
|
||||
http://localhost:18110
|
||||
```
|
||||
|
||||
Er verwaltet Quellen und Datenpunkte in einer JSON-Datei (`/app/data/sources.json`) und schreibt aktive Punkte zyklisch in MariaDB.
|
||||
|
||||
### Collector-Endpunkte
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/health` | Healthcheck |
|
||||
| `GET` | `/capabilities` | Unterstuetzte Protokolle/Funktionen |
|
||||
| `GET` | `/sources` | Quellen lesen |
|
||||
| `POST` | `/sources` | Quelle anlegen |
|
||||
| `PUT` | `/sources/:id` | Quelle bearbeiten |
|
||||
| `DELETE` | `/sources/:id` | Quelle loeschen |
|
||||
| `GET` | `/datapoints` | Datenpunkte lesen |
|
||||
| `POST` | `/datapoints/manual` | Datenpunkt manuell anlegen |
|
||||
| `PATCH` | `/datapoints/:id` | Datenpunkt bearbeiten, aktivieren, deaktivieren |
|
||||
| `POST` | `/datapoints/:id/read` | Live-Wert lesen |
|
||||
| `POST` | `/imports/modbus-csv` | Modbus-CSV importieren |
|
||||
| `POST` | `/imports/knx-xml` | KNX-XML importieren |
|
||||
| `POST` | `/scan/opcua` | OPC-UA-Scan starten |
|
||||
| `POST` | `/scan/bacnet` | BACnet-Scan starten |
|
||||
| `GET` | `/debug` | Debugdaten lesen |
|
||||
|
||||
### Schreibmodus
|
||||
|
||||
| Modus | Bedeutung |
|
||||
| --- | --- |
|
||||
| `interval` | Jeder Poll wird geschrieben |
|
||||
| `cov` | Nur bei Wertaenderung wird geschrieben |
|
||||
|
||||
### Aktive vs. inaktive Datenpunkte
|
||||
|
||||
- Inaktive Datenpunkte sind gefunden/importiert, werden aber nicht geloggt.
|
||||
- Aktive Datenpunkte werden vom Collector gepollt und in die zugehoerige Trendtabelle geschrieben.
|
||||
- In der Weboberflaeche entspricht das der Trennung zwischen „Gefunden / importiert“ und „Wird geloggt“.
|
||||
|
||||
---
|
||||
|
||||
## Datenbank und Speicher
|
||||
|
||||
### MariaDB
|
||||
|
||||
MariaDB speichert:
|
||||
|
||||
- `app_users`
|
||||
- bestehende `isp...` Tabellen
|
||||
- neue `trend_...` Tabellen
|
||||
- Registry-Tabellen fuer Quellen und Datenpunkte, falls vom Collector/API genutzt
|
||||
|
||||
Die API kann bestehende breite Tabellen mit `Wert1`, `Wert2`, ... lesen. Neue lokale Trendtabellen koennen bis zu 2000 Werte unterstuetzen.
|
||||
|
||||
### Redis
|
||||
|
||||
Redis speichert:
|
||||
|
||||
- Rollen
|
||||
- Einheiten
|
||||
- Aliase und Tabellen-Metadaten
|
||||
- Auswahlen
|
||||
- Dashboard-Konfiguration
|
||||
- Benutzerpraeferenzen
|
||||
|
||||
Redis sollte deshalb ebenfalls gebackupt werden, nicht nur MariaDB.
|
||||
|
||||
---
|
||||
|
||||
## WAGO- oder SD-Karten-Betrieb
|
||||
|
||||
Fuer Systeme, bei denen persistente Daten auf SD-Karte liegen sollen, gibt es ein Override:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.wago.yml up -d --build
|
||||
```
|
||||
|
||||
Dabei werden grosse Daten nach `/media/sd/seltd/...` gelegt:
|
||||
|
||||
```text
|
||||
/media/sd/seltd/mariadb
|
||||
/media/sd/seltd/redis
|
||||
/media/sd/seltd/collector
|
||||
/media/sd/seltd/dockhand
|
||||
```
|
||||
|
||||
Vorher sicherstellen:
|
||||
|
||||
```bash
|
||||
mkdir -p /media/sd/seltd/mariadb /media/sd/seltd/redis /media/sd/seltd/collector /media/sd/seltd/dockhand
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup und Restore
|
||||
|
||||
### MariaDB Backup
|
||||
|
||||
```powershell
|
||||
docker exec seltd-mariadb mariadb-dump -uroot -pSE3112 --all-databases > seltd-mariadb-backup.sql
|
||||
```
|
||||
|
||||
Restore:
|
||||
|
||||
```powershell
|
||||
type .\seltd-mariadb-backup.sql | docker exec -i seltd-mariadb mariadb -uroot -pSE3112
|
||||
```
|
||||
|
||||
### Redis Backup
|
||||
|
||||
Redis nutzt ein Docker-Volume. Fuer ein einfaches Dateibackup:
|
||||
|
||||
```powershell
|
||||
docker run --rm -v seltd_redis_data:/data -v ${PWD}:/backup alpine tar czf /backup/seltd-redis-backup.tar.gz -C /data .
|
||||
```
|
||||
|
||||
### Collector Backup
|
||||
|
||||
```powershell
|
||||
docker run --rm -v seltd_collector_data:/data -v ${PWD}:/backup alpine tar czf /backup/seltd-collector-backup.tar.gz -C /data .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment-Hinweise
|
||||
|
||||
### Nach Code-Aenderungen
|
||||
|
||||
Wenn Frontend oder API geaendert wurden:
|
||||
|
||||
```powershell
|
||||
docker compose build --no-cache web api collector
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Wenn nur `.env` geaendert wurde:
|
||||
|
||||
```powershell
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Browsercache
|
||||
|
||||
Bei alten JavaScript-Fehlern nach einem Fix:
|
||||
|
||||
```text
|
||||
Strg + F5
|
||||
```
|
||||
|
||||
oder Vite/Docker neu bauen.
|
||||
|
||||
### Nginx Proxy Manager
|
||||
|
||||
Wenn das Frontend hinter Nginx laeuft, muessen die API- und Collector-URLs sauber erreichbar sein. Entweder:
|
||||
|
||||
- Frontend, API und Collector unter eigenen Subdomains veroeffentlichen, oder
|
||||
- Nginx so konfigurieren, dass `/api` zur API und `/collector` zum Collector weitergeleitet wird.
|
||||
|
||||
In diesem Fall kann `VITE_API_URL` statt `VITE_API_PORT` gesetzt werden:
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://trend-api.example.local/api
|
||||
VITE_COLLECTOR_URL=https://trend-collector.example.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Fehler: `NetworkError when attempting to fetch resource`
|
||||
|
||||
Ursachen:
|
||||
|
||||
- API-Container laeuft nicht
|
||||
- falscher Port im Frontend
|
||||
- CORS nicht korrekt
|
||||
- Browser ruft alte Version auf
|
||||
|
||||
Pruefen:
|
||||
|
||||
```powershell
|
||||
docker ps
|
||||
curl http://localhost:18080/api/health
|
||||
```
|
||||
|
||||
Wenn Frontend lokal auf `localhost:5173` laeuft:
|
||||
|
||||
```env
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
Danach API neu starten:
|
||||
|
||||
```powershell
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
### Fehler: Browser zeigt alte React-Fehler nach Fix
|
||||
|
||||
Sehr wahrscheinlich wurde das Docker-Image nicht neu gebaut.
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
docker compose build --no-cache web api collector
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Danach im Browser `Strg + F5`.
|
||||
|
||||
### Fehler: `ReferenceError: editingCollectorPointId is not defined`
|
||||
|
||||
In `web/src/App.jsx` muss folgender State vorhanden sein:
|
||||
|
||||
```jsx
|
||||
const [editingCollectorPointId, setEditingCollectorPointId] = useState("");
|
||||
```
|
||||
|
||||
Wenn die Zeile vorhanden ist, der Fehler aber weiterhin erscheint, laeuft noch altes Build-/Container-Material.
|
||||
|
||||
### Fehler: `SyntaxError: missing ) after argument list` in `isp.js`
|
||||
|
||||
Bei SQL-Template-Strings muessen Backticks im String escaped werden:
|
||||
|
||||
```js
|
||||
missing.push(`ADD COLUMN \`${columnName}\` TEXT NULL`);
|
||||
```
|
||||
|
||||
### Fehler: `Rollup failed to resolve import "jspdf"`
|
||||
|
||||
Abhaengigkeiten im Frontend installieren:
|
||||
|
||||
```powershell
|
||||
cd web
|
||||
npm install jspdf xlsx recharts
|
||||
```
|
||||
|
||||
Bei Docker danach neu bauen.
|
||||
|
||||
### Source-Map-Warnungen im Browser
|
||||
|
||||
Warnungen wie diese sind in der Regel nicht kritisch:
|
||||
|
||||
```text
|
||||
Source map error: JSON.parse: unexpected character...
|
||||
```
|
||||
|
||||
Sie kommen oft aus Devtools/React-Devtools oder externen Source-Maps und koennen ignoriert werden, solange keine roten Laufzeitfehler auftreten.
|
||||
|
||||
### MariaDB Login oder DB-Verbindung fehlgeschlagen
|
||||
|
||||
Logs ansehen:
|
||||
|
||||
```powershell
|
||||
docker logs seltd-api --tail 100
|
||||
docker logs seltd-mariadb --tail 100
|
||||
```
|
||||
|
||||
Pruefen, ob MariaDB healthy ist:
|
||||
|
||||
```powershell
|
||||
docker ps
|
||||
```
|
||||
|
||||
### Redis-Probleme
|
||||
|
||||
```powershell
|
||||
docker exec seltd-redis redis-cli ping
|
||||
```
|
||||
|
||||
Erwartet:
|
||||
|
||||
```text
|
||||
PONG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nuetzliche Befehle
|
||||
|
||||
### Alle Container anzeigen
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Logs live verfolgen
|
||||
|
||||
```powershell
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Nur API:
|
||||
|
||||
```powershell
|
||||
docker logs seltd-api -f
|
||||
```
|
||||
|
||||
Nur Frontend:
|
||||
|
||||
```powershell
|
||||
docker logs seltd-web -f
|
||||
```
|
||||
|
||||
Nur Collector:
|
||||
|
||||
```powershell
|
||||
docker logs seltd-collector -f
|
||||
```
|
||||
|
||||
### Komplett neu starten
|
||||
|
||||
```powershell
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Komplett neu bauen
|
||||
|
||||
```powershell
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Volumes loeschen
|
||||
|
||||
Achtung: Dadurch gehen Daten verloren.
|
||||
|
||||
```powershell
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheits-Checkliste
|
||||
|
||||
Vor produktiver Nutzung:
|
||||
|
||||
- [ ] `JWT_SECRET` auf langen zufaelligen Wert setzen
|
||||
- [ ] Admin-Passwort aendern
|
||||
- [ ] MariaDB-Root-Passwort aendern
|
||||
- [ ] `CORS_ORIGIN` nicht auf `*` lassen, sondern feste Domain setzen
|
||||
- [ ] MariaDB-Port `3306` nicht ungeschuetzt ins Internet veroeffentlichen
|
||||
- [ ] Redis-Port `6379` nicht ungeschuetzt ins Internet veroeffentlichen
|
||||
- [ ] Dockhand absichern oder nur intern bereitstellen
|
||||
- [ ] Backups fuer MariaDB, Redis und Collector-Daten einrichten
|
||||
- [ ] Reverse Proxy mit HTTPS verwenden
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Hinweise
|
||||
|
||||
- Leere Trendtabellen erscheinen nicht in der Trendauswahl, aber im Admin-Bereich.
|
||||
- Bestehende breite `isp...` Tabellen bleiben kompatibel.
|
||||
- Neue lokale Tabellen koennen als `trend_...` Tabellen angelegt werden.
|
||||
- Der Collector speichert seine Quellen/Datenpunkte aktuell dateibasiert in `sources.json`.
|
||||
- Fuer vollstaendige Protokollintegration muessen je nach Feldbus noch Details wie Adressierung, Datentyp, Byteorder und Authentifizierung sauber projektspezifisch gepflegt werden.
|
||||
|
||||
---
|
||||
|
||||
## Kurzfassung fuer Betrieb
|
||||
|
||||
```powershell
|
||||
cd C:\Projects\SeLTD
|
||||
copy .env.example .env
|
||||
notepad .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Dann aufrufen:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
Login:
|
||||
|
||||
```text
|
||||
admin / ChangeMe123!
|
||||
```
|
||||
|
||||
Bei Code-Aenderungen:
|
||||
|
||||
```powershell
|
||||
docker compose build --no-cache web api collector
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
+162
-33
@@ -242,8 +242,8 @@ function parseModbusCsv(payload) {
|
||||
protocol: "modbus-tcp",
|
||||
address,
|
||||
pointIndex,
|
||||
name: get("name", `Register ${index + 1}`),
|
||||
alias: get("alias", get("name", `Register ${index + 1}`)),
|
||||
name: normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
|
||||
alias: normalizeText(get("alias", get("name", `Register ${index + 1}`))) || normalizeText(get("name", `Register ${index + 1}`)) || `Register ${index + 1}`,
|
||||
unit: normalizeUnit(get("unit")),
|
||||
dataType: get("datatype", "holding-register"),
|
||||
kind: normalizePointKind(get("kind", get("type", "analog"))),
|
||||
@@ -282,8 +282,8 @@ function parseKnxXml(payload) {
|
||||
};
|
||||
|
||||
const address = getAttr("Address") || getAttr("address");
|
||||
const name = getAttr("Name") || getAttr("name") || `KNX ${index + 1}`;
|
||||
const dpt = getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || "";
|
||||
const name = normalizeText(getAttr("Name") || getAttr("name") || `KNX ${index + 1}`) || `KNX ${index + 1}`;
|
||||
const dpt = normalizeText(getAttr("DatapointType") || getAttr("DPTs") || getAttr("DPT") || "");
|
||||
|
||||
if (address) {
|
||||
datapoints.push({
|
||||
@@ -462,29 +462,71 @@ function tryTcpConnect(host, portNumber, timeout = 1200) {
|
||||
}
|
||||
|
||||
function inferModbusAddress(datapoint) {
|
||||
const rawAddress = Number(String(datapoint.address || datapoint.register || "").replace(/[^0-9]/g, ""));
|
||||
const addressText = String(datapoint.address || datapoint.register || "").trim();
|
||||
const rawAddress = Number(addressText.replace(/[^0-9]/g, ""));
|
||||
const dataType = String(datapoint.dataType || "").toLowerCase();
|
||||
const pointIndex = normalizePointIndex(datapoint.pointIndex, 1);
|
||||
const raw = Number.isFinite(rawAddress) && rawAddress > 0 ? rawAddress : pointIndex;
|
||||
let functionCode = dataType.includes("coil") ? 1 : dataType.includes("discrete") ? 2 : dataType.includes("input") ? 4 : 3;
|
||||
let address = Math.max(0, raw - 1);
|
||||
|
||||
if (raw >= 40001) {
|
||||
functionCode = 3;
|
||||
if (functionCode === 3 && raw >= 40001 && raw <= 49999) {
|
||||
address = raw - 40001;
|
||||
} else if (raw >= 30001) {
|
||||
functionCode = 4;
|
||||
} else if (functionCode === 4 && raw >= 30001 && raw <= 39999) {
|
||||
address = raw - 30001;
|
||||
} else if (raw >= 10001) {
|
||||
functionCode = 2;
|
||||
} else if (functionCode === 2 && raw >= 10001 && raw <= 19999) {
|
||||
address = raw - 10001;
|
||||
} else if (raw <= 9999 && functionCode > 2) {
|
||||
} else if (!addressText.match(/^[134]\d{4,}$/) && raw <= 9999 && functionCode > 2) {
|
||||
address = raw - 1;
|
||||
}
|
||||
|
||||
return { functionCode, address };
|
||||
}
|
||||
|
||||
function swapModbusWords(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
||||
return buffer;
|
||||
}
|
||||
return Buffer.from([buffer[2], buffer[3], buffer[0], buffer[1]]);
|
||||
}
|
||||
|
||||
function resolveModbusReadDetails(datapoint) {
|
||||
const dataType = String(datapoint.dataType || "").toLowerCase();
|
||||
const scale = Number(datapoint.scale || 1) || 1;
|
||||
const quantity = /(?:float32|real|int32|uint32|dint|udint)/.test(dataType) ? 2 : 1;
|
||||
const wantsSwap = /(?:swap|swapped|cdab|badc)/.test(dataType);
|
||||
|
||||
return {
|
||||
quantity,
|
||||
decode(responseBuffer, functionCode) {
|
||||
if (functionCode === 1 || functionCode === 2) {
|
||||
return (responseBuffer.readUInt8(0) & 0x01) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (responseBuffer.length < quantity * 2) {
|
||||
throw new Error("Modbus Antwort enth\u00e4lt zu wenige Register.");
|
||||
}
|
||||
|
||||
const baseBuffer = responseBuffer.subarray(0, quantity * 2);
|
||||
const buffer = wantsSwap && quantity === 2 ? swapModbusWords(baseBuffer) : baseBuffer;
|
||||
|
||||
if (/(?:float32|real)/.test(dataType)) {
|
||||
return buffer.readFloatBE(0) * scale;
|
||||
}
|
||||
if (/(?:uint32|udint)/.test(dataType)) {
|
||||
return buffer.readUInt32BE(0) * scale;
|
||||
}
|
||||
if (/(?:int32|dint)/.test(dataType)) {
|
||||
return buffer.readInt32BE(0) * scale;
|
||||
}
|
||||
if (/(?:int16|signed)/.test(dataType)) {
|
||||
return buffer.readInt16BE(0) * scale;
|
||||
}
|
||||
return buffer.readUInt16BE(0) * scale;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readModbusTcp(source, datapoint) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const host = String(source.host || "").trim();
|
||||
@@ -495,6 +537,7 @@ function readModbusTcp(source, datapoint) {
|
||||
}
|
||||
|
||||
const { functionCode, address } = inferModbusAddress(datapoint);
|
||||
const { quantity, decode } = resolveModbusReadDetails(datapoint);
|
||||
const unitId = Number(source.deviceId || 1) & 0xff;
|
||||
const currentTransaction = transactionId;
|
||||
transactionId = transactionId >= 0xffff ? 1 : transactionId + 1;
|
||||
@@ -506,7 +549,7 @@ function readModbusTcp(source, datapoint) {
|
||||
request.writeUInt8(unitId, 6);
|
||||
request.writeUInt8(functionCode, 7);
|
||||
request.writeUInt16BE(address, 8);
|
||||
request.writeUInt16BE(1, 10);
|
||||
request.writeUInt16BE(quantity, 10);
|
||||
|
||||
const socket = new net.Socket();
|
||||
const chunks = [];
|
||||
@@ -528,6 +571,11 @@ function readModbusTcp(source, datapoint) {
|
||||
socket.setTimeout(1800);
|
||||
socket.once("timeout", () => finish(new Error("Modbus Timeout.")));
|
||||
socket.once("error", (error) => finish(error));
|
||||
socket.once("close", (hadError) => {
|
||||
if (!settled) {
|
||||
finish(new Error(hadError ? "Modbus Verbindung wurde fehlerhaft geschlossen." : "Modbus Verbindung wurde ohne Antwort geschlossen."));
|
||||
}
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
const response = Buffer.concat(chunks);
|
||||
@@ -551,29 +599,23 @@ function readModbusTcp(source, datapoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Number(datapoint.scale || 1) || 1;
|
||||
if (functionCode === 1 || functionCode === 2) {
|
||||
const digital = (response.readUInt8(9) & 0x01) ? 1 : 0;
|
||||
finish(null, digital);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.length < 11) {
|
||||
const byteCount = Number(response.readUInt8(8) || 0);
|
||||
const startOffset = 9;
|
||||
if (response.length < startOffset + byteCount) {
|
||||
finish(new Error("Modbus Antwort zu kurz."));
|
||||
return;
|
||||
}
|
||||
|
||||
const dataType = String(datapoint.dataType || "").toLowerCase();
|
||||
const raw = dataType.includes("int16") || dataType.includes("signed")
|
||||
? response.readInt16BE(9)
|
||||
: response.readUInt16BE(9);
|
||||
finish(null, raw * scale);
|
||||
try {
|
||||
finish(null, decode(response.subarray(startOffset, startOffset + byteCount), functionCode));
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
|
||||
socket.connect(portNumber, host, () => socket.write(request));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOptionalPackage(packageName) {
|
||||
try {
|
||||
const module = await import(packageName);
|
||||
@@ -870,11 +912,87 @@ async function readBacnetValue(source, datapoint) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeKnxDatapointType(dataType) {
|
||||
const raw = String(dataType || "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
const match = raw.match(/(?:dpt\s*)?(\d+)(?:[.-](\d+))?/i);
|
||||
if (!match) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
|
||||
function decodeKnxPayload(value, dataType) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && !Buffer.isBuffer(value)) {
|
||||
if (Array.isArray(value.data)) {
|
||||
return decodeKnxPayload(Buffer.from(value.data), dataType);
|
||||
}
|
||||
if (Array.isArray(value.buffer)) {
|
||||
return decodeKnxPayload(Buffer.from(value.buffer), dataType);
|
||||
}
|
||||
if ("value" in value && value.value !== value) {
|
||||
return decodeKnxPayload(value.value, dataType);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedType = normalizeKnxDatapointType(dataType);
|
||||
const mainType = normalizedType.split(/[.-]/)[0];
|
||||
const buffer = Buffer.isBuffer(value)
|
||||
? value
|
||||
: value instanceof Uint8Array
|
||||
? Buffer.from(value)
|
||||
: Array.isArray(value)
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(String(value), "binary");
|
||||
|
||||
if (mainType === "1") {
|
||||
return (buffer[buffer.length - 1] & 0x01) ? 1 : 0;
|
||||
}
|
||||
if (mainType === "5") {
|
||||
return buffer[buffer.length - 1] || 0;
|
||||
}
|
||||
if (mainType === "9" && buffer.length >= 2) {
|
||||
const hi = buffer[0];
|
||||
const lo = buffer[1];
|
||||
const sign = (hi & 0x80) ? -1 : 1;
|
||||
const exponent = (hi & 0x78) >> 3;
|
||||
let mantissa = ((hi & 0x07) << 8) | lo;
|
||||
if (sign === -1) {
|
||||
mantissa = -(~(mantissa - 1) & 0x07ff);
|
||||
}
|
||||
return 0.01 * mantissa * Math.pow(2, exponent);
|
||||
}
|
||||
if (mainType === "13" && buffer.length >= 4) {
|
||||
return buffer.readInt32BE(0);
|
||||
}
|
||||
if (mainType === "14" && buffer.length >= 4) {
|
||||
return buffer.readFloatBE(0);
|
||||
}
|
||||
|
||||
const numeric = Number(String(value).replace(",", "."));
|
||||
if (Number.isFinite(numeric)) {
|
||||
return numeric;
|
||||
}
|
||||
return repairText(String(value));
|
||||
}
|
||||
|
||||
async function readKnxValue(source, datapoint) {
|
||||
const knx = await loadOptionalPackage("knx");
|
||||
const groupAddress = String(datapoint.address || "").trim();
|
||||
if (!groupAddress) {
|
||||
throw new Error(`KNX Gruppenadresse fehlt für ${datapoint.alias || datapoint.name}.`);
|
||||
throw new Error(`KNX Gruppenadresse fehlt f\u00fcr ${datapoint.alias || datapoint.name}.`);
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -890,8 +1008,13 @@ async function readKnxValue(source, datapoint) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
const numeric = Number(value);
|
||||
resolve(typeof value === "boolean" ? (value ? 1 : 0) : Number.isFinite(numeric) ? numeric : String(value || ""));
|
||||
const decoded = decodeKnxPayload(value, datapoint.dataType);
|
||||
if (typeof decoded === "number" && Number.isFinite(decoded)) {
|
||||
const scale = Number(datapoint.scale || 1) || 1;
|
||||
resolve(decoded * scale);
|
||||
} else {
|
||||
resolve(decoded === null ? "" : decoded);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -917,7 +1040,6 @@ async function readKnxValue(source, datapoint) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function matchesLogCondition(condition, value) {
|
||||
const text = String(condition || "").trim();
|
||||
if (!text) {
|
||||
@@ -1399,7 +1521,7 @@ async function pollSource(source, datapoints) {
|
||||
updateSourceState(source.id, {
|
||||
lastPollAt: new Date().toISOString(),
|
||||
lastPollStatus: "unsupported",
|
||||
lastPollError: `${source.protocol} wird vom Collector nicht unterst?tzt.`,
|
||||
lastPollError: `${source.protocol} wird vom Collector nicht unterst\u00fctzt.`,
|
||||
});
|
||||
} catch (error) {
|
||||
updateSourceState(source.id, {
|
||||
@@ -1577,10 +1699,14 @@ const server = createServer(async (req, res) => {
|
||||
store.datapoints[index] = {
|
||||
...store.datapoints[index],
|
||||
enabled: typeof body.enabled === "boolean" ? body.enabled : store.datapoints[index].enabled,
|
||||
writeMode: body.writeMode ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode,
|
||||
writeMode: typeof body.writeMode === "string" ? normalizeWriteMode(body.writeMode) : store.datapoints[index].writeMode,
|
||||
condition: typeof body.condition === "string" ? body.condition.trim() : store.datapoints[index].condition,
|
||||
pointIndex: body.pointIndex ? normalizePointIndex(body.pointIndex, store.datapoints[index].pointIndex) : store.datapoints[index].pointIndex,
|
||||
alias: typeof body.alias === "string" && body.alias.trim() ? body.alias.trim() : store.datapoints[index].alias,
|
||||
name: typeof body.name === "string" && body.name.trim() ? body.name.trim() : store.datapoints[index].name,
|
||||
address: typeof body.address === "string" ? normalizeText(body.address) : store.datapoints[index].address,
|
||||
nodeId: typeof body.nodeId === "string" ? normalizeText(body.nodeId) : store.datapoints[index].nodeId,
|
||||
dataType: typeof body.dataType === "string" && body.dataType.trim() ? normalizeText(body.dataType) : store.datapoints[index].dataType,
|
||||
unit: body.unit ? normalizeUnit(body.unit) : store.datapoints[index].unit,
|
||||
kind: body.kind ? normalizePointKind(body.kind) : store.datapoints[index].kind,
|
||||
scale: Number.isFinite(Number(body.scale)) ? Number(body.scale) : store.datapoints[index].scale,
|
||||
@@ -1740,3 +1866,6 @@ server.listen(port, () => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -99,7 +99,7 @@ services:
|
||||
|
||||
dockhand:
|
||||
image: fnsys/dockhand:latest
|
||||
container_name: dockhand
|
||||
container_name: seltd-dockhand
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -108,7 +108,7 @@ services:
|
||||
- dockhand_data:/app/data
|
||||
cadvisor:
|
||||
image: ghcr.io/google/cadvisor:v0.60.2
|
||||
container_name: cadvisor
|
||||
container_name: seltd-cadvisor
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
|
||||
|
||||
+349
-157
@@ -20,8 +20,43 @@ const adminSections = [
|
||||
{ id: "debug", label: "Debug" },
|
||||
];
|
||||
|
||||
const TREND_PRESET_OPTIONS = [
|
||||
{ value: "0.0034722222", label: "5 min" },
|
||||
{ value: "0.0069444444", label: "10 min" },
|
||||
{ value: "0.0208333333", label: "30 min" },
|
||||
{ value: "0.0416666667", label: "1 h" },
|
||||
{ value: "0.0833333333", label: "2 h" },
|
||||
{ value: "0.25", label: "6 h" },
|
||||
{ value: "0.5", label: "12 h" },
|
||||
{ value: "1", label: "24 h" },
|
||||
{ value: "2", label: "2 Tage" },
|
||||
{ value: "3", label: "3 Tage" },
|
||||
{ value: "7", label: "7 Tage" },
|
||||
{ value: "14", label: "14 Tage" },
|
||||
{ value: "30", label: "30 Tage" },
|
||||
];
|
||||
|
||||
const SCALE_PRESET_OPTIONS = ["1", "0.1", "0.01", "0.001", "10", "100", "1000"];
|
||||
|
||||
const MODBUS_DATA_TYPE_OPTIONS = [
|
||||
{ value: "coil", label: "Coil" },
|
||||
{ value: "discrete-input", label: "Discrete Input" },
|
||||
{ value: "input-register", label: "Input Register (uint16)" },
|
||||
{ value: "input-register-int16", label: "Input Register (int16)" },
|
||||
{ value: "input-register-int32", label: "Input Register (int32)" },
|
||||
{ value: "input-register-uint32", label: "Input Register (uint32)" },
|
||||
{ value: "input-register-float32", label: "Input Register (float32)" },
|
||||
{ value: "input-register-float32-swap", label: "Input Register (float32, Word-Swap)" },
|
||||
{ value: "holding-register", label: "Holding Register (uint16)" },
|
||||
{ value: "holding-register-int16", label: "Holding Register (int16)" },
|
||||
{ value: "holding-register-int32", label: "Holding Register (int32)" },
|
||||
{ value: "holding-register-uint32", label: "Holding Register (uint32)" },
|
||||
{ value: "holding-register-float32", label: "Holding Register (float32)" },
|
||||
{ value: "holding-register-float32-swap", label: "Holding Register (float32, Word-Swap)" },
|
||||
];
|
||||
|
||||
const emptyTrendRange = {
|
||||
presetDays: 1,
|
||||
presetDays: "1",
|
||||
from: "",
|
||||
to: "",
|
||||
maxRows: 4000,
|
||||
@@ -129,6 +164,13 @@ function getDefaultPointDataType(protocol) {
|
||||
return "holding-register";
|
||||
}
|
||||
|
||||
function getPointDataTypeOptions(protocol) {
|
||||
if (protocol === "modbus-tcp") {
|
||||
return MODBUS_DATA_TYPE_OPTIONS;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getOpcuaScanDatapointId(sourceId, node) {
|
||||
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
|
||||
}
|
||||
@@ -427,6 +469,33 @@ function UnitField({ units, value, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ScaleField({ value, onChange }) {
|
||||
const normalizedValue = String(value ?? "1");
|
||||
const selectedValue = SCALE_PRESET_OPTIONS.includes(normalizedValue) ? normalizedValue : "__custom__";
|
||||
|
||||
return (
|
||||
<div className="unit-field">
|
||||
<select
|
||||
value={selectedValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (nextValue === "__custom__") {
|
||||
onChange(SCALE_PRESET_OPTIONS.includes(normalizedValue) ? "" : normalizedValue);
|
||||
return;
|
||||
}
|
||||
onChange(nextValue);
|
||||
}}
|
||||
>
|
||||
{SCALE_PRESET_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
<option value="__custom__">Benutzerdefiniert</option>
|
||||
</select>
|
||||
{selectedValue === "__custom__" ? (
|
||||
<input value={value || ""} placeholder="Eigener Faktor" onChange={(event) => onChange(event.target.value)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({ value, onChange }) {
|
||||
const normalizedValue = hslToHexString(value || "");
|
||||
const pickerValue = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(normalizedValue || "") ? normalizedValue : "#d62839";
|
||||
@@ -475,8 +544,8 @@ function IconButton({ title, onClick, children, danger = false }) {
|
||||
}
|
||||
|
||||
function LoginScreen({ onLogin, error, busy }) {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("ChangeMe123!");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -637,6 +706,7 @@ export default function App() {
|
||||
const [units, setUnits] = useState(DEFAULT_UNIT_OPTIONS);
|
||||
const [unitDraft, setUnitDraft] = useState({ symbol: "", label: "" });
|
||||
const [editingUnitSymbol, setEditingUnitSymbol] = useState("");
|
||||
const [editingCollectorPointId, setEditingCollectorPointId] = useState("");
|
||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||
const [isCreatingSource, setIsCreatingSource] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
|
||||
@@ -681,6 +751,40 @@ export default function App() {
|
||||
const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]);
|
||||
const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]);
|
||||
const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]);
|
||||
const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol;
|
||||
|
||||
const upsertIspEntry = (entry) => {
|
||||
if (!entry?.tableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsps((current) => {
|
||||
const map = new Map(current.map((item) => [item.tableName, item]));
|
||||
const previous = map.get(entry.tableName) || {};
|
||||
map.set(entry.tableName, {
|
||||
...previous,
|
||||
...entry,
|
||||
tableName: entry.tableName,
|
||||
displayName: entry.displayName || previous.displayName || entry.tableName,
|
||||
description: entry.description ?? previous.description ?? "",
|
||||
storage: entry.storage || previous.storage || (String(entry.tableName).startsWith("trend_") ? "narrow" : "wide"),
|
||||
pointCount: entry.pointCount ?? previous.pointCount ?? 0,
|
||||
rowCount: entry.rowCount ?? previous.rowCount ?? 0,
|
||||
latestTimestamp: entry.latestTimestamp ?? previous.latestTimestamp ?? null,
|
||||
});
|
||||
return Array.from(map.values()).sort((left, right) => left.tableName.localeCompare(right.tableName));
|
||||
});
|
||||
};
|
||||
|
||||
const refreshIspCatalog = async () => {
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const result = await api.getIsps(token);
|
||||
const nextIsps = result.isps || [];
|
||||
setIsps(nextIsps);
|
||||
return nextIsps;
|
||||
};
|
||||
const filteredOpcuaNodes = useMemo(
|
||||
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
|
||||
node.displayName,
|
||||
@@ -767,7 +871,7 @@ export default function App() {
|
||||
setThemeMode(pref.theme || "system");
|
||||
setCsvDelimiter(pref.csvDelimiter || ";");
|
||||
setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true);
|
||||
setIsps(ispResult.isps);
|
||||
setIsps(ispResult.isps || []);
|
||||
setSelections(selectionResult.selections);
|
||||
setDashboard(dashboardResult);
|
||||
setActiveView("dashboard");
|
||||
@@ -940,6 +1044,33 @@ export default function App() {
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canEditAliases, canManageSources, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
const refreshIsps = () => {
|
||||
refreshIspCatalog().catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshIsps();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(refreshIsps, 5000);
|
||||
window.addEventListener("focus", refreshIsps);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
ignore = true;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refreshIsps);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
|
||||
setCollectorCapabilities(null);
|
||||
@@ -1382,8 +1513,7 @@ export default function App() {
|
||||
try {
|
||||
const sql = await file.text();
|
||||
await api.importSql(token, { filename: file.name, sql });
|
||||
const refreshed = await api.getIsps(token);
|
||||
setIsps(refreshed.isps);
|
||||
await refreshIspCatalog();
|
||||
setSuccessMessage("SQL-Datei importiert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message || "SQL-Datei konnte nicht importiert werden.");
|
||||
@@ -1401,8 +1531,15 @@ export default function App() {
|
||||
resetFlash();
|
||||
try {
|
||||
const result = await api.createTrendTable(token, newTableDraft);
|
||||
const refreshed = await api.getIsps(token);
|
||||
setIsps(refreshed.isps);
|
||||
upsertIspEntry({
|
||||
tableName: result.tableName,
|
||||
displayName: result.metadata?.displayName || newTableDraft.displayName || result.tableName,
|
||||
description: result.metadata?.description || "",
|
||||
storage: "narrow",
|
||||
pointCount: 0,
|
||||
rowCount: 0,
|
||||
});
|
||||
await refreshIspCatalog().catch(() => undefined);
|
||||
setAdminIsp(result.tableName);
|
||||
setNewTableDraft({ tableName: "", displayName: "" });
|
||||
setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`);
|
||||
@@ -1475,10 +1612,17 @@ export default function App() {
|
||||
displayName: payload.displayName,
|
||||
});
|
||||
|
||||
upsertIspEntry({
|
||||
tableName: payload.tableName,
|
||||
displayName: payload.displayName,
|
||||
description: "",
|
||||
storage: "narrow",
|
||||
pointCount: 0,
|
||||
rowCount: 0,
|
||||
});
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
const refreshed = await api.getIsps(token);
|
||||
setIsps(refreshed.isps);
|
||||
await refreshIspCatalog().catch(() => undefined);
|
||||
setIsCreatingSource(false);
|
||||
setSelectedSourceId(saved.id);
|
||||
setSourceModalOpen(false);
|
||||
@@ -1520,6 +1664,26 @@ export default function App() {
|
||||
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
|
||||
};
|
||||
|
||||
const resetManualDatapointEditor = (protocol = sourceDraft.protocol || defaultSourceDraft.protocol) => {
|
||||
setEditingCollectorPointId("");
|
||||
setManualDatapointDraft(emptyManualDatapointDraft(protocol));
|
||||
};
|
||||
|
||||
const editCollectorPoint = (datapoint) => {
|
||||
setEditingCollectorPointId(datapoint.id);
|
||||
setManualDatapointDraft({
|
||||
alias: datapoint.alias || datapoint.name || "",
|
||||
address: datapoint.address || datapoint.nodeId || "",
|
||||
pointIndex: String(datapoint.pointIndex || ""),
|
||||
unit: normalizeDisplayUnit(datapoint.unit || "°C"),
|
||||
dataType: datapoint.dataType || getDefaultPointDataType(sourceDraft.protocol),
|
||||
kind: datapoint.kind || "analog",
|
||||
scale: String(datapoint.scale ?? "1"),
|
||||
writeMode: datapoint.writeMode === "cov" ? "cov" : datapoint.writeMode || "",
|
||||
condition: datapoint.condition || "",
|
||||
});
|
||||
};
|
||||
|
||||
const saveManualCollectorDatapoint = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
if (!activeSourceId) {
|
||||
@@ -1529,7 +1693,7 @@ export default function App() {
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
await api.createCollectorDatapoint({
|
||||
const payload = {
|
||||
sourceId: activeSourceId,
|
||||
protocol: sourceDraft.protocol,
|
||||
alias: manualDatapointDraft.alias,
|
||||
@@ -1543,11 +1707,17 @@ export default function App() {
|
||||
scale: manualDatapointDraft.scale,
|
||||
writeMode: manualDatapointDraft.writeMode,
|
||||
condition: manualDatapointDraft.condition,
|
||||
});
|
||||
};
|
||||
|
||||
if (editingCollectorPointId) {
|
||||
await api.updateCollectorDatapoint(editingCollectorPointId, payload);
|
||||
} else {
|
||||
await api.createCollectorDatapoint(payload);
|
||||
}
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol));
|
||||
setSuccessMessage("Datenpunkt angelegt.");
|
||||
resetManualDatapointEditor(sourceDraft.protocol);
|
||||
setSuccessMessage(editingCollectorPointId ? "Datenpunkt aktualisiert." : "Datenpunkt angelegt.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
@@ -1864,8 +2034,10 @@ export default function App() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tage</span>
|
||||
<input type="number" min="1" max="365" value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))} />
|
||||
<span>Zeitraum</span>
|
||||
<select value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))}>
|
||||
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{widgetDraft.source === "saved" ? (
|
||||
<label className="span-two">
|
||||
@@ -2022,8 +2194,10 @@ export default function App() {
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Tage</span>
|
||||
<input type="number" min="1" max="365" value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))} />
|
||||
<span>Zeitraum</span>
|
||||
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
|
||||
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Max. Zeilen</span>
|
||||
@@ -2205,7 +2379,7 @@ export default function App() {
|
||||
<div className="alias-index">Wert {point.pointIndex}</div>
|
||||
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
|
||||
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
|
||||
<input type="number" step="0.01" value={point.factor} onChange={(event) => updateAliasPoint(String(point.pointIndex), { factor: event.target.value })} />
|
||||
<ScaleField value={String(point.factor ?? "1")} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { factor: nextValue })} />
|
||||
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value })}>
|
||||
<option value="analog">analog</option>
|
||||
<option value="digital">digital</option>
|
||||
@@ -2294,45 +2468,149 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="protocol-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Protokollquellen</h3>
|
||||
<p>{collectorCapabilities?.protocols?.join(" · ") || "Modbus TCP · OPC UA · BACnet IP · KNX IP"}</p>
|
||||
<div className="source-admin-top">
|
||||
<div className="protocol-panel source-overview-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Protokollquellen</h3>
|
||||
<p>{collectorCapabilities?.protocols?.join(" | ") || "Modbus TCP | OPC UA | BACnet IP | KNX IP"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
|
||||
{collectorSources.map((source) => {
|
||||
const isActive = source.id === selectedSourceId;
|
||||
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
|
||||
onClick={() => {
|
||||
setIsCreatingSource(false);
|
||||
setSelectedSourceId(source.id);
|
||||
}}
|
||||
>
|
||||
<div className="source-tile-copy">
|
||||
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
|
||||
<span>{`${source.protocol} | ${normalizeDisplayText(source.tableName)}`}</span>
|
||||
<small>{`${String(source.pollIntervalSeconds || 60)}s | ${pointCount} DP`}</small>
|
||||
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
|
||||
</div>
|
||||
<div className="source-tile-actions">
|
||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
||||
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>L?schen</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. ?ber <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
|
||||
{collectorSources.map((source) => {
|
||||
const isActive = source.id === selectedSourceId;
|
||||
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
|
||||
onClick={() => {
|
||||
setIsCreatingSource(false);
|
||||
setSelectedSourceId(source.id);
|
||||
}}
|
||||
>
|
||||
<div className="source-tile-copy">
|
||||
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
|
||||
<span>{`${source.protocol} · ${normalizeDisplayText(source.tableName)}`}</span>
|
||||
<small>{`${String(source.pollIntervalSeconds || 60)}s · ${pointCount} DP`}</small>
|
||||
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
|
||||
</div>
|
||||
<div className="source-tile-actions">
|
||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
||||
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. Über <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
|
||||
<div className="protocol-panel source-settings-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Datenpunkte & Werkzeuge</h3>
|
||||
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
|
||||
</div>
|
||||
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")}</small>
|
||||
</div>
|
||||
|
||||
<div className="protocol-grid source-tools-grid">
|
||||
<div className="protocol-block">
|
||||
<strong>Import / Vorlagen</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
<label className="ghost-button compact-button file-button">
|
||||
<span>Modbus CSV</span>
|
||||
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
|
||||
</label>
|
||||
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
|
||||
<label className="ghost-button compact-button file-button">
|
||||
<span>KNX XML</span>
|
||||
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
|
||||
</label>
|
||||
</div>
|
||||
<small className="muted">SQL-Import bleibt oben. Bei Modbus kommt der Function Code jetzt aus dem Dropdown Datenart am Datenpunkt.</small>
|
||||
</div>
|
||||
|
||||
<div className="protocol-block">
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
</div>
|
||||
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
|
||||
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Ger?t(e) gefunden` : "F?r mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
|
||||
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">F?r 192.168.153.50 ist Input Register / FC4 best?tigt. Den Registertyp w?hlst du pro Datenpunkt ?ber Datenart.</small> : null}
|
||||
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageSources ? (
|
||||
<div className="protocol-block manual-point-card source-settings-card">
|
||||
<strong>Einzelnen Datenpunkt anlegen</strong>
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{getManualAddressLabel(sourceToolProtocol)}</span>
|
||||
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Wertnummer</span>
|
||||
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Typ</span>
|
||||
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
|
||||
<option value="analog">analog</option>
|
||||
<option value="digital">digital</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Datenart</span>
|
||||
{sourceToolProtocol === "modbus-tcp" ? (
|
||||
<select value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))}>
|
||||
{getPointDataTypeOptions(sourceToolProtocol).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>Einheit</span>
|
||||
<UnitField units={availableUnits} value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Faktor</span>
|
||||
<ScaleField value={String(manualDatapointDraft.scale ?? "1")} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, scale: nextValue }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Schreibmodus</span>
|
||||
<select value={manualDatapointDraft.writeMode} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, writeMode: event.target.value }))}>
|
||||
<option value="">Default der Quelle</option>
|
||||
<option value="interval">Jeden Poll</option>
|
||||
<option value="cov">Nur bei ?nderung (COV)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="span-two">
|
||||
<span>Bedingung / Notiz</span>
|
||||
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage l?uft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row wrap-tight">
|
||||
{editingCollectorPointId ? <button className="ghost-button compact-button" type="button" onClick={() => resetManualDatapointEditor(sourceToolProtocol)}>Abbrechen</button> : null}
|
||||
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>{editingCollectorPointId ? "Datenpunkt speichern" : "Datenpunkt anlegen"}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sourceModalOpen ? (
|
||||
<div className="modal-backdrop" onClick={closeSourceEditor}>
|
||||
@@ -2342,7 +2620,7 @@ export default function App() {
|
||||
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
|
||||
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
|
||||
</div>
|
||||
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
|
||||
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schlie?en</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
@@ -2368,7 +2646,7 @@ export default function App() {
|
||||
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Geräte-ID</span>
|
||||
<span>Ger?te-ID</span>
|
||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||
</label>
|
||||
<label className="span-two">
|
||||
@@ -2379,7 +2657,7 @@ export default function App() {
|
||||
<span>Schreibmodus</span>
|
||||
<select value={sourceDraft.writeMode} onChange={(event) => setSourceDraft((current) => ({ ...current, writeMode: event.target.value }))}>
|
||||
<option value="interval">Jeden Poll schreiben</option>
|
||||
<option value="cov">Nur bei Änderung (COV)</option>
|
||||
<option value="cov">Nur bei ?nderung (COV)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -2388,11 +2666,11 @@ export default function App() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
|
||||
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf.</small>
|
||||
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
|
||||
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
|
||||
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle l?schen</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2414,46 +2692,6 @@ export default function App() {
|
||||
onChange={(event) => setCollectorPointFilter(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="protocol-grid source-tools-grid">
|
||||
<div className="protocol-block">
|
||||
<strong>Importe</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
<label className="ghost-button compact-button file-button">
|
||||
<span>Modbus CSV</span>
|
||||
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
|
||||
</label>
|
||||
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
|
||||
<label className="ghost-button compact-button file-button">
|
||||
<span>KNX XML</span>
|
||||
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
|
||||
</label>
|
||||
</div>
|
||||
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}</small>
|
||||
</div>
|
||||
|
||||
{sourceDraft.protocol === "opc-ua" ? (
|
||||
<div className="protocol-block">
|
||||
<strong>OPC UA</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button>
|
||||
</div>
|
||||
<small className="muted">Verwendet Host und Port der oben ausgewählten Quelle.</small>
|
||||
{scanResults.opcua ? <small className="muted">{scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar"}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<div className="protocol-block">
|
||||
<strong>BACnet IP</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button>
|
||||
</div>
|
||||
<small className="muted">Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden.</small>
|
||||
{scanResults.bacnet ? <small className="muted">{`${scanResults.bacnet.devices?.length || 0} Gerät(e)`}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="scan-result-list">
|
||||
{filteredOpcuaNodes.slice(0, 160).map((node, index) => {
|
||||
const pointId = getOpcuaScanDatapointId(selectedSourceId, node);
|
||||
@@ -2482,7 +2720,7 @@ export default function App() {
|
||||
<div key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive">
|
||||
<div className="datapoint-main">
|
||||
<strong>{normalizeDisplayText(point.alias || point.name)}</strong>
|
||||
<span>{`${normalizeDisplayText(point.host)} · ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
|
||||
<span>{`${normalizeDisplayText(point.host)} ? ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
|
||||
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
|
||||
</div>
|
||||
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
|
||||
@@ -2503,11 +2741,12 @@ export default function App() {
|
||||
<span className="datapoint-value">{item.pointIndex}</span>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
|
||||
<button className="primary-button compact-button" type="button" disabled={item.enabled === true} onClick={() => updateCollectorPoint(item, { enabled: true })}>{item.enabled === true ? "Im Logging" : "Ins Logging"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)) : null}
|
||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2519,69 +2758,18 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageSources ? (
|
||||
<div className="protocol-block manual-point-card">
|
||||
<strong>Einzelnen Datenpunkt anlegen</strong>
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{getManualAddressLabel(sourceDraft.protocol)}</span>
|
||||
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Wertnummer</span>
|
||||
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Typ</span>
|
||||
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
|
||||
<option value="analog">analog</option>
|
||||
<option value="digital">digital</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Datenart</span>
|
||||
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Einheit</span>
|
||||
<UnitField units={availableUnits} value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Faktor</span>
|
||||
<input type="number" step="0.0001" value={manualDatapointDraft.scale} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, scale: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Schreibmodus</span>
|
||||
<select value={manualDatapointDraft.writeMode} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, writeMode: event.target.value }))}>
|
||||
<option value="">Default der Quelle</option>
|
||||
<option value="interval">Jeden Poll</option>
|
||||
<option value="cov">Nur bei Änderung (COV)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="span-two">
|
||||
<span>Bedingung / Notiz</span>
|
||||
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage läuft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="datapoint-table">
|
||||
{loggedCollectorPoints.length ? loggedCollectorPoints.slice(0, 2000).map((item) => (
|
||||
<div key={item.id} className="datapoint-row active">
|
||||
<div className="datapoint-main">
|
||||
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
||||
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
|
||||
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` · ${normalizeDisplayText(item.condition)}` : ""}`}</small>
|
||||
<span>{`Wert ${item.pointIndex} ? ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
|
||||
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` ? ${normalizeDisplayText(item.condition)}` : ""}`}</small>
|
||||
</div>
|
||||
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { enabled: false })}>Rausnehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2590,9 +2778,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "users" && canManageUsers ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
@@ -2817,3 +3003,9 @@ export default function App() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ async function request(baseUrl, path, options = {}) {
|
||||
const response = await fetch(baseUrl + path, {
|
||||
headers,
|
||||
method: options.method || "GET",
|
||||
cache: options.cache || "no-store",
|
||||
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { jsPDF } from "jspdf";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
@@ -144,6 +144,7 @@ export default function TrendChart({
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
||||
if (!mapped) {
|
||||
return;
|
||||
@@ -151,36 +152,54 @@ export default function TrendChart({
|
||||
setDragBox({ start: mapped, end: mapped });
|
||||
};
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
if (!dragBox) {
|
||||
return;
|
||||
}
|
||||
const mapped = mapDragToDomain(event.clientX, event.clientY);
|
||||
const updateDragBox = (clientX, clientY) => {
|
||||
const mapped = mapDragToDomain(clientX, clientY);
|
||||
if (!mapped) {
|
||||
return;
|
||||
}
|
||||
setDragBox((current) => current ? { ...current, end: mapped } : current);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragBox) {
|
||||
const finalizeDragBox = (box) => {
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
|
||||
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
|
||||
const deltaX = Math.abs(box.end.xPixel - box.start.xPixel);
|
||||
const deltaY = Math.abs(box.end.yPixel - box.start.yPixel);
|
||||
if (deltaX < 12 || deltaY < 12) {
|
||||
setDragBox(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setZoomDomain({
|
||||
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
|
||||
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
|
||||
x: [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)],
|
||||
y: [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)],
|
||||
});
|
||||
setDragBox(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragBox) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWindowMouseMove = (event) => {
|
||||
updateDragBox(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleWindowMouseUp = () => {
|
||||
finalizeDragBox(dragBox);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleWindowMouseMove);
|
||||
window.addEventListener("mouseup", handleWindowMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleWindowMouseMove);
|
||||
window.removeEventListener("mouseup", handleWindowMouseUp);
|
||||
};
|
||||
}, [dragBox, xDomain, yDomain]);
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoomDomain(null);
|
||||
};
|
||||
@@ -367,9 +386,6 @@ export default function TrendChart({
|
||||
ref={wrapperRef}
|
||||
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div ref={chartHostRef} className="chart-host">
|
||||
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
|
||||
|
||||
+18
-1
@@ -920,6 +920,22 @@ span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-admin-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
gap: 0.65rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.source-overview-panel,
|
||||
.source-settings-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.source-settings-card {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.source-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -995,7 +1011,7 @@ span {
|
||||
}
|
||||
|
||||
.source-tools-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.manual-point-card {
|
||||
@@ -1030,6 +1046,7 @@ span {
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.source-admin-top,
|
||||
.source-split,
|
||||
.source-tools-grid,
|
||||
.source-tile,
|
||||
|
||||
Reference in New Issue
Block a user