Compare commits

...

32 Commits

Author SHA1 Message Date
admin 21050f51c9 compare-laermwerte: Testprogramm zum Vergleich von Laermwerten zweier API-Instanzen
Ruft dieselben Parameter (sensorid/data/span/datetime/peak) gegen zwei
"getsensordata"-Endpunkte ab und diffed die Werte zeitstempelweise, generisch
über alle Felder (funktioniert für live genauso wie für havg/davg/daynight/lden).
Diente als Verifikation der VictoriaMetrics-Anbindung: live-Werte stimmen exakt,
havg-Werte liegen weit innerhalb der Toleranz gegenüber der Influx-Produktion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:59:56 +02:00
admin 23fe7d2ed2 VictoriaMetrics als zusätzliche Datenbank-Option für Messwerte ergänzt (STORE/DBASE=victoria)
Dritte, zu mongo/influx exklusive Auswahl für die laufenden Messwerte. Schreibpfad
nutzt das bestehende Influx-Line-Protocol unverändert (common/victoria_post.js);
Lesepfad (sensorapi/databases/victoria.js + victoria2json.js) holt Rohdaten per
VictoriaMetrics' /api/v1/export und bucketet/aggregiert stundenweise clientseitig,
nach Mongo-Konvention (Stunden-Start als Label, kein Zeit-Shift nötig wie bei Influx).
Scope bewusst auf die schon heute per DBASE umschaltbaren Funktionen begrenzt
(getActData/getNoiseAVGData) - getAvgData/getLongAvg/getGeigerData bleiben wie bisher.

Docker-Compose um victoriametrics-Service ergänzt (Retention explizit auf 100y
gesetzt, da VictoriaMetrics sonst nach 1 Monat Daten löscht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:31:06 +02:00
admin b1b8f39ff4 espid2sensor: ToDos.md entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:39:11 +00:00
admin c0fe05c67e espid2sensor: Doku aktualisiert, eigene Bedienungsanleitung ergänzt
beschreibung.md auf technische Inhalte gekürzt und Bildverweise
entfernt; neue Bedienungsanleitung.md für Endanwender ergänzt
(ebenfalls ohne Bilder).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:37:15 +00:00
admin 2e5a7be4dd espid2sensor: Rechten Rand bei Inputfeldern angleichen (box-sizing: border-box)
Padding und Rahmen der Inputs wurden bislang zur width:100% addiert
und ragten dadurch über den rechten Rand des Containers hinaus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:14:01 +00:00
admin ef12b275f5 espid2sensor: Logout-Button wie inaktive Tabs grau hinterlegen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:10:51 +00:00
admin 782055c4fd espid2sensor: User-Formular und Meldung beim Verlassen des User-Tabs zurücksetzen
Nach dem Anlegen eines Users blieben Formularfelder und die Meldung
"User erfolgreich angelegt!" beim erneuten Öffnen des User-Tabs stehen.
Werden jetzt beim (Wieder-)Betreten des Tabs zurückgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:04:57 +00:00
admin ab74164608 espid2sensor: Logout-Button und Fix für dauerhafte "User gelöscht"-Meldung
Logout-Button rechts in der Tab-Zeile ergänzt. Die Bestätigungsmeldung
nach dem Löschen eines Users blieb bisher beim erneuten Öffnen des
User-Tabs stehen; sie wird jetzt beim (Wieder-)Betreten des Tabs
zurückgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:56:25 +00:00
admin bc9976d5c6 Merge branch 'espid2sensor-admin-bootstrap'
Admin-Bootstrap via ADMIN_EMAIL/ADMIN_PASSWORD für extern verwaltete
Deployments, User-Liste mit Passwort-Reset/Löschen im User-Tab, sowie
zwei Bugfixes (Absturz bei Nicht-Admin-Login, fehlende Version bei
Login-Fehler).
2026-08-07 09:43:56 +00:00
admin 0c45cd9f46 espid2sensor: Version/Datum fehlten auf Login-Seite bei falschem Passwort
POST /login rendert bei Fehlermeldung wieder version/vdate/noUsers, so wie
GET /login das schon tut - vorher fielen diese Locals beim Fehlerfall weg
und der Footer war leer.
2026-08-07 08:20:19 +00:00
admin ddc503cb63 espid2sensor: Passwort-Reset-Modal statt Browser-prompt(), Absturz bei Nicht-Admin-Login behoben
- Ersetzt window.prompt() für den Passwort-Reset in der User-Liste durch
  ein eigenes, zum bestehenden Bestätigungs-Modal passendes Popup
  (showInputModal), analog zum vorhandenen showModal für Löschbestätigungen.
- Behebt ReferenceError beim Login als Nicht-Admin: userCancelBtn wurde als
  impliziter globaler Bezeichner referenziert statt über
  document.getElementById, existiert aber nur im admin-only User-Tab-HTML.
  Für Nicht-Admins riss das den kompletten Setup-Block (Tabs, Liste laden) ab.
2026-08-07 08:20:11 +00:00
admin 19c6facf71 espid2sensor: User-Liste mit Passwort-Reset und Löschen im User-Tab
Ersetzt das manuelle E-Mail-Eingabefeld für den Passwort-Reset durch
eine Liste aller angemeldeten User. Pro User stehen zwei Aktionen zur
Verfügung: Passwort zurücksetzen (nutzt die bestehende
/api/resetPassword-Route) und Löschen (neue DELETE /api/users/:id-Route,
admin-only, verhindert Löschen des eigenen Accounts). Neue GET
/api/users-Route liefert Email/Rolle aller User (admin-only).
2026-08-07 08:03:27 +00:00
admin 278f91ed3c espid2sensor: Bootstrap-Hinweis auf Login-Seite fett hervorheben 2026-08-07 08:03:16 +00:00
admin 7b3403d943 espid2sensor: Admin-Bootstrap via ADMIN_EMAIL/ADMIN_PASSWORD
Für extern verwaltete Deployments (Portainer, kein direkter Mongo-Zugriff)
wird beim Start automatisch ein Admin-User angelegt, falls die
user-Collection leer ist und ADMIN_EMAIL/ADMIN_PASSWORD gesetzt sind.
Login-Seite zeigt einen Hinweis, solange noch kein User existiert.

Version auf 1.5.0 angehoben.
2026-08-07 06:46:28 +00:00
admin 6ff2ceb76d Merge branch 'espid2sensor-passwort'
espid2sensor: Selfservice-Passwortänderung fuer alle User und
Admin-gesteuerter Passwort-Reset (kein Mail-Versand noetig).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:12:18 +00:00
admin cd4d7ba25d espid2sensor: Passwort ändern + Admin-Passwort-Reset
- POST /api/changePassword: jeder eingeloggte User kann sein eigenes
  Passwort ändern (erfordert aktuelles Passwort), neuer Profil-Tab
- POST /api/resetPassword: Admin kann das Passwort eines Users per
  Email zuruecksetzen, neue Karte im bestehenden User-Tab
- Login-Seite: Hinweis fuer "Passwort vergessen" (Kontakt zum Admin,
  da keine Mail-Infrastruktur im Projekt existiert)
- Bugfix beim Umsetzen: express-session's MemoryStore serialisiert
  Sessions per JSON, wodurch die ObjectId aus req.session.userId zu
  einem String wird - vor der Mongo-Query per ObjectId(...) casten

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:07:52 +00:00
admin 47d27148ca Merge branch 'with_esp2sensor'
espid2sensor in den Hauptstack integriert: verschachteltes Git-Repo
aufgeloest, Dockerfile/deploy.sh an die anderen Services angeglichen,
als Service in noisesensors/docker-compose.yml aufgenommen (Port 3004,
gemeinsame Mongo-Instanz), APIHOST-Bugfix fuer die Adressaufloesung,
Versionsbump auf 1.3.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 07:53:32 +00:00
admin ccaf6dd6f3 espid2sensor: Version auf 1.3.2 angehoben
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 07:52:51 +00:00
admin 4283f07291 espid2sensor/build_and_copy.sh entfernen
Ueberfluessig, seit deploy.sh direkt ueber docker build + push
arbeitet (wie bei den anderen Services) statt ueber das alte
scp-basierte Kopieren auf einen Zielhost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 06:56:29 +00:00
admin b8bcd946d0 espid2sensor/deploy.sh an die anderen deploy.sh angleichen
REGISTRY per Env-Variable konfigurierbar (Default ghcr.io/dnms-org/laermkarte-sensor-community),
Build direkt ueber docker build -f Dockerfile_esp2sensor statt ueber das
alte scp-basierte build_and_copy.sh, einheitlicher Aufbau/Ausgabe wie bei
noise/sensorapi/readin/readarchive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 06:55:44 +00:00
admin b92dffef0f esp2sensor: APIHOST auf internen sensorapi-Container setzen
Ohne APIHOST griff der externe Default (https://noise.fuerst-stuttgart.de/srv/),
der im Container mit einem TLS-Fehler (self-signed certificate) fehlschlug. Die
dadurch leere Adresse wurde vom Frontend faelschlich als "Sensor unbekannt"
interpretiert, obwohl der Sensor in Mongo existierte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 06:50:17 +00:00
admin 0d10e17caf espid2sensor in Hauptstack aufnehmen, verschachteltes Git-Repo aufgeloest
espid2sensor lief bisher nur per eigenem Dev-Compose mit isolierter
Wegwerf-Mongo. Jetzt Teil von noisesensors/docker-compose.yml, Port 3004,
Zugriff auf die dort laufende mongodb-Instanz (sensor_data). DEV_AUTO_LOGIN
explizit aus, SESSION_SECRET ueber neue Env-Variable statt Code-Default.

Dockerfile_esp2sensor an das Muster der anderen Services angeglichen
(node:22-alpine, /opt/app, npm ci, tzdata/Europe-Berlin, deluser node).

espid2sensor/.git entfernt (war eigenes verschachteltes Repo) und der
komplette Verzeichnisinhalt ins laermsensor-stack-Repo uebernommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:14:02 +00:00
admin cf55afda38 Merge branch 'main' of github.com:dnms-org/laermkarte-sensor-community
# Conflicts:
#	noise/deploy.sh
#	readin/deploy.sh
#	sensorapi/deploy.sh
2026-08-05 12:56:25 +00:00
admin 306239cfbc Merge branch 'deploy-target-env' 2026-08-05 12:51:01 +00:00
admin b5ffd8ae4a deploy.sh: Standard-Registry auf ghcr.io/dnms-org/laermkarte-sensor-community umgestellt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:48:07 +00:00
admin add666b297 deploy.sh: Push in zweite Registry (Gitea) entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:40:55 +00:00
admin 8891addccb deploy.sh: Ziel-Registry ueber REGISTRY-Umgebungsvariable einstellbar
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:33:17 +00:00
admin ff789a68ef .gitignore: Verweis auf geloeschte UEBERGABE.md entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:44 +00:00
admin 1c213634c7 E-Mail-Adressen aus Footer und User-Agent entfernt, vonRemote-Ordner ignoriert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:10 +00:00
rbsensors f4bf4fffd6 REGISTRY angepasst 2026-07-29 21:59:52 +02:00
rbsensors d7a9a7a487 Update deploy.sh 2026-07-29 20:06:14 +02:00
rbsensors d081b9f52d Update README.md 2026-07-26 17:39:01 +02:00
48 changed files with 9408 additions and 122 deletions
+9
View File
@@ -6,3 +6,12 @@ log/
*.log *.log
.env .env
**/.env **/.env
# Produktionsstand vom Server, nur Referenz
noisesensors/vonRemote/
# Laufzeitdaten der lokal getesteten Container (DB-Dateien, aktuelle Messwerte)
noisesensors/data/mongo/data/
noisesensors/data/influx/
noisesensors/data/victoria/
noisesensors/data/aktdata.json
+18 -4
View File
@@ -20,6 +20,8 @@ Die Datei **noise.tgz** enthält die folgenden Dateien und Verzeichnisse:
| | | +- <f>create.js | | | +- <f>create.js
| + <d>influx | + <d>influx
| | + <d>data | | + <d>data
| + <d>victoria
| | + <d>data
<f>: file, <d>: directory <f>: file, <d>: directory
~~~ ~~~
@@ -34,7 +36,7 @@ Ablage für die Logdateien der einzelnene Container (**noise.log**, **sensorapi.
hier darunter befinden sich alle Daten, sowohl die Datenbanken als auch Konfigurationen. Außerdem werden jeweils die laufend eingelesenen Daten als **actdata.json** abgelegt. Diese werden bei jedem neuen Einlesen wieder überschrieben. hier darunter befinden sich alle Daten, sowohl die Datenbanken als auch Konfigurationen. Außerdem werden jeweils die laufend eingelesenen Daten als **actdata.json** abgelegt. Diese werden bei jedem neuen Einlesen wieder überschrieben.
* **data/mongo** * **data/mongo**
Verzeichnis für die Mongo-Datenbank Verzeichnis für die Mongo-Datenbank
* **data/mngo/data** * **data/mongo/data**
hier befinden sich die eingelesenen Daten hier befinden sich die eingelesenen Daten
* **data/mongo/entries** * **data/mongo/entries**
hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenbank die Collections und die Indexe erzeugt werden. hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenbank die Collections und die Indexe erzeugt werden.
@@ -42,10 +44,14 @@ hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenba
Verzeichnis für die Influx-Datenbank Verzeichnis für die Influx-Datenbank
* **data/influx/data** * **data/influx/data**
Hier dann die eingelesenen Daten Hier dann die eingelesenen Daten
* **data/victoria**
Verzeichnis für die VictoriaMetrics-Datenbank (optionale Alternative zu Influx, siehe unten)
* **data/victoria/data**
Hier dann die eingelesenen Daten
### Aufrufe ### Aufrufe
Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Direktoty *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden. Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Verzeichnis *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden.
Nun das *docker-compose.yml* in das Portainer-Stack einfügen. Zusätzlich folgende Environment-Variablen festlegen: Nun das *docker-compose.yml* in das Portainer-Stack einfügen. Zusätzlich folgende Environment-Variablen festlegen:
@@ -60,6 +66,8 @@ STORE=influx
~~~ ~~~
\<username>, \<passwort>, \<username1>, \<passwort1> und \<token> sind anzupassen. \<username>, \<passwort>, \<username1>, \<passwort1> und \<token> sind anzupassen.
**STORE** legt fest, in welche Datenbank(en) *readin* die laufenden Messwerte schreibt: `influx`, `mongo`, `victoria` oder `both` (= mongo **und** influx gleichzeitig; Default). `victoria` ist dabei ein zu `influx` alternativer, exklusiver Wert (nicht über `both` kombinierbar). *sensorapi* liest die Messwerte unabhängig davon über **DBASE** (dieselben Werte `influx`/`mongo`/`victoria`, siehe Container-Beschreibung unten).
Danach einloggen in das Docker-Registry auf *citysensor.de*: Danach einloggen in das Docker-Registry auf *citysensor.de*:
~~~ ~~~
@@ -91,7 +99,7 @@ Im **Portainer** als eigenes Stack anlegen (Inhalt von `readarchive-stack.yml`)
`-s` | `START` | gestern `-s` | `START` | gestern
`-e` | `END` | Startdatum + 1 Tag (**exklusiv**) `-e` | `END` | Startdatum + 1 Tag (**exklusiv**)
`-t` | `TYP` | `noise` `-t` | `TYP` | `noise`
`-d` | `DBASE` | `both`, in der Stack-Datei auf `influx` vorbelegt `-d` | `DBASE` | `mongo`, `influx`, `victoria` oder `both` (= mongo+influx); in der Stack-Datei auf `influx` vorbelegt
*Deploy the stack* startet den Lauf, der Container endet danach und bleibt als „Exited" stehen. Für den nächsten Zeitraum nur die Variablen ändern und *Update the stack*. Der Haupt-Stack wird dabei nicht angefasst. *Deploy the stack* startet den Lauf, der Container endet danach und bleibt als „Exited" stehen. Für den nächsten Zeitraum nur die Variablen ändern und *Update the stack*. Der Haupt-Stack wird dabei nicht angefasst.
@@ -143,13 +151,19 @@ Die Influx-Datenbank. Hier werden in dem Bucket *sensor_data* die reinen Messwe
http://\<server>:8086 http://\<server>:8086
Logindaten: Entsprechen der im Portainer hinterlegten. Logindaten: Entsprechen der im Portainer hinterlegten.
* **victoriametrics**
Alternative zur Influx-Datenbank für die reinen Messwerte, wählbar über `STORE`/`DBASE=victoria`. Läuft als Single-Node-Instanz auf **Port 8428**, ohne Login/Auth.
**Wichtig:** VictoriaMetrics löscht standardmäßig Daten, die älter als 1 Monat sind (`-retentionPeriod=1`). Im `docker-compose.yml` ist das Flag deshalb explizit auf `--retentionPeriod=100y` gesetzt — das **nicht** entfernen, sonst verschwinden alte Messwerte nach 30 Tagen kommentarlos.
**Zugriff:**
http://\<server>:8428 (z.B. `/api/v1/export?match[]={sid="..."}` zum Rohdaten-Export)
## Sourcen für die Container ## Sourcen für die Container
Das gesamte Projekt ist im GitHub unter **laermsensor-stack** abgelegt. Für jeden der 4 Container existiert darunter ein Verzeichnis: Das gesamte Projekt ist im GitHub unter **laermsensor-stack** abgelegt. Für jeden der 4 Container existiert darunter ein Verzeichnis:
**readin**, **readarchive**, **sensorapi** und **noise**. In diesen Verzeichnissen sind alle benötigten Sourcen enthalten. **readin**, **readarchive**, **sensorapi** und **noise**. In diesen Verzeichnissen sind alle benötigten Sourcen enthalten.
Daneben gibt es **common** mit den Modulen, die sich *readin* und *readarchive* teilen: `mongo.js`, `influx_post.js`, `logit.js` und `statistics.js`. Sie lagen früher in beiden Komponenten doppelt und sind auseinandergelaufen. Wer dort etwas ändert, ändert es für beide — nach einer Änderung also **beide** Container neu bauen. Daneben gibt es **common** mit den Modulen, die sich *readin* und *readarchive* teilen: `mongo.js`, `influx_post.js`, `victoria_post.js`, `logit.js` und `statistics.js`. Sie lagen früher in beiden Komponenten doppelt und sind auseinandergelaufen. Wer dort etwas ändert, ändert es für beide — nach einer Änderung also **beide** Container neu bauen.
Sollte was geändert werden, so muss mit **deploy.sh** der Container neu erzeugt und auf die Registry (siehe oben) gepushed werden. Der Build-Kontext von *readin* und *readarchive* ist wegen `common/` das Wurzelverzeichnis des Repositories; `deploy.sh` wechselt selbst dorthin und kann aus dem Komponentenverzeichnis aufgerufen werden. Sollte was geändert werden, so muss mit **deploy.sh** der Container neu erzeugt und auf die Registry (siehe oben) gepushed werden. Der Build-Kontext von *readin* und *readarchive* ist wegen `common/` das Wurzelverzeichnis des Repositories; `deploy.sh` wechselt selbst dorthin und kann aus dem Komponentenverzeichnis aufgerufen werden.
+55
View File
@@ -0,0 +1,55 @@
/* Zugriff auf VictoriaMetrics per HTTP (InfluxDB-Line-Protocol-kompatibler Write-Endpunkt)
Gemeinsam genutzt von readin (Live-API) und readarchive (CSV-Archiv).
*/
import axios from 'axios'
import { logit, logerror } from './logit.js'
import { statistics } from './statistics.js'
import { DateTime } from 'luxon'
let VICTORIAHOST = process.env.VICTORIAHOST || "localhost"
let VICTORIAPORT = process.env.VICTORIAPORT || 8428
const VICTORIAURL_WRITE = `http://${VICTORIAHOST}:${VICTORIAPORT}/write?precision=ms`
// `${e}` liefert bei einem AggregateError nur "AggregateError" - die einzelnen
// Verbindungsfehler (je einer pro aufgeloester IP) stecken in e.errors bzw. e.cause.errors
function describeError(e) {
const sub = e.errors || e.cause?.errors
if (sub?.length) {
return `${e.message || e.name}: ` + sub.map(s => `${s.code} ${s.address}:${s.port}`).join(', ')
}
if (e.response) {
return `HTTP ${e.response.status} ${JSON.stringify(e.response.data)}`
}
return `${e.code ? e.code + ' ' : ''}${e.message || e}`
}
// liefert true, wenn VictoriaMetrics die Daten uebernommen hat, sonst false
export const victoriaWrite = async (data) => {
let start = DateTime.now()
let ok = false
try {
const ret = await axios({
method: 'post',
url: VICTORIAURL_WRITE,
data: data,
headers: {
Accept: 'application/json',
'Content-Type': 'text/plain; charset=utf-8'
},
timeout: 10000,
})
if (ret.status != 204) {
logerror(`doWrite2API Status: ${ret.status}`)
} else {
ok = true
}
} catch (e) {
logerror(`doWrite2API ${VICTORIAURL_WRITE} ${describeError(e)}`)
}
statistics['writeVictoriaData[sensor_data]Time'] = DateTime.now().diff(start, ['seconds']).toObject().seconds
logit(`Victoria-Write-Time: ${start.diffNow('seconds').toObject().seconds * -1} sec`)
return ok
}
+249
View File
@@ -0,0 +1,249 @@
// compare.js - vergleicht die Laermwerte zweier "noise"-API-Instanzen
// (z.B. lokaler VictoriaMetrics-Test-Stack vs. Produktion) fuer denselben
// Sensor/Zeitraum/Auswertungstyp. Siehe Laerm_API.md fuer die API-Parameter.
//
// Aufruf: node compare.js [-s sensorid] [-d live|havg|davg|daynight|lden] ...
// node compare.js -h fuer alle Optionen
import axios from 'axios'
import { DateTime } from 'luxon'
import mod_getopt from 'posix-getopt'
const DEFAULT_URL_A = 'http://localhost:3003/api/getsensordata'
const DEFAULT_URL_B = 'https://noise.citysensor.de/api/getsensordata'
const DEFAULT_SENSORID = '37833'
const DEFAULT_DATA = 'live'
const DEFAULT_SPAN = '1'
const DEFAULT_PEAK = '70'
const DEFAULT_TOLERANCE = 0.05
const MAX_EXAMPLES = 20
function parseArgs(argv) {
let opts = {
urlA: DEFAULT_URL_A,
urlB: DEFAULT_URL_B,
sensorid: DEFAULT_SENSORID,
data: DEFAULT_DATA,
span: DEFAULT_SPAN,
datetime: null,
peak: DEFAULT_PEAK,
tolerance: DEFAULT_TOLERANCE,
}
let parser = new mod_getopt.BasicParser(
'a:(urlA)b:(urlB)s:(sensorid)d:(data)p:(span)t:(datetime)k:(peak)e:(tolerance)h(help)v(version)',
argv
)
let option
while ((option = parser.getopt()) !== undefined) {
switch (option.option) {
case 'a': opts.urlA = option.optarg; break
case 'b': opts.urlB = option.optarg; break
case 's': opts.sensorid = option.optarg; break
case 'd': opts.data = option.optarg; break
case 'p': opts.span = option.optarg; break
case 't': opts.datetime = option.optarg; break
case 'k': opts.peak = option.optarg; break
case 'e': opts.tolerance = parseFloat(option.optarg); break
case 'v':
console.log('compare-laermwerte 1.0.0')
process.exit()
break
case 'h':
console.log('Usage: node compare.js [options]')
console.log('Options:')
console.log(` -a urlA Basis-URL Implementierung A (default: ${DEFAULT_URL_A})`)
console.log(` -b urlB Basis-URL Implementierung B (default: ${DEFAULT_URL_B})`)
console.log(` -s sensorid Sensor-ID (default: ${DEFAULT_SENSORID})`)
console.log(` -d data live|havg|davg|daynight|lden (default: ${DEFAULT_DATA})`)
console.log(` -p span Zeitspanne in Tagen (default: ${DEFAULT_SPAN})`)
console.log(' -t datetime Start-Zeitpunkt ISO8601 (default: unbelegt -> jetzt - span)')
console.log(` -k peak dB-Schwelle fuer peakcount, nur havg/davg (default: ${DEFAULT_PEAK})`)
console.log(` -e tolerance Toleranz fuer Zahlenvergleich, absolut (default: ${DEFAULT_TOLERANCE})`)
console.log(' -v Version anzeigen')
console.log(' -h diese Hilfe')
process.exit()
break
default:
break
}
}
return opts
}
function buildURL(base, opts) {
let params = {
sensorid: opts.sensorid,
data: opts.data,
span: opts.span,
peak: opts.peak,
}
if (opts.datetime) {
params.datetime = opts.datetime
}
let qs = new URLSearchParams(params).toString()
return `${base}?${qs}`
}
async function fetchSide(label, url) {
try {
let ret = await axios.get(url, { timeout: 15000 })
if (ret.data && ret.data.err) {
return { ok: false, err: `${label}: API-Fehler: ${ret.data.err}` }
}
return { ok: true, values: ret.data.values || [], options: ret.data.options }
} catch (e) {
let msg = e.response ? `HTTP ${e.response.status}` : e.message
return { ok: false, err: `${label}: Request fehlgeschlagen: ${msg}` }
}
}
function toMap(values) {
let map = new Map()
for (let row of values) {
let ms = DateTime.fromISO(row.datetime, { zone: 'utc' }).toMillis()
if (Number.isNaN(ms)) {
continue
}
map.set(ms, row)
}
return map
}
function diffRows(mapA, mapB, tolerance) {
let onlyA = []
let onlyB = []
let commonCount = 0
let fieldStats = new Map() // field -> {count, sumDiff, maxDiff, mismatchCount}
let examples = []
for (let ts of mapA.keys()) {
if (!mapB.has(ts)) {
onlyA.push(ts)
}
}
for (let ts of mapB.keys()) {
if (!mapA.has(ts)) {
onlyB.push(ts)
}
}
for (let [ts, rowA] of mapA) {
let rowB = mapB.get(ts)
if (!rowB) {
continue
}
commonCount++
let fields = new Set([...Object.keys(rowA), ...Object.keys(rowB)])
fields.delete('datetime')
for (let field of fields) {
let a = (field in rowA) ? rowA[field] : null
let b = (field in rowB) ? rowB[field] : null
if (a === null && b === null) {
continue
}
let stat = fieldStats.get(field)
if (!stat) {
stat = { count: 0, sumDiff: 0, maxDiff: 0, mismatchCount: 0 }
fieldStats.set(field, stat)
}
stat.count++
if (typeof a !== 'number' || typeof b !== 'number') {
stat.mismatchCount++
if (examples.length < MAX_EXAMPLES) {
examples.push({ ts, field, a, b, reason: 'null-mismatch' })
}
continue
}
let diff = Math.abs(a - b)
stat.sumDiff += diff
if (diff > stat.maxDiff) {
stat.maxDiff = diff
}
if (diff > tolerance) {
stat.mismatchCount++
if (examples.length < MAX_EXAMPLES) {
examples.push({ ts, field, a, b, diff })
}
}
}
}
return { onlyA, onlyB, commonCount, fieldStats, examples }
}
function fmtTs(ms) {
return DateTime.fromMillis(ms, { zone: 'utc' }).toISO()
}
function printSummary(opts, sideA, sideB, diff) {
console.log('=== Vergleich Laermwerte ===')
console.log(`Sensor: ${opts.sensorid} data: ${opts.data} span: ${opts.span}${opts.datetime ? ` datetime: ${opts.datetime}` : ''}`)
console.log(`A: ${sideA.count} Werte`)
console.log(`B: ${sideB.count} Werte`)
console.log(`Gemeinsame Zeitstempel: ${diff.commonCount}`)
console.log(`Nur in A: ${diff.onlyA.length}${diff.onlyA.length ? ` (z.B. ${diff.onlyA.slice(0, 3).map(fmtTs).join(', ')})` : ''}`)
console.log(`Nur in B: ${diff.onlyB.length}${diff.onlyB.length ? ` (z.B. ${diff.onlyB.slice(0, 3).map(fmtTs).join(', ')})` : ''}`)
console.log('')
console.log('Felder (bei gemeinsamen Zeitstempeln):')
let totalMismatches = 0
for (let [field, stat] of diff.fieldStats) {
let avg = stat.count ? stat.sumDiff / stat.count : 0
totalMismatches += stat.mismatchCount
console.log(` ${field.padEnd(14)} verglichen: ${String(stat.count).padStart(5)} max diff: ${stat.maxDiff.toFixed(4).padStart(10)} avg diff: ${avg.toFixed(4).padStart(10)} mismatches (>${opts.tolerance}): ${stat.mismatchCount}`)
}
if (diff.examples.length > 0) {
console.log('')
console.log(`Beispiel-Mismatches (max ${MAX_EXAMPLES}):`)
for (let ex of diff.examples) {
if (ex.reason === 'null-mismatch') {
console.log(` ${fmtTs(ex.ts)} ${ex.field}: A=${ex.a} B=${ex.b}`)
} else {
console.log(` ${fmtTs(ex.ts)} ${ex.field}: A=${ex.a} B=${ex.b} diff=${ex.diff.toFixed(4)}`)
}
}
}
console.log('')
if (totalMismatches > 0) {
console.log(`FEHLGESCHLAGEN: ${totalMismatches} Feld-Mismatches ausserhalb der Toleranz gefunden.`)
} else {
console.log('OK: Keine Mismatches ausserhalb der Toleranz bei gemeinsamen Zeitstempeln.')
}
return totalMismatches
}
async function main() {
let opts = parseArgs(process.argv)
let urlA = buildURL(opts.urlA, opts)
let urlB = buildURL(opts.urlB, opts)
console.log(`A: ${urlA}`)
console.log(`B: ${urlB}`)
console.log('')
let [sideA, sideB] = await Promise.all([
fetchSide('A', urlA),
fetchSide('B', urlB),
])
if (!sideA.ok || !sideB.ok) {
if (!sideA.ok) console.error(sideA.err)
if (!sideB.ok) console.error(sideB.err)
process.exitCode = 2
return
}
let mapA = toMap(sideA.values)
let mapB = toMap(sideB.values)
let diff = diffRows(mapA, mapB, opts.tolerance)
let mismatches = printSummary(
opts,
{ count: sideA.values.length },
{ count: sideB.values.length },
diff
)
process.exitCode = mismatches > 0 ? 1 : 0
}
main().catch((e) => {
console.error(e)
process.exitCode = 2
})
+366
View File
@@ -0,0 +1,366 @@
{
"name": "compare-laermwerte",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "compare-laermwerte",
"version": "1.0.0",
"dependencies": {
"axios": "^1.12.0",
"luxon": "^3.3.0",
"posix-getopt": "^1.2.1"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/posix-getopt": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/posix-getopt/-/posix-getopt-1.2.1.tgz",
"integrity": "sha512-BbGTiH8MOWAuc6h5yITkSn9k3HP4+QOCV9t6I5F62OrH7zqTHRo08QNsgELRreTBxcvRhbSpMoUnAx77Dz4yUA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "compare-laermwerte",
"version": "1.0.0",
"description": "Vergleicht Laermwerte zweier API-Instanzen (z.B. lokaler Victoria-Test-Stack vs. Produktion)",
"type": "module",
"main": "compare.js",
"scripts": {
"start": "node compare.js"
},
"dependencies": {
"axios": "^1.12.0",
"luxon": "^3.3.0",
"posix-getopt": "^1.2.1"
}
}
+15
View File
@@ -0,0 +1,15 @@
.vscode
docs
node_modules
tests
.gitignore
build_and_copy
deploy.sh
docker-compose.yml
Dockerfile_esp2sensor
hashpasswd
.env
.env.local
*.log
log/
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
*.pdf
.DS_Store
._*
docs/beschreibung_tmp.html
docs/beschreibung.html
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"yzane.markdown-pdf"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/server.js"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine
ADD package.json /tmp/package.json
ADD package-lock.json /tmp/package-lock.json
RUN cd /tmp && npm ci
RUN mkdir -p /opt/app && cp -a /tmp/node_modules /tmp/package.json /opt/app/
WORKDIR /opt/app
ADD . /opt/app/
RUN apk add --no-cache tzdata
ENV TZ=Europe/Berlin
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN deluser --remove-home node
EXPOSE 3000
CMD ["npm", "start"]
+59
View File
@@ -0,0 +1,59 @@
import { MongoClient } from 'mongodb';
import dotenv from 'dotenv';
dotenv.config();
const MONGOHOST = process.env.MONGOHOST || 'localhost'
const MONGOPORT = process.env.MONGOPORT || 27017
const MONGOAUTH = process.env.MONGOAUTH || false
const MONGOUSRP = process.env.MONGOUSRP || ''
const MONGOBASE = process.env.MONGOBASE || 'sensor_data'
let MONGO_URL = 'mongodb://'+MONGOHOST+':'+MONGOPORT; // URL to mongo database
if (MONGOAUTH === 'true') {
MONGO_URL = 'mongodb://'+MONGOUSRP+'@' + MONGOHOST + ':' + MONGOPORT + '/?authSource=admin'; // URL to mongo database
}
const DB_NAME = MONGOBASE
let db, usersCollection, propCollection;
let client = null;
export async function initMongo() {
const client = new MongoClient(MONGO_URL);
await client.connect();
db = client.db(DB_NAME);
usersCollection = db.collection('user');
propCollection = db.collection('properties')
return { db, usersCollection, propCollection};
}
export const clientClose = async () => {
if (client) {
client.close()
}
}
export function getCollections() {
return { db, usersCollection, propCollection};
}
export const update_pflux = async(sn, doc) => {
try {
await propCollection.updateOne({_id: sn},{ $set: { 'chip': doc}})
return {"error": null}
} catch (e) {
return { "error": true, "what": e}
}
}
export const get_pflux = async(sn) => {
try {
let r = await propCollection.findOne({_id: sn})
if (r == null) {
return { "error": true, "what": "Not found", "erg": r}
}
return {"error": null, "what": null, "erg": r}
} catch (e) {
return { "error": true, "what": e, "erg": null}
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -e
# Angepasst aus dem noise-/sensorapi-deploy.sh
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_esp2sensor).
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="esp2sensor"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
DOCKERFILE="Dockerfile_esp2sensor"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "esp2sensor Deploy Script (amd64)"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Platform: ${PLATFORM}"
echo "Dockerfile: ${DOCKERFILE}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# Hinweis: Login wird uebersprungen, weil die Credentials fuer ${REGISTRY}
# bereits in ~/.docker/config.json liegen.
# Bei Bedarf manuell: docker login ${REGISTRY}
echo ">>> Baue Image (nur ${PLATFORM}, nativ) ..."
docker build \
--platform "${PLATFORM}" \
-f "${DOCKERFILE}" \
-t "${FULL_IMAGE}" \
.
echo ""
echo ">>> Pushe ${FULL_IMAGE} ..."
docker push "${FULL_IMAGE}"
echo ""
# Falls ein anderer Tag als 'latest' gebaut wurde, zusaetzlich latest setzen+pushen
if [ "${TAG}" != "latest" ]; then
echo ">>> Tagge zusaetzlich als :latest ..."
docker tag "${FULL_IMAGE}" "${REGISTRY}/${IMAGE_NAME}:latest"
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
echo ""
fi
echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}"
echo "=========================================="
echo ""
echo "Danach Stack aktualisieren:"
echo " cd /opt/stacks/esprimo/noisesensors && docker compose up -d esp2sensor"
+25
View File
@@ -0,0 +1,25 @@
services:
app:
build: .
container_name: esp2sensor
ports:
- "3000:3000"
depends_on:
- mongo
volumes:
- .:/app # bind mount für Live-Reload
- /app/node_modules # node_modules vom Host nicht überschreiben
- ./log:/var/log
restart: unless-stopped
mongo:
image: mongo:6
container_name: esp-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
+65
View File
@@ -0,0 +1,65 @@
# Bedienungsanleitung esp2sensor
Diese Anleitung richtet sich an die Anwender der Webapp. Technische Hintergründe (Deployment, Erststart/Bootstrap) stehen in `beschreibung.md`.
## Anmeldung
Als Benutzername dient die **E-Mail-Adresse**. Während der Eingabe wird geprüft, ob diese bekannt ist; solange die Eingabe unvollständig ist, erscheint *Benutzer nicht gefunden*.
Passwort vergessen? Bitte an einen Admin wenden — der kann es über den **User**-Tab zurücksetzen.
Nach erfolgreicher Anmeldung gelangt man zur Hauptseite mit den Tabs am oberen Rand. Welche Tabs sichtbar sind, hängt davon ab, ob man als **Admin** oder als normaler **User** angemeldet ist. Ganz rechts in der Tab-Zeile befindet sich der **Logout**-Button, mit dem man sich jederzeit abmelden kann.
## Tab „Eingabe“
Dieser Tab ist nach der Anmeldung standardmäßig ausgewählt.
* In die Zeile **Sensornummer** wird die Sensornummer (von sensor.community) eingetragen. Ist sie bereits bekannt, wird die zugehörige Adresse automatisch in der Zeile **Anschrift** angezeigt. Ist sie unbekannt, erscheint eine Fehlermeldung.
* Danach können **ESP-ID**, **Bezeichnung** und **Beschreibung** eingegeben werden. Nur die ESP-ID ist Pflicht, die anderen beiden Felder sind optional. Die Anschrift kann nicht manuell verändert werden, sie ergibt sich immer aus der Sensornummer.
* Über **Speichern** werden die Daten übernommen. Bereits vorhandene Daten zu dieser Sensornummer werden dabei überschrieben.
* Wurde die Sensornummer bereits einmal erfasst, werden die vorhandenen Daten automatisch geladen und können hier geändert werden.
## Tab „Liste“
Zeigt alle erfassten Datensätze. Über **Aktualisieren** wird die Liste neu geladen, über **Seite** und **Limit** lässt sich blättern bzw. die Anzahl der Einträge pro Seite einstellen.
In der Spalte **Aktionen** gibt es zwei Symbole:
* **Stift** (✏️) — lädt den Eintrag zur Bearbeitung in den Eingabe-Tab.
* **Mülleimer** (🗑️) — löscht den Eintrag nach einer Sicherheitsrückfrage.
Die Liste lässt sich nach **SensorNr**, **ESP-ID** und **Datum** auf- oder absteigend sortieren (Pfeile ↑↓ in der Kopfzeile).
## Tab „Profil“
Hier kann jeder angemeldete User sein eigenes Passwort ändern:
* **Aktuelles Passwort**
* **Neues Passwort**
* **Neues Passwort (Wiederholung)**
Über **Ändern** wird das neue Passwort gespeichert. Stimmen die beiden neuen Passwörter nicht überein oder ist das aktuelle Passwort falsch, erscheint eine entsprechende Fehlermeldung.
## Tab „User“ (nur für Admins)
Dieser Tab ist nur sichtbar, wenn man als Admin angemeldet ist.
**Neuen User anlegen:** Benutzername (E-Mail), Passwort und Rolle (*User* oder *Admin*) eingeben und auf **Anlegen** klicken.
**Angemeldete User:** darunter erscheint eine Liste aller vorhandenen User mit E-Mail und Rolle. Pro Zeile stehen zwei Aktionen zur Verfügung:
* **Passwort zurücksetzen** (🔑) — öffnet ein Eingabefeld für ein neues Passwort für diesen User.
* **User löschen** (🗑️) — löscht den User nach einer Sicherheitsrückfrage. Der eigene Account kann nicht gelöscht werden.
## Abmelden
Über den **Logout**-Button rechts in der Tab-Zeile wird die Sitzung beendet und man gelangt zurück zur Login-Seite.
### History
| Version | Datum | Author |Bemerkung|
|---------|-------|-----|----------|
| 1.0.0 | 2026-08-07 | rxf | erste Fassung
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+30
View File
@@ -0,0 +1,30 @@
# Beschreibung von esp2senor
### Erststart / Bootstrap
Ist die `user`-Collection in der Mongo-Datenbank leer (z.B. bei einem neuen, extern verwalteten Deployment ohne direkten Datenbankzugriff), legt der Server beim Start automatisch einen Admin-User an — vorausgesetzt, die Umgebungsvariablen `ADMIN_EMAIL` und `ADMIN_PASSWORD` sind gesetzt. Fehlen sie, erscheint auf der Login-Seite ein entsprechender Hinweis und im Log eine Warnung.
Nach dem ersten Login mit diesen Zugangsdaten sollte das Passwort über den **Profil**-Tab geändert werden. Sobald mindestens ein User existiert, greift der Bootstrap-Mechanismus nicht mehr — weitere User werden wie gewohnt über den **User**-Tab (nur für Admins) angelegt.
### Darstellung
Die Login-Seite dient der Anmeldung per E-Mail und Passwort. Nach erfolgreicher Anmeldung erscheint die Hauptseite mit den Tabs **Eingabe**, **Liste**, **Profil** und — nur für Admins — **User**, sowie einem **Logout**-Button ganz rechts in der Tab-Zeile.
* **Eingabe**: Erfassen/Ändern eines Datensatzes über Sensornummer (mit automatischer Adressermittlung), ESP-ID, Bezeichnung und Beschreibung.
* **Liste**: paginierte, sortierbare Übersicht aller Datensätze mit Bearbeiten- und Löschen-Aktion je Zeile.
* **Profil**: Ändern des eigenen Passworts (aktuelles Passwort, neues Passwort, Wiederholung).
* **User** (nur Admin): Anlegen neuer User (E-Mail, Passwort, Rolle) sowie eine Liste aller vorhandenen User mit Aktionen zum Passwort-Zurücksetzen und Löschen (der eigene Account kann nicht gelöscht werden).
Eine ausführliche Bedienungsanleitung für Endanwender steht in `Bedienungsanleitung.md`.
#### History
| Version | Datum | Author |Bemerkung|
|---------|-------|-----|----------|
| 1.2.0 | 2026-08-07 | rxf | verkürzt auf technische Dinge, Verweis auf Bedienunsgaleitung
| 1.1.0 | 2025-09-03 | rxf | Ändern,Löschen und Usereingabe |
|1.0.0 | 2025-08-19 | rxf | erste Fassung |
+3
View File
@@ -0,0 +1,3 @@
import bcrypt from 'bcrypt';
const hashedPassword = await bcrypt.hash('Tux4esp', 10);
console.log(hashedPassword)
+6549
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "espid2sensor",
"version": "1.6.3",
"date": "2026-08-07",
"type": "module",
"description": "Kleine Webapp ESP-ID <-> Sensornummer, speichern in MongoDB",
"main": "server.js",
"scripts": {
"start": "node server.js >>/var/log/esp2sensor.log 2>&1",
"dev": "nodemon --watch server.js --watch views --watch public server.js",
"test": "jest"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-session": "^1.18.2",
"mongodb": "^6.19.0",
"pug": "^3.0.3"
},
"devDependencies": {
"jest": "^30.1.3",
"nodemon": "^3.1.10",
"supertest": "^7.1.4"
}
}
+602
View File
@@ -0,0 +1,602 @@
// Tab-Wechsel Funktion aus index.pug
function showTab(tab) {
document.getElementById('tabInputContent').style.display = tab === 'input' ? '' : 'none';
document.getElementById('tabListContent').style.display = tab === 'list' ? '' : 'none';
document.getElementById('tabProfileContent').style.display = tab === 'profile' ? '' : 'none';
const tabUserContent = document.getElementById('tabUserContent');
if (tabUserContent) tabUserContent.style.display = tab === 'user' ? '' : 'none';
document.getElementById('tabInput').classList.toggle('active', tab === 'input');
document.getElementById('tabList').classList.toggle('active', tab === 'list');
document.getElementById('tabProfile').classList.toggle('active', tab === 'profile');
const tabUser = document.getElementById('tabUser');
if (tabUser) tabUser.classList.toggle('active', tab === 'user');
}
// User-Tab Handling (nur für Admins)
document.addEventListener('DOMContentLoaded', () => {
const userSaveBtn = document.getElementById('userSaveBtn');
if (userSaveBtn) {
userSaveBtn.addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const role = document.getElementById('role').value;
const userResult = document.getElementById('userResult');
if (!username || !password) {
userResult.textContent = 'Benutzername und Passwort erforderlich.';
return;
}
try {
const res = await fetch('/api/createUser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
});
const data = await res.json();
if (data.success) {
userResult.textContent = 'User erfolgreich angelegt!';
if (window.loadUsers) window.loadUsers();
} else {
userResult.textContent = data.error || 'Fehler beim Anlegen.';
}
} catch (err) {
userResult.textContent = 'Serverfehler.';
}
});
}
const profileSaveBtn = document.getElementById('profileSaveBtn');
if (profileSaveBtn) {
profileSaveBtn.addEventListener('click', async () => {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const newPasswordRepeat = document.getElementById('newPasswordRepeat').value;
const profileResult = document.getElementById('profileResult');
if (!currentPassword || !newPassword) {
profileResult.textContent = 'Aktuelles und neues Passwort erforderlich.';
return;
}
if (newPassword !== newPasswordRepeat) {
profileResult.textContent = 'Die Wiederholung stimmt nicht mit dem neuen Passwort überein.';
return;
}
try {
const res = await fetch('/api/changePassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (data.success) {
profileResult.textContent = 'Passwort erfolgreich geändert!';
document.getElementById('profileForm').reset();
} else {
profileResult.textContent = data.error || 'Fehler beim Ändern.';
}
} catch (err) {
profileResult.textContent = 'Serverfehler.';
}
});
}
});
function updateSortArrows() {
const arrows = {
sensorNr: document.getElementById('sortArrowSensorNr'),
espId: document.getElementById('sortArrowEspId'),
date: document.getElementById('sortArrowDate')
};
Object.entries(arrows).forEach(([key, el]) => {
if (!el) return;
// Aktiver Pfeil fett, andere ausgegraut
el.textContent = currentSort.key === key
? (currentSort.asc ? '↑' : '↓')
: '↑';
el.style.fontWeight = currentSort.key === key ? 'bold' : 'normal';
el.style.opacity = currentSort.key === key ? '1' : '0.3';
});
}
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
const refreshBtn = document.getElementById('refreshBtn');
const espIdInput = document.getElementById('espId');
const sensorNumberInput = document.getElementById('sensorNumber');
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
const addressInput = document.getElementById('address');
const pageInput = document.getElementById('page');
const limitInput = document.getElementById('limit');
const resultDiv = document.getElementById('result');
const tableBody = document.querySelector('#entriesTable tbody');
const tabInput = document.getElementById('tabInput');
const tabList = document.getElementById('tabList');
const cancelBtn = document.getElementById('cancelBtn');
const userCancelBtn = document.getElementById('userCancelBtn');
// Modal für Fehleranzeige
function showModal(message, showCancelButton, callback) {
// Remove previous modals
document.querySelectorAll('.custom-modal-popup').forEach(m => m.remove());
let modal = document.createElement('div');
modal.className = 'custom-modal-popup';
let box = document.createElement('div');
box.className = 'custom-modal-box';
let msg = document.createElement('div');
msg.className = 'custom-modal-msg';
msg.textContent = message;
box.appendChild(msg);
let btndiv = document.createElement('div')
btndiv.className = 'twobuttons'
// Cancel Button (only if showCancelButton is true)
if (showCancelButton) {
let btnCancel = document.createElement('button');
btnCancel.className = 'custom-modal-btn';
btnCancel.textContent = 'Abbruch';
btnCancel.onclick = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(false); // Pass false for Cancel
};
btndiv.appendChild(btnCancel);
}
// OK Button
let btnOk = document.createElement('button');
btnOk.className = 'custom-modal-btn';
btnOk.textContent = 'OK';
btnOk.onclick = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(true); // Pass true for OK
};
btndiv.appendChild(btnOk);
box.appendChild(btndiv)
modal.appendChild(box);
document.body.appendChild(modal);
// Optional: Close modal when clicking outside
modal.onclick = (e) => {
if (e.target === modal) {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(false); // Treat as cancel
}
};
}
// Modal für Passwort-Eingabe (z.B. Passwort-Reset aus der User-Liste)
function showInputModal(message, callback) {
document.querySelectorAll('.custom-modal-popup').forEach(m => m.remove());
let modal = document.createElement('div');
modal.className = 'custom-modal-popup';
let box = document.createElement('div');
box.className = 'custom-modal-box';
let msg = document.createElement('div');
msg.className = 'custom-modal-msg';
msg.textContent = message;
box.appendChild(msg);
let input = document.createElement('input');
input.type = 'password';
input.className = 'custom-modal-input';
box.appendChild(input);
let btndiv = document.createElement('div');
btndiv.className = 'twobuttons';
const close = () => {
if (modal.parentNode) modal.parentNode.removeChild(modal);
};
let btnCancel = document.createElement('button');
btnCancel.className = 'custom-modal-btn';
btnCancel.textContent = 'Abbruch';
btnCancel.onclick = () => {
close();
callback(null);
};
btndiv.appendChild(btnCancel);
let btnOk = document.createElement('button');
btnOk.className = 'custom-modal-btn';
btnOk.textContent = 'OK';
btnOk.onclick = () => {
const value = input.value;
close();
callback(value || null);
};
btndiv.appendChild(btnOk);
box.appendChild(btndiv);
modal.appendChild(box);
document.body.appendChild(modal);
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
btnOk.click();
}
});
modal.onclick = (e) => {
if (e.target === modal) {
close();
callback(null);
}
};
}
// Sensornummer nur Zahlen erlauben
sensorNumberInput.addEventListener('input', () => {
sensorNumberInput.value = sensorNumberInput.value.replace(/\D/g, '');
});
// Adresse vom Server holen, wenn Enter oder Feld verlassen
async function fetchAddressIfValid() {
const value = sensorNumberInput.value.trim();
if (value.length > 0) {
try {
const res = await fetch(`/api/address/${value}`);
const data = await res.json();
console.dir(data)
if (!data.error && data.address) {
addressInput.value = data.address;
// Felder automatisch füllen, wenn props vorhanden
if (!data.props.error) {
if (Object.hasOwn(data.props, "chip")) {
let pp = data.props.chip
espIdInput.value = pp.id || ''
nameInput.value = pp.name || ''
descriptionInput.value = pp.description || ''
// Weitere Felder nach Bedarf
}
}
} else {
addressInput.value = '';
sensorNumberInput.disabled = true;
showModal('Sensor unbekannt', false, () => {
sensorNumberInput.disabled = false;
sensorNumberInput.focus();
});
}
} catch (err) {
console.error('Fehler beim Abrufen der Adresse:', err);
addressInput.value = '';
sensorNumberInput.disabled = true;
showModal('Sensor unbekannt', false, () => {
sensorNumberInput.disabled = false;
sensorNumberInput.focus();
});
}
}
}
// Enter-Taste
sensorNumberInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
fetchAddressIfValid();
}
});
// Feld verlassen
sensorNumberInput.addEventListener('blur', fetchAddressIfValid);
async function saveEntry() {
const espId = espIdInput.value.trim();
const sensorNumber = sensorNumberInput.value.trim();
const name = nameInput.value.trim();
const description = descriptionInput.value.trim();
const address = addressInput.value.trim();
if (!espId || !sensorNumber) {
resultDiv.textContent = 'ESP-ID und Sensornummer sind Pflichtfelder.';
return;
}
try {
const url = '/api/save';
const method = 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ espId, sensorNumber, name, description, address })
});
const data = await res.json();
if (data.error) {
resultDiv.textContent = data.error;
} else {
resultDiv.textContent = 'OK!';
setTimeout(() => {
resultDiv.textContent = ''
saveBtn.textContent = 'Speichern';
}, 5000)
clearForm(false);
await loadEntries();
}
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Speichern.';
}
}
function clearForm(mitButton) {
espIdInput.value = '';
sensorNumberInput.value = '';
nameInput.value = '';
descriptionInput.value = '';
addressInput.value = '';
if (mitButton) {
saveBtn.textContent = 'Speichern';
}
}
const clearUserForm = () => {
document.getElementById('username').value = ''
document.getElementById('password').value = ''
document.getElementById('role').value = 'user'
}
// Globale Sortier-Variable
window.currentSort = window.currentSort || { key: null, asc: true };
async function loadEntries() {
const page = parseInt(pageInput.value) || 1;
const limit = parseInt(limitInput.value) || 50;
try {
const res = await fetch(`/api/list?page=${page}&limit=${limit}`);
const erg = await res.json();
const items = erg.items
const gz = document.getElementById('gzahl');
gz.innerHTML = `Gesamtzahl: ${erg.anzahl}`
let currentSort = window.currentSort || { key: null, asc: true };
function renderTable(sortedItems) {
tableBody.innerHTML = '';
sortedItems.forEach(item => {
const date = new Date(item.chip.lastUpdatedAt).toISOString().split('T')[0];
const tr = document.createElement('tr');
tr.innerHTML = `
<td id="tdSensornumber">${item._id}</td>
<td>${item.chip.id}</td>
<td>${item.chip.name || ''}</td>
<td id="tdBeschreibung">${item.chip.description || ''}</td>
<td id="tdDate">${date}</td>
<td>
<div class="twobuttons">
<button data-id="${item._id}" class="editBtn" title="Bearbeiten">✏️</button>
<button data-id="${item._id}" class="deleteBtn" title="Löschen">🗑️</button>
</div>
</td>
`;
tableBody.appendChild(tr);
});
}
function sortItems(items, key, asc) {
return items.slice().sort((a, b) => {
let valA, valB;
if (key === 'sensorNr') {
valA = a._id;
valB = b._id;
} else if (key === 'espId') {
valA = a.chip.id;
valB = b.chip.id;
} else if (key === 'date') {
valA = new Date(a.chip.lastUpdatedAt);
valB = new Date(b.chip.lastUpdatedAt);
}
if (valA < valB) return asc ? -1 : 1;
if (valA > valB) return asc ? 1 : -1;
return 0;
});
}
// Initial render: Standard nach SensorNr, ESP-ID oder Datum aufsteigend
// Ändere hier die Spalte für die Standardsortierung:
const defaultSortKey = window.currentSort && window.currentSort.key ? window.currentSort.key : 'sensorNr';
const defaultSortAsc = window.currentSort && typeof window.currentSort.asc === 'boolean' ? window.currentSort.asc : true;
currentSort.key = defaultSortKey;
currentSort.asc = defaultSortAsc;
window.currentSort = currentSort;
renderTable(sortItems(items, defaultSortKey, defaultSortAsc));
updateSortArrows();
// Add sort listeners
document.getElementById('thSensorNr').onclick = () => {
currentSort.asc = currentSort.key === 'sensorNr' ? !currentSort.asc : true;
currentSort.key = 'sensorNr';
window.currentSort = currentSort;
renderTable(sortItems(items, 'sensorNr', currentSort.asc));
updateSortArrows();
};
document.getElementById('thEspId').onclick = () => {
currentSort.asc = currentSort.key === 'espId' ? !currentSort.asc : true;
currentSort.key = 'espId';
window.currentSort = currentSort;
renderTable(sortItems(items, 'espId', currentSort.asc));
updateSortArrows();
};
document.getElementById('thDate').onclick = () => {
currentSort.asc = currentSort.key === 'date' ? !currentSort.asc : true;
currentSort.key = 'date';
window.currentSort = currentSort;
renderTable(sortItems(items, 'date', currentSort.asc));
updateSortArrows();
};
document.querySelectorAll('.deleteBtn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
await deleteEntry(id);
});
});
document.querySelectorAll('.editBtn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
const res = await fetch(`/api/list?page=1&limit=1&id=${id}`);
const items = await res.json();
const item = items.find(e => e._id === parseInt(id));
if (item) {
espIdInput.value = item.chip.id;
sensorNumberInput.value = item._id;
nameInput.value = item.chip.name || '';
descriptionInput.value = item.chip.description || '';
addressInput.value = '';
saveBtn.textContent = 'Aktualisieren';
showTab('input')
try {
const rt = await fetch(`api/holAdresse/${item._id}`)
const data = await rt.json();
console.dir(data)
if (!data.error && data.address) {
addressInput.value = data.address;
}
} catch (e) {
console.log("Fehler beim Adresse holen", e)
}
}
});
});
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Laden.';
}
}
async function deleteEntry(id) {
showModal('Wirklich löschen?', true, async (confirmed) => {
if (confirmed) {
try {
const res = await fetch(`/api/delete/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
await loadEntries();
resultDiv.textContent = 'Eintrag gelöscht.';
setTimeout(() => resultDiv.textContent = '', 3000);
} else {
resultDiv.textContent = 'Fehler beim Löschen.';
}
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Löschen.';
}
}
});
}
async function loadUsers() {
const usersTableBody = document.querySelector('#usersTable tbody');
const usersResult = document.getElementById('usersResult');
if (!usersTableBody) return;
try {
const res = await fetch('/api/users');
const users = await res.json();
usersTableBody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.email}</td>
<td>${u.role}</td>
<td>
<div class="twobuttons">
<button data-id="${u._id}" data-email="${u.email}" class="editBtn resetUserBtn" title="Passwort zurücksetzen">🔑</button>
<button data-id="${u._id}" class="deleteBtn deleteUserBtn" title="Löschen">🗑️</button>
</div>
</td>
`;
usersTableBody.appendChild(tr);
});
document.querySelectorAll('.resetUserBtn').forEach(btn => {
btn.addEventListener('click', () => {
const email = btn.getAttribute('data-email');
showInputModal(`Neues Passwort für ${email}:`, async (newPassword) => {
if (!newPassword) return;
try {
const res = await fetch('/api/resetPassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, newPassword })
});
const data = await res.json();
usersResult.textContent = data.success ? `Passwort für ${email} zurückgesetzt.` : (data.error || 'Fehler beim Zurücksetzen.');
} catch (err) {
usersResult.textContent = 'Serverfehler.';
}
});
});
});
document.querySelectorAll('.deleteUserBtn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-id');
showModal('User wirklich löschen?', true, async (confirmed) => {
if (!confirmed) return;
try {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
usersResult.textContent = 'User gelöscht.';
await loadUsers();
} else {
usersResult.textContent = data.error || 'Fehler beim Löschen.';
}
} catch (err) {
usersResult.textContent = 'Serverfehler.';
}
});
});
});
} catch (err) {
console.error(err);
}
}
saveBtn.addEventListener('click', saveEntry);
refreshBtn.addEventListener('click', loadEntries);
cancelBtn.addEventListener('click', () => clearForm(true));
if (userCancelBtn) userCancelBtn.addEventListener('click', () => clearUserForm(true));
tabInput.addEventListener('click', () => showTab('input'))
tabList.addEventListener('click', () => showTab('list'))
const tabUser = document.getElementById('tabUser');
if (tabUser) tabUser.addEventListener('click', () => {
showTab('user');
clearUserForm();
const userResult = document.getElementById('userResult');
if (userResult) userResult.textContent = '';
const usersResult = document.getElementById('usersResult');
if (usersResult) usersResult.textContent = '';
loadUsers();
})
loadEntries();
window.loadUsers = loadUsers;
});
window.showTab = showTab;
+34
View File
@@ -0,0 +1,34 @@
// public/login.js
document.addEventListener('DOMContentLoaded', () => {
const emailInput = document.getElementById('email');
const emailStatus = document.getElementById('emailStatus');
let debounceTimeout;
emailInput.addEventListener('input', () => {
clearTimeout(debounceTimeout);
const email = emailInput.value.trim();
if (!email) {
emailStatus.textContent = '';
return;
}
debounceTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const data = await res.json();
if (data.exists) {
emailStatus.textContent = '✅ Benutzer existiert';
emailStatus.style.color = 'green';
} else {
emailStatus.textContent = '❌ Benutzer nicht gefunden';
emailStatus.style.color = 'red';
}
} catch (err) {
console.error(err);
emailStatus.textContent = 'Fehler bei der Prüfung';
emailStatus.style.color = 'orange';
}
}, 300);
});
});
+261
View File
@@ -0,0 +1,261 @@
*, *::before, *::after {
box-sizing: border-box;
}
/* Tab Navigation */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab-btn {
background: #eee;
border: none;
padding: 0.7rem 2rem;
font-size: 1.1rem;
border-radius: 6px 6px 0 0;
cursor: pointer;
color: #333;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.tab-btn.active {
background: #007bff;
color: #fff;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
}
#tabUser {
margin-left: 50px;
}
#logoutBtn {
margin-left: auto;
text-decoration: none;
}
/* Modal Fehlerfenster */
.custom-modal-popup {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.custom-modal-box {
background: #fff;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
text-align: center;
min-width: 350px;
max-width: 90vw;
}
.custom-modal-msg {
margin-bottom: 2rem;
font-size: 1.5rem;
color: red;
}
.custom-modal-input {
display: block;
width: 80%;
margin: 0 auto 1.5rem auto;
}
.custom-modal-btn {
padding: 0.8rem 2.5rem;
font-size: 1.1rem;
background: #007bff;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
body {
font-family: system-ui, sans-serif;
padding: 20px;
max-width: 800px;
margin: auto;
}
input, button {
font-size: 1rem;
padding: 8px;
margin: 2px;
}
.card {
border: 1px solid #ddd;
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
background-color: antiquewhite;
}
#result {
margin-top: 10px;
}
.controls {
margin-bottom: 8px;
}
.controls input {
width: 50px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 30px;
}
th, td {
text-align: left;
padding: 4px;
border-bottom: 1px solid #888;
}
/* Spaltenbreiten über colgroup steuern */
col.col-sensornumber { width: 7em; }
col.col-espid {width: 9em}
col.col-bezeichnung { width: 8em; }
col.col-beschreibung{ width: 15em; }
col.col-date { width: 10em; }
col.col-aktionen { width: 2em; }
.controls input#page,
.controls input#limit {
width: 50px;
margin-left: 10px;
}
.error {
color: red;
font-weight: bold;
margin: 10px 0;
}
form {
max-width: 400px;
margin: 0 auto;
padding: 1rem;
border-radius: 8px;
background: #f9f9f9;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color:burlywood
}
label {
font-weight: bold;
}
input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.6rem;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
font-size: 1rem;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
.editBtn, .deleteBtn {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s ease;
margin: 0 2px;
}
.editBtn:hover {
background: rgba(0, 123, 255, 0.1);
transform: scale(1.1);
}
.deleteBtn:hover {
background: rgba(220, 53, 69, 0.1);
transform: scale(1.1);
}
.twobuttons {
display: flex;
justify-content: space-between;
width: 100%;
gap: 5px;
}
p.error {
color: red;
font-weight: bold;
text-align: center;
}
.card form {
display: flex;
flex-direction: column;
align-items: flex-start; /* Links bündig */
gap: 0.5rem;
}
.card form label {
font-weight: bold;
}
.card form input,
.card form textarea {
width: 100%;
max-width: 400px; /* gleiche Breite */
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.card form textarea {
min-height: 60px;
resize: vertical;
}
#gzahl {
margin-left: 30px;
}
#role {
font-size: 12pt;
padding: 5px 0 5px 3px;
margin-bottom: 20px;
}
#version {
width: 100%;
display: flex;
justify-content: flex-end;
font-size: 70%;
color: #007bff;
margin-top: 15px;
}
+64
View File
@@ -0,0 +1,64 @@
import { MongoClient } from 'mongodb';
import { get_pflux } from '../db/mongo.js';
import { getCollections, update_pflux, clientClose } from '../db/mongo.js';
export function registerAddressRoute(app, requireLogin) {
const APIHOST = process.env.APIHOST || 'https://noise.fuerst-stuttgart.de/srv/';
const holAdresse = async (id) => {
// Adresse wie bisher holen (über die Sensornummer via nominative)
let addressString = '';
let addrParts = {};
try {
const url = APIHOST + 'getaddress/' + `?sensorid=${id}`;
console.log(url)
const r = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (r.ok) {
const data = await r.json();
const addrObj = data?.erg?.address || data?.address || {};
const street = addrObj.street ?? '';
const plz = addrObj.plz ?? '';
const city = addrObj.city ?? '';
const rightPart = [plz, city].filter(Boolean).join(' ').trim();
addressString = [street, rightPart].filter(Boolean).join(', ');
addrParts = { street, plz, city };
}
} catch (err) {
console.error('Address lookup failed:', err);
}
return {
address: addressString,
parts: addrParts,
}
}
app.get('/api/holAdresse/:sensorNumber', requireLogin, async (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
const addr = await holAdresse(sensorNumber)
res.json(addr)
})
app.get('/api/address/:sensorNumber', requireLogin, async (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
const { propCollection, prop_fluxCollection } = getCollections();
// Suche nach Sensornummer als _id
const propEntry = await propCollection.findOne({ _id: sensorNumber });
if (!propEntry) {
await clientClose()
return res.status(404).json({ error: 'Sensor nicht gefunden' });
}
const adr = await holAdresse(encodeURIComponent(propEntry._id))
adr.props = propEntry
return res.json(adr)
})
}
+155
View File
@@ -0,0 +1,155 @@
import { MongoAPIError, ObjectId } from 'mongodb';
import bcrypt from 'bcrypt';
import { getCollections, update_pflux } from '../db/mongo.js';
export function registerApiRoutes(app, requireLogin) {
const { usersCollection, propCollection } = getCollections();
app.get('/api/check-email', async (req, res) => {
const email = (req.query.email || '').toLowerCase().trim();
if (!email) return res.json({ exists: false });
try {
const existingUser = await usersCollection.findOne({ email:`${email}` });
res.json({ exists: !!existingUser });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler bei der E-Mail-Prüfung' });
}
});
app.post('/api/save', requireLogin, async (req, res) => {
let { espId, sensorNumber, name, description} = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
try {
const doc = {
id: espId,
name: name || '',
description: description || '',
lastUpdatedAt: new Date()
};
await update_pflux(sensorNumber, doc)
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler beim Speichern' });
}
});
app.get('/api/list', requireLogin, async (req, res) => {
const { id } = req.query;
if (id) {
try {
const item = await propCollection.findOne({ _id: parseInt(id) });
if (item) return res.json([item]);
return res.json([]);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Fehler beim Laden' });
}
}
let gesamtZahl = 0
try {
gesamtZahl = await propCollection.countDocuments({chip: {$exists: true}})
} catch (err) {
console.error(err);
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const skip = (page - 1) * limit;
try {
const items = await propCollection.find({chip: {$exists: true}})
.sort({ "chip.lastUpdatedAt": -1 })
.skip(skip)
.limit(limit)
.toArray();
const data = {items: items, anzahl: gesamtZahl}
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler beim Laden' });
}
});
app.delete('/api/delete/:id', requireLogin, async (req, res) => {
await propCollection.deleteOne({ _id: parseInt(req.params.id) });
res.json({ success: true });
});
app.post('/api/createUser', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
try {
const hash = await bcrypt.hash(password, 10);
await usersCollection.insertOne({ email: username.toLowerCase(), passwordHash: hash, role: role || 'user' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Anlegen' });
}
});
app.post('/api/changePassword', requireLogin, async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich' });
}
try {
const user = await usersCollection.findOne({ _id: new ObjectId(req.session.userId) });
if (!user) return res.status(404).json({ error: 'User nicht gefunden' });
const match = await bcrypt.compare(currentPassword, user.passwordHash);
if (!match) return res.status(403).json({ error: 'Aktuelles Passwort ist falsch' });
const hash = await bcrypt.hash(newPassword, 10);
await usersCollection.updateOne({ _id: user._id }, { $set: { passwordHash: hash } });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Ändern des Passworts' });
}
});
app.post('/api/resetPassword', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { email, newPassword } = req.body;
if (!email || !newPassword) return res.status(400).json({ error: 'Email und neues Passwort erforderlich' });
try {
const hash = await bcrypt.hash(newPassword, 10);
const result = await usersCollection.updateOne(
{ email: email.toLowerCase() },
{ $set: { passwordHash: hash } }
);
if (result.matchedCount === 0) return res.status(404).json({ error: 'User nicht gefunden' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Zurücksetzen' });
}
});
app.get('/api/users', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
try {
const users = await usersCollection.find({}, { projection: { email: 1, role: 1 } }).sort({ email: 1 }).toArray();
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Fehler beim Laden der User' });
}
});
app.delete('/api/users/:id', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { id } = req.params;
if (id === String(req.session.userId)) {
return res.status(400).json({ error: 'Der eigene Account kann nicht gelöscht werden' });
}
try {
const result = await usersCollection.deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) return res.status(404).json({ error: 'User nicht gefunden' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen' });
}
});
}
+40
View File
@@ -0,0 +1,40 @@
import bcrypt from 'bcrypt';
import { getCollections } from '../db/mongo.js';
import pkg from '../package.json' with { type: "json" }
export function registerAuthRoutes(app) {
const { usersCollection } = getCollections();
const errText = 'Falsche Email oder falsches Passwort.'
app.get('/login', async (req, res) => {
const version = pkg.version
const vdate = pkg.date
const noUsers = (await usersCollection.countDocuments()) === 0;
res.render('login', {
error: null,
version: version,
vdate: vdate,
noUsers: noUsers
})
});
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const version = pkg.version
const vdate = pkg.date
const noUsers = (await usersCollection.countDocuments()) === 0;
const renderError = () => res.render('login', { error: errText, version, vdate, noUsers });
const user = await usersCollection.findOne({ email: email.toLowerCase() });
if (!user) return renderError();
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) return renderError();
req.session.userId = user._id;
req.session.isAdmin = user.role === 'admin';
res.redirect('/');
});
app.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
}
+87
View File
@@ -0,0 +1,87 @@
import express from 'express';
import session from 'express-session';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import pkg from './package.json' with { type: "json" }
dotenv.config();
import { initMongo, getCollections } from './db/mongo.js';
import { registerApiRoutes } from './routes/api.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerAddressRoute } from './routes/address.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const SESSION_SECRET = process.env.SESSION_SECRET || 'supersecret';
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 }
}));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
// DB verbinden
await initMongo();
await bootstrapAdmin();
// Legt beim allerersten Start (leere user-Collection) einen Admin aus
// ADMIN_EMAIL/ADMIN_PASSWORD an - notwendig für extern verwaltete Deployments
// ohne direkten Mongo-Zugriff (z.B. per Portainer betriebene Server).
async function bootstrapAdmin() {
const { usersCollection } = getCollections();
const count = await usersCollection.countDocuments();
if (count > 0) return;
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
console.warn('Keine User in der Datenbank und ADMIN_EMAIL/ADMIN_PASSWORD nicht gesetzt — es kann sich niemand einloggen.');
return;
}
const passwordHash = await bcrypt.hash(password, 10);
await usersCollection.insertOne({ email: email.toLowerCase(), passwordHash, role: 'admin' });
console.log(`Bootstrap-Admin angelegt: ${email.toLowerCase()}`);
}
// Login-Middleware
function requireLogin(req, res, next) {
// Entwicklungs-Bypass: Automatischer Login als Admin
if (process.env.DEV_AUTO_LOGIN === 'true') {
req.session.userId = 'dev-user';
req.session.isAdmin = true;
return next();
}
if (req.session.userId) {
return next();
}
res.redirect('/login');
}
// Routen registrieren
registerAuthRoutes(app);
registerApiRoutes(app, requireLogin);
registerAddressRoute(app, requireLogin);
// Hauptseite
app.get('/', requireLogin, (req, res) => {
const version = pkg.version
const vdate = pkg.date
const isAdmin = req.session && req.session.isAdmin;
res.render('index', { isAdmin, version, vdate });
});
app.listen(PORT, () => console.log(`Server läuft auf http://localhost:${PORT}`));
+166
View File
@@ -0,0 +1,166 @@
const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
// ...existing code...
// tests/server.test.js
describe('Server.js API', () => {
let app;
let entries = [];
let users = [];
beforeEach(() => {
app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Mock session middleware
app.use((req, res, next) => { req.session = {}; next(); });
// /api/check-email
app.get('/api/check-email', (req, res) => {
const email = (req.query.email || '').toLowerCase().trim();
if (!email) return res.json({ exists: false });
const existingUser = users.find(u => u.email === email);
res.json({ exists: !!existingUser });
});
// /api/save
app.post('/api/save', (req, res) => {
let { espId, sensorNumber, name, description, address } = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
const doc = { espId, sensorNumber, name, description, address, lastUpdatedAt: new Date(), _id: String(entries.length + 1) };
entries.push(doc);
res.json({ success: true });
});
// /api/update/:id
app.put('/api/update/:id', (req, res) => {
const { id } = req.params;
let { espId, sensorNumber, name, description, address } = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
const idx = entries.findIndex(e => e._id === id);
if (idx === -1) return res.status(404).json({ error: 'Not found' });
entries[idx] = { ...entries[idx], espId, sensorNumber, name, description, address };
res.json({ success: true });
});
// /api/list
app.get('/api/list', (req, res) => {
const { id } = req.query;
if (id) {
const item = entries.find(e => e._id === id);
return res.json(item ? [item] : []);
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
res.json(entries.slice(skip, skip + limit));
});
// /api/delete/:id
app.delete('/api/delete/:id', (req, res) => {
const { id } = req.params;
entries = entries.filter(e => e._id !== id);
res.json({ success: true });
});
// /api/address/:sensorNumber
app.get('/api/address/:sensorNumber', (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
// Dummy logic
if (sensorNumber === 1001) {
return res.json({ address: 'Musterstraße 1, 12345 Musterstadt', parts: { street: 'Musterstraße 1', plz: '12345', city: 'Musterstadt' } });
}
return res.status(404).json({ error: 'Sensor unbekannt' });
});
});
beforeEach(() => {
entries = [];
users = [{ email: 'test@example.com', passwordHash: 'hash' }];
});
test('GET /api/check-email returns exists: true for known user', async () => {
const res = await request(app).get('/api/check-email?email=test@example.com');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('exists', true);
});
test('GET /api/check-email returns exists: false for unknown user', async () => {
const res = await request(app).get('/api/check-email?email=unknown@example.com');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('exists', false);
});
test('POST /api/save creates entry', async () => {
const res = await request(app).post('/api/save').send({ espId: 'esp1', sensorNumber: '1001', name: 'Test', description: 'Desc', address: 'Addr' });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(entries.length).toBe(1);
});
test('POST /api/save fails without espId', async () => {
const res = await request(app).post('/api/save').send({ sensorNumber: '1001' });
expect(res.body).toHaveProperty('error');
});
test('PUT /api/update/:id updates entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).put('/api/update/1').send({ espId: 'esp2', sensorNumber: '1002', name: 'Neu', description: 'Neu', address: 'Neu' });
expect(res.body).toHaveProperty('success', true);
expect(entries[0].espId).toBe('esp2');
});
test('PUT /api/update/:id fails for unknown id', async () => {
const res = await request(app).put('/api/update/999').send({ espId: 'esp2', sensorNumber: '1002', name: 'Neu', description: 'Neu', address: 'Neu' });
expect(res.statusCode).toBe(404);
});
test('GET /api/list returns all entries', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).get('/api/list');
expect(res.body.length).toBe(1);
});
test('GET /api/list?id returns specific entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).get('/api/list?id=1');
expect(res.body.length).toBe(1);
expect(res.body[0]._id).toBe('1');
});
test('DELETE /api/delete/:id deletes entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).delete('/api/delete/1');
expect(res.body).toHaveProperty('success', true);
expect(entries.length).toBe(0);
});
test('GET /api/address/:sensorNumber returns address for known sensor', async () => {
const res = await request(app).get('/api/address/1001');
expect(res.body).toHaveProperty('address', 'Musterstraße 1, 12345 Musterstadt');
});
test('GET /api/address/:sensorNumber returns error for unknown sensor', async () => {
const res = await request(app).get('/api/address/9999');
expect(res.statusCode).toBe(404);
expect(res.body).toHaveProperty('error', 'Sensor unbekannt');
});
test('GET /api/address/:sensorNumber returns error for invalid sensor', async () => {
const res = await request(app).get('/api/address/abc');
expect(res.statusCode).toBe(400);
expect(res.body).toHaveProperty('error', 'Ungültige Sensornummer');
});
});
+118
View File
@@ -0,0 +1,118 @@
doctype html
html(lang="de")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title ESP-ID zu Sensornummer
link(rel="stylesheet", href="/styles.css")
body
h1 ESP-ID → Sensornummer
// Tab Navigation
div.tabs
button.tab-btn#tabInput.active(type="button" onclick="showTab('input')") Eingabe
button.tab-btn#tabList(type="button" onclick="showTab('list')") Liste
button.tab-btn#tabProfile(type="button" onclick="showTab('profile')") Profil
if isAdmin
button.tab-btn#tabUser(type="button" onclick="showTab('user')") User
a.tab-btn#logoutBtn(href="/logout") Logout
// Eingabe-Tab
div#tabInputContent.tab-content
div.card
form#entryForm
label(for="sensorNumber") Sensornummer:
input#sensorNumber(type="text" placeholder="Nur Zahlen erlaubt")
label(for="espId") ESP-ID:
input#espId(type="text")
label(for="name") Bezeichnung:
input#name(type="text")
label(for="description") Beschreibung:
textarea#description
label(for="address") Anschrift:
input#address(type="text" placeholder="Wird automatisch ausgefüllt" readonly disabled)
.twobuttons
button#saveBtn(type="button") Speichern
button#cancelBtn(type="button") Abbrechen
div#result
#version Version: #{version} vom #{vdate}
// Listen-Tab
div#tabListContent.tab-content(style="display:none")
div.controls
button#refreshBtn Aktualisieren
| Seite:
input#page(value="1")
| Limit:
input#limit(value="50")
span#gzahl
table#entriesTable
colgroup
col.col-sensornumber
col.col-espid
col.col-bezeichnung
col.col-beschreibung
col.col-date
col.col-aktionen
thead
tr
th(id="thSensorNr" data-sort="sensorNr" style="cursor:pointer") SensorNr <span id="sortArrowSensorNr">↑</span>
th(id="thEspId" data-sort="espId" style="cursor:pointer") ESP-ID <span id="sortArrowEspId">↑</span>
th Bezeichnung
th Beschreibung
th(id="thDate" data-sort="date" style="cursor:pointer") Datum <span id="sortArrowDate">↑</span>
th Aktionen
tbody
// Profil-Tab (für alle eingeloggten User)
div#tabProfileContent.tab-content(style="display:none")
div.card
h2 Passwort ändern
form#profileForm
label(for="currentPassword") Aktuelles Passwort:
input#currentPassword(type="password" required)
label(for="newPassword") Neues Passwort:
input#newPassword(type="password" required)
label(for="newPasswordRepeat") Neues Passwort (Wiederholung):
input#newPasswordRepeat(type="password" required)
.twobuttons
button#profileSaveBtn(type="button") Ändern
div#profileResult
#version Version: #{version} vom #{vdate}
// User-Tab (nur für Admins)
if isAdmin
div#tabUserContent.tab-content(style="display:none")
div.card
h2 Neuen User anlegen
form#userForm
label(for="username") Benutzername:
input#username(type="text" required)
label(for="password") Passwort:
input#password(type="password" required)
label(for="role") Rolle:
select#role
option(value="user") User
option(value="admin") Admin
.twobuttons
button#userSaveBtn(type="button") Anlegen
button#userCancelBtn(type="button") Abbrechen
div#userResult
div.card
h2 Angemeldete User
table#usersTable
thead
tr
th Email
th Rolle
th Aktionen
tbody
div#usersResult
#version Version: #{version} vom #{vdate}
script(type="module" src="/global.js")
+27
View File
@@ -0,0 +1,27 @@
doctype html
html(lang="de")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title Login
link(rel="stylesheet", href="/styles.css")
body
h1 ESP-ID → Sensornummer
div.card
h2 Login
form(method="POST" action="/login")
label(for="email") E-Mail:
input#email(type="email" name="email" required)
span#emailStatus
label(for="password") Passwort:
input#password(type="password" name="password" required)
button(type="submit") Login
p.hint Passwort vergessen? Bitte an einen Admin wenden.
if noUsers
p.hint
strong Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten.
#version Version: #{version} vom #{vdate}
if error
p.error= error
script(type="module" src="/login.js")
+3 -25
View File
@@ -6,11 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu, # Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise. # eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise.
REGISTRY="docker.citysensor.de" # Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es # REGISTRY=anderes.registry.example ./deploy.sh
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab: REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
# REGISTRY2= ./deploy.sh
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
IMAGE_NAME="noise" IMAGE_NAME="noise"
TAG="${1:-latest}" TAG="${1:-latest}"
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
@@ -54,26 +52,6 @@ if [ "${TAG}" != "latest" ]; then
echo "" echo ""
fi fi
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
if [ -n "${REGISTRY2}" ]; then
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
ok=true
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
fi
if [ "${ok}" != true ]; then
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
fi
echo ""
fi
echo "==========================================" echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}" echo "Build + Push fertig: ${FULL_IMAGE}"
echo "==========================================" echo "=========================================="
-1
View File
@@ -26,7 +26,6 @@ html
footer footer
#author #author
#mailadr #mailadr
a(href="mailto:rexfue@gmail.com") mailto:rexfue@gmail.com
#versn #{t('Version')}: #{version} #{t('from')} #{date} #versn #{t('Version')}: #{version} #{t('from')} #{date}
script(src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js" script(src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
+7 -1
View File
@@ -9,9 +9,15 @@ MONGO_ROOT_PASSWORD=<Mongo_Passwort>
LOCALDIR=<Aktuelle Direcrory ab /> LOCALDIR=<Aktuelle Direcrory ab />
# Datenbank, in welche die laufenden Messwerte gespeichert werden (kannn 'influx' oder 'mongo' sein) # Datenbank, in welche die laufenden Messwerte gespeichert werden (kann 'influx', 'mongo' oder 'victoria' sein)
STORE=influx STORE=influx
# Datenbank, aus welcher sensorapi die Messwerte liest (kann 'influx', 'mongo' oder 'victoria' sein)
DBASE=influx
# Secret zum Signieren der Session-Cookies von esp2sensor (langer Zufallsstring)
ESP2SENSOR_SESSION_SECRET=<zufaelliger_string>
# Nur fuer readarchive-stack.yml: Zeitraum und Ziel des Einmal-Laufs. # Nur fuer readarchive-stack.yml: Zeitraum und Ziel des Einmal-Laufs.
# Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx). # Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx).
# END ist exklusiv. # END ist exklusiv.
+39 -9
View File
@@ -26,20 +26,33 @@ services:
DOCKER_INFLUXDB_INIT_ORG: citysensor DOCKER_INFLUXDB_INIT_ORG: citysensor
DOCKER_INFLUXDB_INIT_BUCKET: sensor_data DOCKER_INFLUXDB_INIT_BUCKET: sensor_data
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${DOCKER_INFLUXTOKEN} DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${DOCKER_INFLUXTOKEN}
restart: restart:
unless-stopped unless-stopped
container_name: influxdb container_name: influxdb
victoriametrics:
image: victoriametrics/victoria-metrics:v1.102.0
ports:
- "8428:8428"
volumes:
- ${LOCALDIR}/noisesensors/data/victoria/data:/victoria-metrics-data
command:
- "--storageDataPath=/victoria-metrics-data"
- "--retentionPeriod=100y"
restart: unless-stopped
container_name: victoriametrics
readin: readin:
image: docker.citysensor.de/readin image: docker.citysensor.de/readin
environment: environment:
MONGOHOST: mongodb MONGOHOST: mongodb
INFLUXHOST: influxdb INFLUXHOST: influxdb
VICTORIAHOST: victoriametrics
TYP: "[\"noise\"]" TYP: "[\"noise\"]"
STORE: ${STORE:-mongo} STORE: ${STORE:-mongo}
MONGOAUTH: true MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD} MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN} INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
volumes: volumes:
- ${LOCALDIR}/noisesensors/log:/var/log - ${LOCALDIR}/noisesensors/log:/var/log
# Pfad im Image ist jetzt /opt/app/readin, weil readin und readarchive # Pfad im Image ist jetzt /opt/app/readin, weil readin und readarchive
@@ -67,7 +80,8 @@ services:
environment: environment:
INFLUXHOST: influxdb INFLUXHOST: influxdb
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN} INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
DBASE: influx VICTORIAHOST: victoriametrics
DBASE: ${DBASE:-influx}
MONGOHOST: mongodb MONGOHOST: mongodb
MONGOAUTH: true MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD} MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
@@ -79,8 +93,24 @@ services:
- ${LOCALDIR}/noisesensors/log:/var/log - ${LOCALDIR}/noisesensors/log:/var/log
container_name: noise container_name: noise
environment: environment:
APIHOST: http://sensorapi:3005 APIHOST: http://sensorapi:3005
ports: ports:
- "3003:3000" - "3003:3000"
restart: unless-stopped restart: unless-stopped
esp2sensor:
image: docker.citysensor.de/esp2sensor
container_name: esp2sensor
volumes:
- ${LOCALDIR}/noisesensors/log:/var/log
environment:
MONGOHOST: mongodb
MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
SESSION_SECRET: ${ESP2SENSOR_SESSION_SECRET}
DEV_AUTO_LOGIN: "false"
APIHOST: http://sensorapi:3005/api/
ports:
- "3004:3000"
restart: unless-stopped
+3 -1
View File
@@ -16,7 +16,8 @@
# #
# readFromcsv.js liest jeden Parameter auch aus einer Env-Variablen: # readFromcsv.js liest jeden Parameter auch aus einer Env-Variablen:
# -s = START (Default: gestern), -e = END (Default: Startdatum + 1 Tag, # -s = START (Default: gestern), -e = END (Default: Startdatum + 1 Tag,
# exklusiv), -t = TYP (Default: noise), -d = DBASE (Default: both), # exklusiv), -t = TYP (Default: noise), -d = DBASE ('mongo', 'influx',
# 'victoria' oder 'both' = mongo+influx; Default: both),
# -p = PARALLEL (Default: 4) - so viele Sensoren werden gleichzeitig geholt. # -p = PARALLEL (Default: 4) - so viele Sensoren werden gleichzeitig geholt.
# #
# Voraussetzungen: # Voraussetzungen:
@@ -39,6 +40,7 @@ services:
environment: environment:
MONGOHOST: mongodb MONGOHOST: mongodb
INFLUXHOST: influxdb INFLUXHOST: influxdb
VICTORIAHOST: victoriametrics
MONGOAUTH: "true" MONGOAUTH: "true"
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD} MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN} INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
+3 -25
View File
@@ -6,11 +6,9 @@ set -e
# Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil # Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil
# readarchive und readin sich die Module unter common/ teilen. # readarchive und readin sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de" # Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es # REGISTRY=anderes.registry.example ./deploy.sh
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab: REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
# REGISTRY2= ./deploy.sh
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
IMAGE_NAME="readarchive" IMAGE_NAME="readarchive"
TAG="${1:-latest}" TAG="${1:-latest}"
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
@@ -58,26 +56,6 @@ if [ "${TAG}" != "latest" ]; then
echo "" echo ""
fi fi
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
if [ -n "${REGISTRY2}" ]; then
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
ok=true
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
fi
if [ "${ok}" != true ]; then
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
fi
echo ""
fi
echo "==========================================" echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}" echo "Build + Push fertig: ${FULL_IMAGE}"
echo "==========================================" echo "=========================================="
+18 -2
View File
@@ -33,7 +33,7 @@ const DBASE = process.env.DBASE || 'both'
// Mehr als 4 bringt nichts: ab da liefert das Archiv nicht mehr schneller. // Mehr als 4 bringt nichts: ab da liefert das Archiv nicht mehr schneller.
const PARALLEL = parseInt(process.env.PARALLEL) || 4 const PARALLEL = parseInt(process.env.PARALLEL) || 4
const DATABASE = ['mongo', 'influx', 'both'] const DATABASE = ['mongo', 'influx', 'victoria', 'both']
const DEVELOP = process.env.DEVELOP || false; const DEVELOP = process.env.DEVELOP || false;
const LIVE = (process.env.LIVE == "true") || true const LIVE = (process.env.LIVE == "true") || true
@@ -47,6 +47,7 @@ import axios from 'axios'
import https from 'https' import https from 'https'
import * as mongo from '../common/mongo.js' import * as mongo from '../common/mongo.js'
import * as influx from '../common/influx_post.js' import * as influx from '../common/influx_post.js'
import * as victoria from '../common/victoria_post.js'
import fs from 'fs' import fs from 'fs'
import csv from 'csvtojson' import csv from 'csvtojson'
import pkg from './package.json' with { type: "json" } import pkg from './package.json' with { type: "json" }
@@ -67,6 +68,8 @@ const newProps = []
// Sensor/Tag-Paare, deren Influx-Write fehlgeschlagen ist - Auswertung am Ende von main() // Sensor/Tag-Paare, deren Influx-Write fehlgeschlagen ist - Auswertung am Ende von main()
const failedInfluxWrites = [] const failedInfluxWrites = []
// Sensor/Tag-Paare, deren VictoriaMetrics-Write fehlgeschlagen ist - Auswertung am Ende von main()
const failedVictoriaWrites = []
const saveList = (list) => { const saveList = (list) => {
fs.writeFileSync('data/list.json', JSON.stringify(list)) fs.writeFileSync('data/list.json', JSON.stringify(list))
@@ -397,6 +400,12 @@ async function enterOneSensorinDB(client, sid, erg, dataline, day, styp, args) {
failedInfluxWrites.push({sensorid: sid, day: day}) failedInfluxWrites.push({sensorid: sid, day: day})
} }
} }
if ((args.database === 'victoria') && (dataline !== '')) {
const ok = await victoria.victoriaWrite(dataline)
if (!ok) {
failedVictoriaWrites.push({sensorid: sid, day: day})
}
}
if((args.database === 'both') || (args.database === 'mongo')) { if((args.database === 'both') || (args.database === 'mongo')) {
if(erg.length !== 0) { if(erg.length !== 0) {
await mongo.writeDataArray(client, mongo.dataCollName(styp), erg) await mongo.writeDataArray(client, mongo.dataCollName(styp), erg)
@@ -507,7 +516,7 @@ function parse_cmdline(argv) {
console.log(" will be used and endDate = startDate + 1 day") console.log(" will be used and endDate = startDate + 1 day")
console.log(" -t sensorType: if given, only those sensors will be used; default: 'noise'"); console.log(" -t sensorType: if given, only those sensors will be used; default: 'noise'");
console.log(" allowed types: 'noise', 'radiactivity', 'pm' and 'thp'") console.log(" allowed types: 'noise', 'radiactivity', 'pm' and 'thp'")
console.log(" -d database: 'mongo', 'influx' or 'both'; default: 'both'") console.log(" -d database: 'mongo', 'influx', 'victoria' or 'both' ('both' = mongo + influx); default: 'both'")
console.log(` -p parallel: number of sensors fetched at the same time; default: ${PARALLEL}`) console.log(` -p parallel: number of sensors fetched at the same time; default: ${PARALLEL}`)
console.log(" -v version: show version"); console.log(" -v version: show version");
console.log(" -h this help text"); console.log(" -h this help text");
@@ -555,6 +564,13 @@ async function main() {
} }
process.exitCode = 1 // damit cron/docker den Fehlschlag sieht process.exitCode = 1 // damit cron/docker den Fehlschlag sieht
} }
if (failedVictoriaWrites.length > 0) {
logerror(`${failedVictoriaWrites.length} Victoria-Writes fehlgeschlagen - diese Daten fehlen in VictoriaMetrics:`)
for (const f of failedVictoriaWrites) {
logerror(` Sensor ${f.sensorid}, Tag ${f.day}`)
}
process.exitCode = 1 // damit cron/docker den Fehlschlag sieht
}
let dauer = starttime.diffNow('seconds').toObject().seconds * -1 let dauer = starttime.diffNow('seconds').toObject().seconds * -1
const duration1 = Duration.fromObject({ seconds: dauer }); const duration1 = Duration.fromObject({ seconds: dauer });
const output1 = duration1.toFormat('hh:mm:ss'); const output1 = duration1.toFormat('hh:mm:ss');
+3 -25
View File
@@ -8,11 +8,9 @@ set -e
# Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und # Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und
# readarchive sich die Module unter common/ teilen. # readarchive sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de" # Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es # REGISTRY=anderes.registry.example ./deploy.sh
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab: REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
# REGISTRY2= ./deploy.sh
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
IMAGE_NAME="readin" IMAGE_NAME="readin"
TAG="${1:-latest}" TAG="${1:-latest}"
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
@@ -60,26 +58,6 @@ if [ "${TAG}" != "latest" ]; then
echo "" echo ""
fi fi
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
if [ -n "${REGISTRY2}" ]; then
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
ok=true
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
fi
if [ "${ok}" != true ]; then
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
fi
echo ""
fi
echo "==========================================" echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}" echo "Build + Push fertig: ${FULL_IMAGE}"
echo "==========================================" echo "=========================================="
+9 -2
View File
@@ -14,13 +14,14 @@
// TYP: Ist ein Array von Strings, wenn nur einzelne Typen gespeichert werden sollen, also z.B.: // TYP: Ist ein Array von Strings, wenn nur einzelne Typen gespeichert werden sollen, also z.B.:
// ['pm', 'noise'] // ['pm', 'noise']
const TYP = process.env.TYP || '' const TYP = process.env.TYP || ''
const STORE = (process.env.STORE || 'both').toLowerCase() // 'mongo' | 'influx' | 'both' const STORE = (process.env.STORE || 'both').toLowerCase() // 'mongo' | 'influx' | 'victoria' | 'both' ('both' = mongo + influx)
import { doReadfromAPI as readin } from './readdata.js' import { doReadfromAPI as readin } from './readdata.js'
import { constructDBaseEntries as parse} from './parse.js' import { constructDBaseEntries as parse} from './parse.js'
import * as mongo from '../common/mongo.js' import * as mongo from '../common/mongo.js'
import * as influx from '../common/influx_post.js' import * as influx from '../common/influx_post.js'
import * as victoria from '../common/victoria_post.js'
import { logit, logerror } from '../common/logit.js' import { logit, logerror } from '../common/logit.js'
import { statistics } from '../common/statistics.js' import { statistics } from '../common/statistics.js'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@@ -62,6 +63,11 @@ const fetchNewData = async (args) => {
await influx.influxWrite(idata) await influx.influxWrite(idata)
} }
// write sensor data to VictoriaMetrics
if(args.victoria) {
await victoria.victoriaWrite(idata)
}
// write properties to mongoDB // write properties to mongoDB
await mongo.bulkWrite(client, mongo.property_coll, props) await mongo.bulkWrite(client, mongo.property_coll, props)
} }
@@ -78,7 +84,7 @@ const fetchNewData = async (args) => {
function parse_cmdline(argv) { function parse_cmdline(argv) {
let parser = new mod_getopt.BasicParser('i(influx)m(mongo)t:(typ)h(help)v(version)',argv); let parser = new mod_getopt.BasicParser('i(influx)m(mongo)t:(typ)h(help)v(version)',argv);
let option; let option;
let ret = {influx: STORE === 'both' || STORE === 'influx', mongo: STORE === 'both' || STORE === 'mongo', typ: TYP} let ret = {influx: STORE === 'both' || STORE === 'influx', mongo: STORE === 'both' || STORE === 'mongo', victoria: STORE === 'victoria', typ: TYP}
while((option = parser.getopt()) !== undefined) { while((option = parser.getopt()) !== undefined) {
switch(option.option) { switch(option.option) {
case 'i': case 'i':
@@ -111,6 +117,7 @@ function parse_cmdline(argv) {
console.log("Params:"); console.log("Params:");
console.log(" -i use only InfluxDB to store the data (default use both, InfluxDB and MongoDB))"); console.log(" -i use only InfluxDB to store the data (default use both, InfluxDB and MongoDB))");
console.log(" -m use only MongoDB to store the data (default use both, InfluxDB and MongoDB)"); console.log(" -m use only MongoDB to store the data (default use both, InfluxDB and MongoDB)");
console.log(" (VictoriaMetrics storage is selected exclusively via STORE=victoria, no CLI flag)");
console.log(" -t [ sensorType, ..]: if given, only those sensors will be used (ex: laerm) default: all"); console.log(" -t [ sensorType, ..]: if given, only those sensors will be used (ex: laerm) default: all");
console.log(" MUST BE AN ARRAY!; allowed types: 'pm', 'noise', 'radiactivity', 'thp', 'gps'.") console.log(" MUST BE AN ARRAY!; allowed types: 'pm', 'noise', 'radiactivity', 'thp', 'gps'.")
console.log(" -v version: show version"); console.log(" -v version: show version");
+1 -1
View File
@@ -8,7 +8,7 @@ const NOMINATIM_URL = `https://nominatim.openstreetmap.org/reverse?lat=${'xx'}&l
const NOMINATIM_CITY_URL = `https://nominatim.openstreetmap.org/?q="${'xx'}"&format=json` const NOMINATIM_CITY_URL = `https://nominatim.openstreetmap.org/?q="${'xx'}"&format=json`
// Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und // Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und
// beantwortet den Default von axios ('axios/1.x') mit 403. // beantwortet den Default von axios ('axios/1.x') mit 403.
const NOMINATIM_UA = 'laermsensor-stack (rxf@gmx.de)' const NOMINATIM_UA = 'laermsensor-stack'
export const getCityCoords = async (params) => { export const getCityCoords = async (params) => {
let ret = {coords: [], city: params.city, err: null} let ret = {coords: [], city: params.city, err: null}
+4 -1
View File
@@ -4,6 +4,7 @@ const DBASE = process.env.DBASE || 'mongo'
import {DateTime} from "luxon" import {DateTime} from "luxon"
import * as influx from "../databases/influx.js" import * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js" import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import {returnOnError} from "../utilities/reporterror.js" import {returnOnError} from "../utilities/reporterror.js"
import {csv2Json} from "../utilities/csv2json.js" import {csv2Json} from "../utilities/csv2json.js"
import checkParams from "../utilities/checkparams.js" import checkParams from "../utilities/checkparams.js"
@@ -155,8 +156,10 @@ export async function getActData(opts) {
return await mongo.fetchActData(opts) return await mongo.fetchActData(opts)
} else if (DBASE === 'influx') { } else if (DBASE === 'influx') {
return await influx.fetchActData(opts) return await influx.fetchActData(opts)
} else if (DBASE === 'victoria') {
return await victoria.fetchActData(opts)
} }
return {err: 'DBASEUNKNOWN', values: []} return {err: 'DBASEUNKNOWN', values: []}
} }
+67
View File
@@ -0,0 +1,67 @@
// Access to VictoriaMetrics via HTTP (Prometheus-compatible /api/v1/export,
// no Flux support - see common/victoria_post.js for the write side, which
// reuses the InfluxDB line-protocol format on VictoriaMetrics' /write endpoint).
import axios from 'axios'
import { logit, logerror } from '../utilities/logit.js'
import { returnOnError } from "../utilities/reporterror.js"
import { exportToRows, bucketNoiseAVG } from "../utilities/victoria2json.js"
let VICTORIAHOST = process.env.VICTORIAHOST || "localhost"
let VICTORIAPORT = process.env.VICTORIAPORT || 8428
const VICTORIAURL_EXPORT = `http://${VICTORIAHOST}:${VICTORIAPORT}/api/v1/export`
// opts.start/opts.stop arrive as Flux range() fragments ("start: <iso>" /
// "stop: <iso>") built by calcRange() in getsensorData.js - strip the Flux
// keyword the same way sensorapi/databases/mongo.js already does.
const isoStart = (opts) => opts.start.slice(7)
const isoStop = (opts) => opts.stop.slice(6)
const victoriaExport = async (matchSelector, start, end) => {
let erg = { values: '', err: null }
try {
let ret = await axios({
method: 'get',
url: VICTORIAURL_EXPORT,
params: { 'match[]': matchSelector, start, end },
timeout: 10000,
transformResponse: [(data) => data], // response body is ndjson, not a single JSON document - keep it raw
})
if (ret.status !== 200) {
return returnOnError(erg, 'RESPSTATUS', victoriaExport.name, ret.status)
}
erg.values = ret.data
} catch (e) {
return returnOnError(erg, e, victoriaExport.name)
}
return erg
}
export const fetchActData = async (opts) => {
let ret = { err: null, values: [] }
const match = `{__name__=~"noise_(LAeq|LA_min|LA_max|E10tel_eq)", sid="${opts.sensorid}"}`
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
if (err) {
return returnOnError(ret, err, fetchActData.name)
}
ret.values = exportToRows(values, opts.sort)
if (ret.values.length === 0) {
return returnOnError(ret, 'NODATA', fetchActData.name)
}
return ret
}
export const fetchNoiseAVGData = async (opts) => {
let ret = { err: null, values: [] }
const match = `{__name__=~"noise_(E10tel_eq|LA_max)", sid="${opts.sensorid}"}`
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
if (err) {
return returnOnError(ret, err, fetchNoiseAVGData.name)
}
ret.values = bucketNoiseAVG(values, opts.peak, opts.long)
if (ret.values.length === 0) {
return returnOnError(ret, 'NODATA', fetchNoiseAVGData.name)
}
return ret
}
+3 -25
View File
@@ -6,11 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu, # Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_sensorapi). # eigener Dockerfile-Name (Dockerfile_sensorapi).
REGISTRY="docker.citysensor.de" # Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es # REGISTRY=anderes.registry.example ./deploy.sh
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab: REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
# REGISTRY2= ./deploy.sh
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
IMAGE_NAME="sensorapi" IMAGE_NAME="sensorapi"
TAG="${1:-latest}" TAG="${1:-latest}"
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
@@ -54,26 +52,6 @@ if [ "${TAG}" != "latest" ]; then
echo "" echo ""
fi fi
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
if [ -n "${REGISTRY2}" ]; then
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
ok=true
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
fi
if [ "${ok}" != true ]; then
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
fi
echo ""
fi
echo "==========================================" echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}" echo "Build + Push fertig: ${FULL_IMAGE}"
echo "==========================================" echo "=========================================="
+4
View File
@@ -9,6 +9,7 @@ import {DateTime} from 'luxon'
import { translate as trans } from '../routes/api.js' import { translate as trans } from '../routes/api.js'
import * as influx from "../databases/influx.js" import * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js" import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import { setoptionfromtable } from "../utilities/chartoptions.js" import { setoptionfromtable } from "../utilities/chartoptions.js"
export const getNoiseData = async (params, possibles, props) => { export const getNoiseData = async (params, possibles, props) => {
@@ -451,6 +452,9 @@ const getNoiseAVGData = async (opts) => {
for (let x=0; x < ret.values.length; x++) { for (let x=0; x < ret.values.length; x++) {
ret.values[x].datetime = DateTime.fromISO(ret.values[x].datetime).toUTC().minus({hours:1}).toFormat("yyyy-LL-dd'T'HH:mm:ss'Z'") ret.values[x].datetime = DateTime.fromISO(ret.values[x].datetime).toUTC().minus({hours:1}).toFormat("yyyy-LL-dd'T'HH:mm:ss'Z'")
} }
} else if (DBASE === 'victoria') {
// victoria.js buckets by hour-start itself (like mongo), so no shift is needed here
ret = await victoria.fetchNoiseAVGData(opts)
} else { } else {
ret.err = 'DBASEUNKNOWN' ret.err = 'DBASEUNKNOWN'
} }
+97
View File
@@ -0,0 +1,97 @@
// Parse VictoriaMetrics' /api/v1/export ndjson responses into the same row
// shapes influx.js builds via csv2Json() / pivot(), for the noise-specific
// fields (measurement "noise", separator "_", i.e. metric names noise_LAeq,
// noise_LA_min, noise_LA_max, noise_E10tel_eq).
import { DateTime } from 'luxon'
const parseExportLines = (ndjsonBody) => {
return String(ndjsonBody)
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
try {
return JSON.parse(line)
} catch (e) {
return null
}
})
.filter((series) => series !== null)
}
const fieldName = (metricName) => metricName.replace(/^noise_/, '')
// Merges the per-field series (one ndjson line per field) back into rows of
// {datetime, LAeq, LA_min, LA_max, E10tel_eq}, matched by raw timestamp -
// mirrors influx's pivot(rowKey:["_time"], columnKey:["_field"]).
export const exportToRows = (ndjsonBody, sort) => {
const rowsByTime = new Map()
for (const series of parseExportLines(ndjsonBody)) {
const field = fieldName(series.metric.__name__)
const { values, timestamps } = series
for (let i = 0; i < timestamps.length; i++) {
const ts = timestamps[i]
let row = rowsByTime.get(ts)
if (!row) {
row = { datetime: DateTime.fromMillis(ts).toUTC().toISO() }
rowsByTime.set(ts, row)
}
row[field] = values[i]
}
}
const rows = Array.from(rowsByTime.values())
rows.sort((a, b) => (a.datetime < b.datetime ? -1 : a.datetime > b.datetime ? 1 : 0))
if (sort === -1) {
rows.reverse()
}
return rows
}
// Buckets raw E10tel_eq/LA_max samples by hour (truncated to the start of the
// hour of each sample, same convention as sensorapi/databases/mongo.js's
// $dateToString hour-grouping) and computes n_AVG/n_sum/count/peakcount -
// equivalent to influx.js's aggregateWindow()/reduce() Flux pipeline.
// Bucketing by hour-start (not hour-end, as Influx does) means no extra
// 1-hour shift is needed downstream, unlike the DBASE === 'influx' branch.
export const bucketNoiseAVG = (ndjsonBody, peak, long) => {
const buckets = new Map()
for (const series of parseExportLines(ndjsonBody)) {
const field = fieldName(series.metric.__name__)
if (field !== 'E10tel_eq' && field !== 'LA_max') {
continue
}
const { values, timestamps } = series
for (let i = 0; i < timestamps.length; i++) {
const key = DateTime.fromMillis(timestamps[i]).toUTC().startOf('hour').toISO()
let b = buckets.get(key)
if (!b) {
b = { sum: 0, count: 0, peakcount: 0 }
buckets.set(key, b)
}
if (field === 'E10tel_eq') {
b.sum += values[i]
b.count += 1
} else {
if (values[i] >= peak) {
b.peakcount += 1
}
}
}
}
const rows = Array.from(buckets.entries())
.filter(([, b]) => b.count > 0) // matches Influx's inner join: hours without E10tel_eq samples are dropped
.map(([datetime, b]) => {
let row = {
datetime,
n_AVG: 10 * Math.log10(b.sum / b.count),
peakcount: b.peakcount,
}
if (long) {
row.count = b.count
row.n_sum = b.sum
}
return row
})
rows.sort((a, b) => (a.datetime < b.datetime ? -1 : a.datetime > b.datetime ? 1 : 0))
return rows
}