Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21050f51c9 | |||
| 23fe7d2ed2 | |||
| b1b8f39ff4 | |||
| c0fe05c67e | |||
| 2e5a7be4dd | |||
| ef12b275f5 | |||
| 782055c4fd | |||
| ab74164608 | |||
| bc9976d5c6 | |||
| 0c45cd9f46 | |||
| ddc503cb63 | |||
| 19c6facf71 | |||
| 278f91ed3c | |||
| 7b3403d943 | |||
| 6ff2ceb76d | |||
| cd4d7ba25d | |||
| 47d27148ca | |||
| ccaf6dd6f3 | |||
| 4283f07291 | |||
| b8bcd946d0 | |||
| b92dffef0f | |||
| 0d10e17caf | |||
| cf55afda38 | |||
| 306239cfbc | |||
| b5ffd8ae4a | |||
| add666b297 | |||
| 8891addccb | |||
| ff789a68ef | |||
| 1c213634c7 | |||
| f4bf4fffd6 | |||
| d7a9a7a487 | |||
| d081b9f52d |
@@ -6,3 +6,12 @@ log/
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
**/.env
|
**/.env
|
||||||
|
|
||||||
|
# Produktionsstand vom Server, nur Referenz
|
||||||
|
noisesensors/vonRemote/
|
||||||
|
|
||||||
|
# Laufzeitdaten der lokal getesteten Container (DB-Dateien, aktuelle Messwerte)
|
||||||
|
noisesensors/data/mongo/data/
|
||||||
|
noisesensors/data/influx/
|
||||||
|
noisesensors/data/victoria/
|
||||||
|
noisesensors/data/aktdata.json
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ Die Datei **noise.tgz** enthält die folgenden Dateien und Verzeichnisse:
|
|||||||
| | | +- <f>create.js
|
| | | +- <f>create.js
|
||||||
| + <d>influx
|
| + <d>influx
|
||||||
| | + <d>data
|
| | + <d>data
|
||||||
|
| + <d>victoria
|
||||||
|
| | + <d>data
|
||||||
|
|
||||||
<f>: file, <d>: directory
|
<f>: file, <d>: directory
|
||||||
~~~
|
~~~
|
||||||
@@ -34,7 +36,7 @@ Ablage für die Logdateien der einzelnene Container (**noise.log**, **sensorapi.
|
|||||||
hier darunter befinden sich alle Daten, sowohl die Datenbanken als auch Konfigurationen. Außerdem werden jeweils die laufend eingelesenen Daten als **actdata.json** abgelegt. Diese werden bei jedem neuen Einlesen wieder überschrieben.
|
hier darunter befinden sich alle Daten, sowohl die Datenbanken als auch Konfigurationen. Außerdem werden jeweils die laufend eingelesenen Daten als **actdata.json** abgelegt. Diese werden bei jedem neuen Einlesen wieder überschrieben.
|
||||||
* **data/mongo**
|
* **data/mongo**
|
||||||
Verzeichnis für die Mongo-Datenbank
|
Verzeichnis für die Mongo-Datenbank
|
||||||
* **data/mngo/data**
|
* **data/mongo/data**
|
||||||
hier befinden sich die eingelesenen Daten
|
hier befinden sich die eingelesenen Daten
|
||||||
* **data/mongo/entries**
|
* **data/mongo/entries**
|
||||||
hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenbank die Collections und die Indexe erzeugt werden.
|
hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenbank die Collections und die Indexe erzeugt werden.
|
||||||
@@ -42,10 +44,14 @@ hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenba
|
|||||||
Verzeichnis für die Influx-Datenbank
|
Verzeichnis für die Influx-Datenbank
|
||||||
* **data/influx/data**
|
* **data/influx/data**
|
||||||
Hier dann die eingelesenen Daten
|
Hier dann die eingelesenen Daten
|
||||||
|
* **data/victoria**
|
||||||
|
Verzeichnis für die VictoriaMetrics-Datenbank (optionale Alternative zu Influx, siehe unten)
|
||||||
|
* **data/victoria/data**
|
||||||
|
Hier dann die eingelesenen Daten
|
||||||
|
|
||||||
|
|
||||||
### Aufrufe
|
### Aufrufe
|
||||||
Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Direktoty *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden.
|
Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Verzeichnis *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden.
|
||||||
|
|
||||||
Nun das *docker-compose.yml* in das Portainer-Stack einfügen. Zusätzlich folgende Environment-Variablen festlegen:
|
Nun das *docker-compose.yml* in das Portainer-Stack einfügen. Zusätzlich folgende Environment-Variablen festlegen:
|
||||||
|
|
||||||
@@ -60,6 +66,8 @@ STORE=influx
|
|||||||
~~~
|
~~~
|
||||||
\<username>, \<passwort>, \<username1>, \<passwort1> und \<token> sind anzupassen.
|
\<username>, \<passwort>, \<username1>, \<passwort1> und \<token> sind anzupassen.
|
||||||
|
|
||||||
|
**STORE** legt fest, in welche Datenbank(en) *readin* die laufenden Messwerte schreibt: `influx`, `mongo`, `victoria` oder `both` (= mongo **und** influx gleichzeitig; Default). `victoria` ist dabei ein zu `influx` alternativer, exklusiver Wert (nicht über `both` kombinierbar). *sensorapi* liest die Messwerte unabhängig davon über **DBASE** (dieselben Werte `influx`/`mongo`/`victoria`, siehe Container-Beschreibung unten).
|
||||||
|
|
||||||
Danach einloggen in das Docker-Registry auf *citysensor.de*:
|
Danach einloggen in das Docker-Registry auf *citysensor.de*:
|
||||||
|
|
||||||
~~~
|
~~~
|
||||||
@@ -91,7 +99,7 @@ Im **Portainer** als eigenes Stack anlegen (Inhalt von `readarchive-stack.yml`)
|
|||||||
`-s` | `START` | gestern
|
`-s` | `START` | gestern
|
||||||
`-e` | `END` | Startdatum + 1 Tag (**exklusiv**)
|
`-e` | `END` | Startdatum + 1 Tag (**exklusiv**)
|
||||||
`-t` | `TYP` | `noise`
|
`-t` | `TYP` | `noise`
|
||||||
`-d` | `DBASE` | `both`, in der Stack-Datei auf `influx` vorbelegt
|
`-d` | `DBASE` | `mongo`, `influx`, `victoria` oder `both` (= mongo+influx); in der Stack-Datei auf `influx` vorbelegt
|
||||||
|
|
||||||
*Deploy the stack* startet den Lauf, der Container endet danach und bleibt als „Exited" stehen. Für den nächsten Zeitraum nur die Variablen ändern und *Update the stack*. Der Haupt-Stack wird dabei nicht angefasst.
|
*Deploy the stack* startet den Lauf, der Container endet danach und bleibt als „Exited" stehen. Für den nächsten Zeitraum nur die Variablen ändern und *Update the stack*. Der Haupt-Stack wird dabei nicht angefasst.
|
||||||
|
|
||||||
@@ -143,13 +151,19 @@ Die Influx-Datenbank. Hier werden in dem Bucket *sensor_data* die reinen Messwe
|
|||||||
http://\<server>:8086
|
http://\<server>:8086
|
||||||
Logindaten: Entsprechen der im Portainer hinterlegten.
|
Logindaten: Entsprechen der im Portainer hinterlegten.
|
||||||
|
|
||||||
|
* **victoriametrics**
|
||||||
|
Alternative zur Influx-Datenbank für die reinen Messwerte, wählbar über `STORE`/`DBASE=victoria`. Läuft als Single-Node-Instanz auf **Port 8428**, ohne Login/Auth.
|
||||||
|
**Wichtig:** VictoriaMetrics löscht standardmäßig Daten, die älter als 1 Monat sind (`-retentionPeriod=1`). Im `docker-compose.yml` ist das Flag deshalb explizit auf `--retentionPeriod=100y` gesetzt — das **nicht** entfernen, sonst verschwinden alte Messwerte nach 30 Tagen kommentarlos.
|
||||||
|
**Zugriff:**
|
||||||
|
http://\<server>:8428 (z.B. `/api/v1/export?match[]={sid="..."}` zum Rohdaten-Export)
|
||||||
|
|
||||||
|
|
||||||
## Sourcen für die Container
|
## Sourcen für die Container
|
||||||
|
|
||||||
Das gesamte Projekt ist im GitHub unter **laermsensor-stack** abgelegt. Für jeden der 4 Container existiert darunter ein Verzeichnis:
|
Das gesamte Projekt ist im GitHub unter **laermsensor-stack** abgelegt. Für jeden der 4 Container existiert darunter ein Verzeichnis:
|
||||||
**readin**, **readarchive**, **sensorapi** und **noise**. In diesen Verzeichnissen sind alle benötigten Sourcen enthalten.
|
**readin**, **readarchive**, **sensorapi** und **noise**. In diesen Verzeichnissen sind alle benötigten Sourcen enthalten.
|
||||||
|
|
||||||
Daneben gibt es **common** mit den Modulen, die sich *readin* und *readarchive* teilen: `mongo.js`, `influx_post.js`, `logit.js` und `statistics.js`. Sie lagen früher in beiden Komponenten doppelt und sind auseinandergelaufen. Wer dort etwas ändert, ändert es für beide — nach einer Änderung also **beide** Container neu bauen.
|
Daneben gibt es **common** mit den Modulen, die sich *readin* und *readarchive* teilen: `mongo.js`, `influx_post.js`, `victoria_post.js`, `logit.js` und `statistics.js`. Sie lagen früher in beiden Komponenten doppelt und sind auseinandergelaufen. Wer dort etwas ändert, ändert es für beide — nach einer Änderung also **beide** Container neu bauen.
|
||||||
|
|
||||||
Sollte was geändert werden, so muss mit **deploy.sh** der Container neu erzeugt und auf die Registry (siehe oben) gepushed werden. Der Build-Kontext von *readin* und *readarchive* ist wegen `common/` das Wurzelverzeichnis des Repositories; `deploy.sh` wechselt selbst dorthin und kann aus dem Komponentenverzeichnis aufgerufen werden.
|
Sollte was geändert werden, so muss mit **deploy.sh** der Container neu erzeugt und auf die Registry (siehe oben) gepushed werden. Der Build-Kontext von *readin* und *readarchive* ist wegen `common/` das Wurzelverzeichnis des Repositories; `deploy.sh` wechselt selbst dorthin und kann aus dem Komponentenverzeichnis aufgerufen werden.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
Generated
+366
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.pdf
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
docs/beschreibung_tmp.html
|
||||||
|
docs/beschreibung.html
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"yzane.markdown-pdf"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+18
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+60
@@ -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"
|
||||||
@@ -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:
|
||||||
@@ -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 |
Executable
+30
@@ -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 |
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
const hashedPassword = await bcrypt.hash('Tux4esp', 10);
|
||||||
|
console.log(hashedPassword)
|
||||||
Generated
+6549
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
doctype html
|
||||||
|
html(lang="de")
|
||||||
|
head
|
||||||
|
meta(charset="utf-8")
|
||||||
|
meta(name="viewport", content="width=device-width, initial-scale=1")
|
||||||
|
title Login
|
||||||
|
link(rel="stylesheet", href="/styles.css")
|
||||||
|
body
|
||||||
|
h1 ESP-ID → Sensornummer
|
||||||
|
div.card
|
||||||
|
h2 Login
|
||||||
|
form(method="POST" action="/login")
|
||||||
|
label(for="email") E-Mail:
|
||||||
|
input#email(type="email" name="email" required)
|
||||||
|
span#emailStatus
|
||||||
|
label(for="password") Passwort:
|
||||||
|
input#password(type="password" name="password" required)
|
||||||
|
button(type="submit") Login
|
||||||
|
p.hint Passwort vergessen? Bitte an einen Admin wenden.
|
||||||
|
if noUsers
|
||||||
|
p.hint
|
||||||
|
strong Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten.
|
||||||
|
#version Version: #{version} vom #{vdate}
|
||||||
|
|
||||||
|
if error
|
||||||
|
p.error= error
|
||||||
|
script(type="module" src="/login.js")
|
||||||
+3
-25
@@ -6,11 +6,9 @@ set -e
|
|||||||
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
|
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
|
||||||
# eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise.
|
# eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise.
|
||||||
|
|
||||||
REGISTRY="docker.citysensor.de"
|
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
|
||||||
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es
|
# REGISTRY=anderes.registry.example ./deploy.sh
|
||||||
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab:
|
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
|
||||||
# REGISTRY2= ./deploy.sh
|
|
||||||
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
|
|
||||||
IMAGE_NAME="noise"
|
IMAGE_NAME="noise"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
@@ -54,26 +52,6 @@ if [ "${TAG}" != "latest" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
|
|
||||||
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
|
|
||||||
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
|
|
||||||
if [ -n "${REGISTRY2}" ]; then
|
|
||||||
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
|
|
||||||
ok=true
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
|
|
||||||
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
|
|
||||||
fi
|
|
||||||
if [ "${ok}" != true ]; then
|
|
||||||
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
|
|
||||||
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
|
|
||||||
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Build + Push fertig: ${FULL_IMAGE}"
|
echo "Build + Push fertig: ${FULL_IMAGE}"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ html
|
|||||||
footer
|
footer
|
||||||
#author
|
#author
|
||||||
#mailadr
|
#mailadr
|
||||||
a(href="mailto:rexfue@gmail.com") mailto:rexfue@gmail.com
|
|
||||||
#versn #{t('Version')}: #{version} #{t('from')} #{date}
|
#versn #{t('Version')}: #{version} #{t('from')} #{date}
|
||||||
|
|
||||||
script(src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
|
script(src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ MONGO_ROOT_PASSWORD=<Mongo_Passwort>
|
|||||||
|
|
||||||
LOCALDIR=<Aktuelle Direcrory ab />
|
LOCALDIR=<Aktuelle Direcrory ab />
|
||||||
|
|
||||||
# Datenbank, in welche die laufenden Messwerte gespeichert werden (kannn 'influx' oder 'mongo' sein)
|
# Datenbank, in welche die laufenden Messwerte gespeichert werden (kann 'influx', 'mongo' oder 'victoria' sein)
|
||||||
STORE=influx
|
STORE=influx
|
||||||
|
|
||||||
|
# Datenbank, aus welcher sensorapi die Messwerte liest (kann 'influx', 'mongo' oder 'victoria' sein)
|
||||||
|
DBASE=influx
|
||||||
|
|
||||||
|
# Secret zum Signieren der Session-Cookies von esp2sensor (langer Zufallsstring)
|
||||||
|
ESP2SENSOR_SESSION_SECRET=<zufaelliger_string>
|
||||||
|
|
||||||
# Nur fuer readarchive-stack.yml: Zeitraum und Ziel des Einmal-Laufs.
|
# Nur fuer readarchive-stack.yml: Zeitraum und Ziel des Einmal-Laufs.
|
||||||
# Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx).
|
# Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx).
|
||||||
# END ist exklusiv.
|
# END ist exklusiv.
|
||||||
|
|||||||
@@ -30,11 +30,24 @@ services:
|
|||||||
unless-stopped
|
unless-stopped
|
||||||
container_name: influxdb
|
container_name: influxdb
|
||||||
|
|
||||||
|
victoriametrics:
|
||||||
|
image: victoriametrics/victoria-metrics:v1.102.0
|
||||||
|
ports:
|
||||||
|
- "8428:8428"
|
||||||
|
volumes:
|
||||||
|
- ${LOCALDIR}/noisesensors/data/victoria/data:/victoria-metrics-data
|
||||||
|
command:
|
||||||
|
- "--storageDataPath=/victoria-metrics-data"
|
||||||
|
- "--retentionPeriod=100y"
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: victoriametrics
|
||||||
|
|
||||||
readin:
|
readin:
|
||||||
image: docker.citysensor.de/readin
|
image: docker.citysensor.de/readin
|
||||||
environment:
|
environment:
|
||||||
MONGOHOST: mongodb
|
MONGOHOST: mongodb
|
||||||
INFLUXHOST: influxdb
|
INFLUXHOST: influxdb
|
||||||
|
VICTORIAHOST: victoriametrics
|
||||||
TYP: "[\"noise\"]"
|
TYP: "[\"noise\"]"
|
||||||
STORE: ${STORE:-mongo}
|
STORE: ${STORE:-mongo}
|
||||||
MONGOAUTH: true
|
MONGOAUTH: true
|
||||||
@@ -67,7 +80,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
INFLUXHOST: influxdb
|
INFLUXHOST: influxdb
|
||||||
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
|
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
|
||||||
DBASE: influx
|
VICTORIAHOST: victoriametrics
|
||||||
|
DBASE: ${DBASE:-influx}
|
||||||
MONGOHOST: mongodb
|
MONGOHOST: mongodb
|
||||||
MONGOAUTH: true
|
MONGOAUTH: true
|
||||||
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
|
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
|
||||||
@@ -84,3 +98,19 @@ services:
|
|||||||
- "3003:3000"
|
- "3003:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
esp2sensor:
|
||||||
|
image: docker.citysensor.de/esp2sensor
|
||||||
|
container_name: esp2sensor
|
||||||
|
volumes:
|
||||||
|
- ${LOCALDIR}/noisesensors/log:/var/log
|
||||||
|
environment:
|
||||||
|
MONGOHOST: mongodb
|
||||||
|
MONGOAUTH: true
|
||||||
|
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
|
||||||
|
SESSION_SECRET: ${ESP2SENSOR_SESSION_SECRET}
|
||||||
|
DEV_AUTO_LOGIN: "false"
|
||||||
|
APIHOST: http://sensorapi:3005/api/
|
||||||
|
ports:
|
||||||
|
- "3004:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
#
|
#
|
||||||
# readFromcsv.js liest jeden Parameter auch aus einer Env-Variablen:
|
# readFromcsv.js liest jeden Parameter auch aus einer Env-Variablen:
|
||||||
# -s = START (Default: gestern), -e = END (Default: Startdatum + 1 Tag,
|
# -s = START (Default: gestern), -e = END (Default: Startdatum + 1 Tag,
|
||||||
# exklusiv), -t = TYP (Default: noise), -d = DBASE (Default: both),
|
# exklusiv), -t = TYP (Default: noise), -d = DBASE ('mongo', 'influx',
|
||||||
|
# 'victoria' oder 'both' = mongo+influx; Default: both),
|
||||||
# -p = PARALLEL (Default: 4) - so viele Sensoren werden gleichzeitig geholt.
|
# -p = PARALLEL (Default: 4) - so viele Sensoren werden gleichzeitig geholt.
|
||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Voraussetzungen:
|
||||||
@@ -39,6 +40,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MONGOHOST: mongodb
|
MONGOHOST: mongodb
|
||||||
INFLUXHOST: influxdb
|
INFLUXHOST: influxdb
|
||||||
|
VICTORIAHOST: victoriametrics
|
||||||
MONGOAUTH: "true"
|
MONGOAUTH: "true"
|
||||||
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
|
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
|
||||||
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
|
INFLUXTOKEN: ${DOCKER_INFLUXTOKEN}
|
||||||
|
|||||||
+3
-25
@@ -6,11 +6,9 @@ set -e
|
|||||||
# Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil
|
# Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil
|
||||||
# readarchive und readin sich die Module unter common/ teilen.
|
# readarchive und readin sich die Module unter common/ teilen.
|
||||||
|
|
||||||
REGISTRY="docker.citysensor.de"
|
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
|
||||||
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es
|
# REGISTRY=anderes.registry.example ./deploy.sh
|
||||||
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab:
|
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
|
||||||
# REGISTRY2= ./deploy.sh
|
|
||||||
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
|
|
||||||
IMAGE_NAME="readarchive"
|
IMAGE_NAME="readarchive"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
@@ -58,26 +56,6 @@ if [ "${TAG}" != "latest" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
|
|
||||||
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
|
|
||||||
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
|
|
||||||
if [ -n "${REGISTRY2}" ]; then
|
|
||||||
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
|
|
||||||
ok=true
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
|
|
||||||
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
|
|
||||||
fi
|
|
||||||
if [ "${ok}" != true ]; then
|
|
||||||
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
|
|
||||||
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
|
|
||||||
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Build + Push fertig: ${FULL_IMAGE}"
|
echo "Build + Push fertig: ${FULL_IMAGE}"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const DBASE = process.env.DBASE || 'both'
|
|||||||
// Mehr als 4 bringt nichts: ab da liefert das Archiv nicht mehr schneller.
|
// Mehr als 4 bringt nichts: ab da liefert das Archiv nicht mehr schneller.
|
||||||
const PARALLEL = parseInt(process.env.PARALLEL) || 4
|
const PARALLEL = parseInt(process.env.PARALLEL) || 4
|
||||||
|
|
||||||
const DATABASE = ['mongo', 'influx', 'both']
|
const DATABASE = ['mongo', 'influx', 'victoria', 'both']
|
||||||
|
|
||||||
const DEVELOP = process.env.DEVELOP || false;
|
const DEVELOP = process.env.DEVELOP || false;
|
||||||
const LIVE = (process.env.LIVE == "true") || true
|
const LIVE = (process.env.LIVE == "true") || true
|
||||||
@@ -47,6 +47,7 @@ import axios from 'axios'
|
|||||||
import https from 'https'
|
import https from 'https'
|
||||||
import * as mongo from '../common/mongo.js'
|
import * as mongo from '../common/mongo.js'
|
||||||
import * as influx from '../common/influx_post.js'
|
import * as influx from '../common/influx_post.js'
|
||||||
|
import * as victoria from '../common/victoria_post.js'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import csv from 'csvtojson'
|
import csv from 'csvtojson'
|
||||||
import pkg from './package.json' with { type: "json" }
|
import pkg from './package.json' with { type: "json" }
|
||||||
@@ -67,6 +68,8 @@ const newProps = []
|
|||||||
|
|
||||||
// Sensor/Tag-Paare, deren Influx-Write fehlgeschlagen ist - Auswertung am Ende von main()
|
// Sensor/Tag-Paare, deren Influx-Write fehlgeschlagen ist - Auswertung am Ende von main()
|
||||||
const failedInfluxWrites = []
|
const failedInfluxWrites = []
|
||||||
|
// Sensor/Tag-Paare, deren VictoriaMetrics-Write fehlgeschlagen ist - Auswertung am Ende von main()
|
||||||
|
const failedVictoriaWrites = []
|
||||||
|
|
||||||
const saveList = (list) => {
|
const saveList = (list) => {
|
||||||
fs.writeFileSync('data/list.json', JSON.stringify(list))
|
fs.writeFileSync('data/list.json', JSON.stringify(list))
|
||||||
@@ -397,6 +400,12 @@ async function enterOneSensorinDB(client, sid, erg, dataline, day, styp, args) {
|
|||||||
failedInfluxWrites.push({sensorid: sid, day: day})
|
failedInfluxWrites.push({sensorid: sid, day: day})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ((args.database === 'victoria') && (dataline !== '')) {
|
||||||
|
const ok = await victoria.victoriaWrite(dataline)
|
||||||
|
if (!ok) {
|
||||||
|
failedVictoriaWrites.push({sensorid: sid, day: day})
|
||||||
|
}
|
||||||
|
}
|
||||||
if((args.database === 'both') || (args.database === 'mongo')) {
|
if((args.database === 'both') || (args.database === 'mongo')) {
|
||||||
if(erg.length !== 0) {
|
if(erg.length !== 0) {
|
||||||
await mongo.writeDataArray(client, mongo.dataCollName(styp), erg)
|
await mongo.writeDataArray(client, mongo.dataCollName(styp), erg)
|
||||||
@@ -507,7 +516,7 @@ function parse_cmdline(argv) {
|
|||||||
console.log(" will be used and endDate = startDate + 1 day")
|
console.log(" will be used and endDate = startDate + 1 day")
|
||||||
console.log(" -t sensorType: if given, only those sensors will be used; default: 'noise'");
|
console.log(" -t sensorType: if given, only those sensors will be used; default: 'noise'");
|
||||||
console.log(" allowed types: 'noise', 'radiactivity', 'pm' and 'thp'")
|
console.log(" allowed types: 'noise', 'radiactivity', 'pm' and 'thp'")
|
||||||
console.log(" -d database: 'mongo', 'influx' or 'both'; default: 'both'")
|
console.log(" -d database: 'mongo', 'influx', 'victoria' or 'both' ('both' = mongo + influx); default: 'both'")
|
||||||
console.log(` -p parallel: number of sensors fetched at the same time; default: ${PARALLEL}`)
|
console.log(` -p parallel: number of sensors fetched at the same time; default: ${PARALLEL}`)
|
||||||
console.log(" -v version: show version");
|
console.log(" -v version: show version");
|
||||||
console.log(" -h this help text");
|
console.log(" -h this help text");
|
||||||
@@ -555,6 +564,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
process.exitCode = 1 // damit cron/docker den Fehlschlag sieht
|
process.exitCode = 1 // damit cron/docker den Fehlschlag sieht
|
||||||
}
|
}
|
||||||
|
if (failedVictoriaWrites.length > 0) {
|
||||||
|
logerror(`${failedVictoriaWrites.length} Victoria-Writes fehlgeschlagen - diese Daten fehlen in VictoriaMetrics:`)
|
||||||
|
for (const f of failedVictoriaWrites) {
|
||||||
|
logerror(` Sensor ${f.sensorid}, Tag ${f.day}`)
|
||||||
|
}
|
||||||
|
process.exitCode = 1 // damit cron/docker den Fehlschlag sieht
|
||||||
|
}
|
||||||
let dauer = starttime.diffNow('seconds').toObject().seconds * -1
|
let dauer = starttime.diffNow('seconds').toObject().seconds * -1
|
||||||
const duration1 = Duration.fromObject({ seconds: dauer });
|
const duration1 = Duration.fromObject({ seconds: dauer });
|
||||||
const output1 = duration1.toFormat('hh:mm:ss');
|
const output1 = duration1.toFormat('hh:mm:ss');
|
||||||
|
|||||||
+3
-25
@@ -8,11 +8,9 @@ set -e
|
|||||||
# Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und
|
# Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und
|
||||||
# readarchive sich die Module unter common/ teilen.
|
# readarchive sich die Module unter common/ teilen.
|
||||||
|
|
||||||
REGISTRY="docker.citysensor.de"
|
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
|
||||||
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es
|
# REGISTRY=anderes.registry.example ./deploy.sh
|
||||||
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab:
|
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
|
||||||
# REGISTRY2= ./deploy.sh
|
|
||||||
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
|
|
||||||
IMAGE_NAME="readin"
|
IMAGE_NAME="readin"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
@@ -60,26 +58,6 @@ if [ "${TAG}" != "latest" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
|
|
||||||
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
|
|
||||||
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
|
|
||||||
if [ -n "${REGISTRY2}" ]; then
|
|
||||||
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
|
|
||||||
ok=true
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
|
|
||||||
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
|
|
||||||
fi
|
|
||||||
if [ "${ok}" != true ]; then
|
|
||||||
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
|
|
||||||
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
|
|
||||||
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Build + Push fertig: ${FULL_IMAGE}"
|
echo "Build + Push fertig: ${FULL_IMAGE}"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -14,13 +14,14 @@
|
|||||||
// TYP: Ist ein Array von Strings, wenn nur einzelne Typen gespeichert werden sollen, also z.B.:
|
// TYP: Ist ein Array von Strings, wenn nur einzelne Typen gespeichert werden sollen, also z.B.:
|
||||||
// ['pm', 'noise']
|
// ['pm', 'noise']
|
||||||
const TYP = process.env.TYP || ''
|
const TYP = process.env.TYP || ''
|
||||||
const STORE = (process.env.STORE || 'both').toLowerCase() // 'mongo' | 'influx' | 'both'
|
const STORE = (process.env.STORE || 'both').toLowerCase() // 'mongo' | 'influx' | 'victoria' | 'both' ('both' = mongo + influx)
|
||||||
|
|
||||||
import { doReadfromAPI as readin } from './readdata.js'
|
import { doReadfromAPI as readin } from './readdata.js'
|
||||||
import { constructDBaseEntries as parse} from './parse.js'
|
import { constructDBaseEntries as parse} from './parse.js'
|
||||||
|
|
||||||
import * as mongo from '../common/mongo.js'
|
import * as mongo from '../common/mongo.js'
|
||||||
import * as influx from '../common/influx_post.js'
|
import * as influx from '../common/influx_post.js'
|
||||||
|
import * as victoria from '../common/victoria_post.js'
|
||||||
import { logit, logerror } from '../common/logit.js'
|
import { logit, logerror } from '../common/logit.js'
|
||||||
import { statistics } from '../common/statistics.js'
|
import { statistics } from '../common/statistics.js'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
@@ -62,6 +63,11 @@ const fetchNewData = async (args) => {
|
|||||||
await influx.influxWrite(idata)
|
await influx.influxWrite(idata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// write sensor data to VictoriaMetrics
|
||||||
|
if(args.victoria) {
|
||||||
|
await victoria.victoriaWrite(idata)
|
||||||
|
}
|
||||||
|
|
||||||
// write properties to mongoDB
|
// write properties to mongoDB
|
||||||
await mongo.bulkWrite(client, mongo.property_coll, props)
|
await mongo.bulkWrite(client, mongo.property_coll, props)
|
||||||
}
|
}
|
||||||
@@ -78,7 +84,7 @@ const fetchNewData = async (args) => {
|
|||||||
function parse_cmdline(argv) {
|
function parse_cmdline(argv) {
|
||||||
let parser = new mod_getopt.BasicParser('i(influx)m(mongo)t:(typ)h(help)v(version)',argv);
|
let parser = new mod_getopt.BasicParser('i(influx)m(mongo)t:(typ)h(help)v(version)',argv);
|
||||||
let option;
|
let option;
|
||||||
let ret = {influx: STORE === 'both' || STORE === 'influx', mongo: STORE === 'both' || STORE === 'mongo', typ: TYP}
|
let ret = {influx: STORE === 'both' || STORE === 'influx', mongo: STORE === 'both' || STORE === 'mongo', victoria: STORE === 'victoria', typ: TYP}
|
||||||
while((option = parser.getopt()) !== undefined) {
|
while((option = parser.getopt()) !== undefined) {
|
||||||
switch(option.option) {
|
switch(option.option) {
|
||||||
case 'i':
|
case 'i':
|
||||||
@@ -111,6 +117,7 @@ function parse_cmdline(argv) {
|
|||||||
console.log("Params:");
|
console.log("Params:");
|
||||||
console.log(" -i use only InfluxDB to store the data (default use both, InfluxDB and MongoDB))");
|
console.log(" -i use only InfluxDB to store the data (default use both, InfluxDB and MongoDB))");
|
||||||
console.log(" -m use only MongoDB to store the data (default use both, InfluxDB and MongoDB)");
|
console.log(" -m use only MongoDB to store the data (default use both, InfluxDB and MongoDB)");
|
||||||
|
console.log(" (VictoriaMetrics storage is selected exclusively via STORE=victoria, no CLI flag)");
|
||||||
console.log(" -t [ sensorType, ..]: if given, only those sensors will be used (ex: laerm) default: all");
|
console.log(" -t [ sensorType, ..]: if given, only those sensors will be used (ex: laerm) default: all");
|
||||||
console.log(" MUST BE AN ARRAY!; allowed types: 'pm', 'noise', 'radiactivity', 'thp', 'gps'.")
|
console.log(" MUST BE AN ARRAY!; allowed types: 'pm', 'noise', 'radiactivity', 'thp', 'gps'.")
|
||||||
console.log(" -v version: show version");
|
console.log(" -v version: show version");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const NOMINATIM_URL = `https://nominatim.openstreetmap.org/reverse?lat=${'xx'}&l
|
|||||||
const NOMINATIM_CITY_URL = `https://nominatim.openstreetmap.org/?q="${'xx'}"&format=json`
|
const NOMINATIM_CITY_URL = `https://nominatim.openstreetmap.org/?q="${'xx'}"&format=json`
|
||||||
// Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und
|
// Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und
|
||||||
// beantwortet den Default von axios ('axios/1.x') mit 403.
|
// beantwortet den Default von axios ('axios/1.x') mit 403.
|
||||||
const NOMINATIM_UA = 'laermsensor-stack (rxf@gmx.de)'
|
const NOMINATIM_UA = 'laermsensor-stack'
|
||||||
|
|
||||||
export const getCityCoords = async (params) => {
|
export const getCityCoords = async (params) => {
|
||||||
let ret = {coords: [], city: params.city, err: null}
|
let ret = {coords: [], city: params.city, err: null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const DBASE = process.env.DBASE || 'mongo'
|
|||||||
import {DateTime} from "luxon"
|
import {DateTime} from "luxon"
|
||||||
import * as influx from "../databases/influx.js"
|
import * as influx from "../databases/influx.js"
|
||||||
import * as mongo from "../databases/mongo.js"
|
import * as mongo from "../databases/mongo.js"
|
||||||
|
import * as victoria from "../databases/victoria.js"
|
||||||
import {returnOnError} from "../utilities/reporterror.js"
|
import {returnOnError} from "../utilities/reporterror.js"
|
||||||
import {csv2Json} from "../utilities/csv2json.js"
|
import {csv2Json} from "../utilities/csv2json.js"
|
||||||
import checkParams from "../utilities/checkparams.js"
|
import checkParams from "../utilities/checkparams.js"
|
||||||
@@ -155,6 +156,8 @@ export async function getActData(opts) {
|
|||||||
return await mongo.fetchActData(opts)
|
return await mongo.fetchActData(opts)
|
||||||
} else if (DBASE === 'influx') {
|
} else if (DBASE === 'influx') {
|
||||||
return await influx.fetchActData(opts)
|
return await influx.fetchActData(opts)
|
||||||
|
} else if (DBASE === 'victoria') {
|
||||||
|
return await victoria.fetchActData(opts)
|
||||||
}
|
}
|
||||||
return {err: 'DBASEUNKNOWN', values: []}
|
return {err: 'DBASEUNKNOWN', values: []}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Access to VictoriaMetrics via HTTP (Prometheus-compatible /api/v1/export,
|
||||||
|
// no Flux support - see common/victoria_post.js for the write side, which
|
||||||
|
// reuses the InfluxDB line-protocol format on VictoriaMetrics' /write endpoint).
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
import { logit, logerror } from '../utilities/logit.js'
|
||||||
|
import { returnOnError } from "../utilities/reporterror.js"
|
||||||
|
import { exportToRows, bucketNoiseAVG } from "../utilities/victoria2json.js"
|
||||||
|
|
||||||
|
let VICTORIAHOST = process.env.VICTORIAHOST || "localhost"
|
||||||
|
let VICTORIAPORT = process.env.VICTORIAPORT || 8428
|
||||||
|
|
||||||
|
const VICTORIAURL_EXPORT = `http://${VICTORIAHOST}:${VICTORIAPORT}/api/v1/export`
|
||||||
|
|
||||||
|
// opts.start/opts.stop arrive as Flux range() fragments ("start: <iso>" /
|
||||||
|
// "stop: <iso>") built by calcRange() in getsensorData.js - strip the Flux
|
||||||
|
// keyword the same way sensorapi/databases/mongo.js already does.
|
||||||
|
const isoStart = (opts) => opts.start.slice(7)
|
||||||
|
const isoStop = (opts) => opts.stop.slice(6)
|
||||||
|
|
||||||
|
const victoriaExport = async (matchSelector, start, end) => {
|
||||||
|
let erg = { values: '', err: null }
|
||||||
|
try {
|
||||||
|
let ret = await axios({
|
||||||
|
method: 'get',
|
||||||
|
url: VICTORIAURL_EXPORT,
|
||||||
|
params: { 'match[]': matchSelector, start, end },
|
||||||
|
timeout: 10000,
|
||||||
|
transformResponse: [(data) => data], // response body is ndjson, not a single JSON document - keep it raw
|
||||||
|
})
|
||||||
|
if (ret.status !== 200) {
|
||||||
|
return returnOnError(erg, 'RESPSTATUS', victoriaExport.name, ret.status)
|
||||||
|
}
|
||||||
|
erg.values = ret.data
|
||||||
|
} catch (e) {
|
||||||
|
return returnOnError(erg, e, victoriaExport.name)
|
||||||
|
}
|
||||||
|
return erg
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchActData = async (opts) => {
|
||||||
|
let ret = { err: null, values: [] }
|
||||||
|
const match = `{__name__=~"noise_(LAeq|LA_min|LA_max|E10tel_eq)", sid="${opts.sensorid}"}`
|
||||||
|
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
|
||||||
|
if (err) {
|
||||||
|
return returnOnError(ret, err, fetchActData.name)
|
||||||
|
}
|
||||||
|
ret.values = exportToRows(values, opts.sort)
|
||||||
|
if (ret.values.length === 0) {
|
||||||
|
return returnOnError(ret, 'NODATA', fetchActData.name)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchNoiseAVGData = async (opts) => {
|
||||||
|
let ret = { err: null, values: [] }
|
||||||
|
const match = `{__name__=~"noise_(E10tel_eq|LA_max)", sid="${opts.sensorid}"}`
|
||||||
|
let { values, err } = await victoriaExport(match, isoStart(opts), isoStop(opts))
|
||||||
|
if (err) {
|
||||||
|
return returnOnError(ret, err, fetchNoiseAVGData.name)
|
||||||
|
}
|
||||||
|
ret.values = bucketNoiseAVG(values, opts.peak, opts.long)
|
||||||
|
if (ret.values.length === 0) {
|
||||||
|
return returnOnError(ret, 'NODATA', fetchNoiseAVGData.name)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
+3
-25
@@ -6,11 +6,9 @@ set -e
|
|||||||
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
|
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
|
||||||
# eigener Dockerfile-Name (Dockerfile_sensorapi).
|
# eigener Dockerfile-Name (Dockerfile_sensorapi).
|
||||||
|
|
||||||
REGISTRY="docker.citysensor.de"
|
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
|
||||||
# Zweite Registry (Gitea). Das Image wird dorthin zusaetzlich gepusht, damit es
|
# REGISTRY=anderes.registry.example ./deploy.sh
|
||||||
# nicht nur an einer Stelle liegt. Leersetzen schaltet den zweiten Push ab:
|
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
|
||||||
# REGISTRY2= ./deploy.sh
|
|
||||||
REGISTRY2="${REGISTRY2-gitea.fuerst-stuttgart.de/admin}"
|
|
||||||
IMAGE_NAME="sensorapi"
|
IMAGE_NAME="sensorapi"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
PLATFORM="linux/amd64"
|
PLATFORM="linux/amd64"
|
||||||
@@ -54,26 +52,6 @@ if [ "${TAG}" != "latest" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dasselbe Image zusaetzlich in die zweite Registry. Ein Fehlschlag bricht das
|
|
||||||
# Skript nicht ab: in ${REGISTRY} liegt das Image dann bereits, der Stack kann
|
|
||||||
# es also ziehen - die Meldung sagt nur, dass die Zweitablage fehlt.
|
|
||||||
if [ -n "${REGISTRY2}" ]; then
|
|
||||||
echo ">>> Pushe zusaetzlich nach ${REGISTRY2}/${IMAGE_NAME} ..."
|
|
||||||
ok=true
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:${TAG}"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:${TAG}" || ok=false
|
|
||||||
if [ "${ok}" = true ] && [ "${TAG}" != "latest" ]; then
|
|
||||||
docker tag "${FULL_IMAGE}" "${REGISTRY2}/${IMAGE_NAME}:latest"
|
|
||||||
docker push "${REGISTRY2}/${IMAGE_NAME}:latest" || ok=false
|
|
||||||
fi
|
|
||||||
if [ "${ok}" != true ]; then
|
|
||||||
echo "!!! Push nach ${REGISTRY2} fehlgeschlagen."
|
|
||||||
echo "!!! Angemeldet? docker login ${REGISTRY2%%/*}"
|
|
||||||
echo "!!! ${FULL_IMAGE} ist gepusht und einsetzbar."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Build + Push fertig: ${FULL_IMAGE}"
|
echo "Build + Push fertig: ${FULL_IMAGE}"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {DateTime} from 'luxon'
|
|||||||
import { translate as trans } from '../routes/api.js'
|
import { translate as trans } from '../routes/api.js'
|
||||||
import * as influx from "../databases/influx.js"
|
import * as influx from "../databases/influx.js"
|
||||||
import * as mongo from "../databases/mongo.js"
|
import * as mongo from "../databases/mongo.js"
|
||||||
|
import * as victoria from "../databases/victoria.js"
|
||||||
import { setoptionfromtable } from "../utilities/chartoptions.js"
|
import { setoptionfromtable } from "../utilities/chartoptions.js"
|
||||||
|
|
||||||
export const getNoiseData = async (params, possibles, props) => {
|
export const getNoiseData = async (params, possibles, props) => {
|
||||||
@@ -451,6 +452,9 @@ const getNoiseAVGData = async (opts) => {
|
|||||||
for (let x=0; x < ret.values.length; x++) {
|
for (let x=0; x < ret.values.length; x++) {
|
||||||
ret.values[x].datetime = DateTime.fromISO(ret.values[x].datetime).toUTC().minus({hours:1}).toFormat("yyyy-LL-dd'T'HH:mm:ss'Z'")
|
ret.values[x].datetime = DateTime.fromISO(ret.values[x].datetime).toUTC().minus({hours:1}).toFormat("yyyy-LL-dd'T'HH:mm:ss'Z'")
|
||||||
}
|
}
|
||||||
|
} else if (DBASE === 'victoria') {
|
||||||
|
// victoria.js buckets by hour-start itself (like mongo), so no shift is needed here
|
||||||
|
ret = await victoria.fetchNoiseAVGData(opts)
|
||||||
} else {
|
} else {
|
||||||
ret.err = 'DBASEUNKNOWN'
|
ret.err = 'DBASEUNKNOWN'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user