Compare commits

...

33 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
admin b2f6fa0993 deploy.sh: Images zusaetzlich nach Gitea pushen
Alle vier Skripte schieben das gebaute Image jetzt auch nach
gitea.fuerst-stuttgart.de/admin, damit es nicht nur an einer Stelle liegt.

Der zweite Push laeuft nach dem ersten und ist bewusst nicht fatal: schlaegt
er fehl, liegt das Image bereits in docker.citysensor.de, aus der der Stack
zieht. Das Skript meldet dann nur, dass die Zweitablage fehlt, statt den
ganzen Deploy als gescheitert auszuweisen.

Abschalten mit  REGISTRY2= ./deploy.sh

Voraussetzung ist ein  docker login gitea.fuerst-stuttgart.de  (Access Token
mit write:package genuegt).

Geprueft: noise, sensorapi, readarchive und readin liegen in beiden
Registries unter demselben Digest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:38:35 +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 26 deletions
+9
View File
@@ -6,3 +6,12 @@ log/
*.log
.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
| + <d>influx
| | + <d>data
| + <d>victoria
| | + <d>data
<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.
* **data/mongo**
Verzeichnis für die Mongo-Datenbank
* **data/mngo/data**
* **data/mongo/data**
hier befinden sich die eingelesenen Daten
* **data/mongo/entries**
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
* **data/influx/data**
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
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:
@@ -60,6 +66,8 @@ STORE=influx
~~~
\<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*:
~~~
@@ -91,7 +99,7 @@ Im **Portainer** als eigenes Stack anlegen (Inhalt von `readarchive-stack.yml`)
`-s` | `START` | gestern
`-e` | `END` | Startdatum + 1 Tag (**exklusiv**)
`-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.
@@ -143,13 +151,19 @@ Die Influx-Datenbank. Hier werden in dem Bucket *sensor_data* die reinen Messwe
http://\<server>:8086
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
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.
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.
+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 -1
View File
@@ -6,7 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="noise"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
-1
View File
@@ -26,7 +26,6 @@ html
footer
#author
#mailadr
a(href="mailto:rexfue@gmail.com") mailto:rexfue@gmail.com
#versn #{t('Version')}: #{version} #{t('from')} #{date}
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 />
# 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
# 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.
# Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx).
# END ist exklusiv.
+39 -9
View File
@@ -26,20 +26,33 @@ services:
DOCKER_INFLUXDB_INIT_ORG: citysensor
DOCKER_INFLUXDB_INIT_BUCKET: sensor_data
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${DOCKER_INFLUXTOKEN}
restart:
unless-stopped
container_name: influxdb
restart:
unless-stopped
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:
image: docker.citysensor.de/readin
environment:
MONGOHOST: mongodb
INFLUXHOST: influxdb
VICTORIAHOST: victoriametrics
TYP: "[\"noise\"]"
STORE: ${STORE:-mongo}
MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
volumes:
- ${LOCALDIR}/noisesensors/log:/var/log
# Pfad im Image ist jetzt /opt/app/readin, weil readin und readarchive
@@ -67,7 +80,8 @@ services:
environment:
INFLUXHOST: influxdb
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
DBASE: influx
VICTORIAHOST: victoriametrics
DBASE: ${DBASE:-influx}
MONGOHOST: mongodb
MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
@@ -79,8 +93,24 @@ services:
- ${LOCALDIR}/noisesensors/log:/var/log
container_name: noise
environment:
APIHOST: http://sensorapi:3005
ports:
APIHOST: http://sensorapi:3005
ports:
- "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:
# -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.
#
# Voraussetzungen:
@@ -39,6 +40,7 @@ services:
environment:
MONGOHOST: mongodb
INFLUXHOST: influxdb
VICTORIAHOST: victoriametrics
MONGOAUTH: "true"
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
+3 -1
View File
@@ -6,7 +6,9 @@ set -e
# Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil
# readarchive und readin sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="readarchive"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
+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.
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 LIVE = (process.env.LIVE == "true") || true
@@ -47,6 +47,7 @@ import axios from 'axios'
import https from 'https'
import * as mongo from '../common/mongo.js'
import * as influx from '../common/influx_post.js'
import * as victoria from '../common/victoria_post.js'
import fs from 'fs'
import csv from 'csvtojson'
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()
const failedInfluxWrites = []
// Sensor/Tag-Paare, deren VictoriaMetrics-Write fehlgeschlagen ist - Auswertung am Ende von main()
const failedVictoriaWrites = []
const saveList = (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})
}
}
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(erg.length !== 0) {
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(" -t sensorType: if given, only those sensors will be used; default: 'noise'");
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(" -v version: show version");
console.log(" -h this help text");
@@ -555,6 +564,13 @@ async function main() {
}
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
const duration1 = Duration.fromObject({ seconds: dauer });
const output1 = duration1.toFormat('hh:mm:ss');
+3 -1
View File
@@ -8,7 +8,9 @@ set -e
# Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und
# readarchive sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="readin"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
+9 -2
View File
@@ -14,13 +14,14 @@
// TYP: Ist ein Array von Strings, wenn nur einzelne Typen gespeichert werden sollen, also z.B.:
// ['pm', 'noise']
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 { constructDBaseEntries as parse} from './parse.js'
import * as mongo from '../common/mongo.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 { statistics } from '../common/statistics.js'
import { DateTime } from 'luxon'
@@ -62,6 +63,11 @@ const fetchNewData = async (args) => {
await influx.influxWrite(idata)
}
// write sensor data to VictoriaMetrics
if(args.victoria) {
await victoria.victoriaWrite(idata)
}
// write properties to mongoDB
await mongo.bulkWrite(client, mongo.property_coll, props)
}
@@ -78,7 +84,7 @@ const fetchNewData = async (args) => {
function parse_cmdline(argv) {
let parser = new mod_getopt.BasicParser('i(influx)m(mongo)t:(typ)h(help)v(version)',argv);
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) {
switch(option.option) {
case 'i':
@@ -111,6 +117,7 @@ function parse_cmdline(argv) {
console.log("Params:");
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(" (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(" MUST BE AN ARRAY!; allowed types: 'pm', 'noise', 'radiactivity', 'thp', 'gps'.")
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`
// Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und
// 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) => {
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 * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import {returnOnError} from "../utilities/reporterror.js"
import {csv2Json} from "../utilities/csv2json.js"
import checkParams from "../utilities/checkparams.js"
@@ -155,8 +156,10 @@ export async function getActData(opts) {
return await mongo.fetchActData(opts)
} else if (DBASE === 'influx') {
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 -1
View File
@@ -6,7 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_sensorapi).
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="sensorapi"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
+4
View File
@@ -9,6 +9,7 @@ import {DateTime} from 'luxon'
import { translate as trans } from '../routes/api.js'
import * as influx from "../databases/influx.js"
import * as mongo from "../databases/mongo.js"
import * as victoria from "../databases/victoria.js"
import { setoptionfromtable } from "../utilities/chartoptions.js"
export const getNoiseData = async (params, possibles, props) => {
@@ -451,6 +452,9 @@ const getNoiseAVGData = async (opts) => {
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'")
}
} 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 {
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
}