31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:14:02 +00:00
admin cf55afda38 Merge branch 'main' of github.com:dnms-org/laermkarte-sensor-community
# Conflicts:
#	noise/deploy.sh
#	readin/deploy.sh
#	sensorapi/deploy.sh
2026-08-05 12:56:25 +00:00
admin 306239cfbc Merge branch 'deploy-target-env' 2026-08-05 12:51:01 +00:00
admin b5ffd8ae4a deploy.sh: Standard-Registry auf ghcr.io/dnms-org/laermkarte-sensor-community umgestellt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:48:07 +00:00
admin add666b297 deploy.sh: Push in zweite Registry (Gitea) entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:40:55 +00:00
admin 8891addccb deploy.sh: Ziel-Registry ueber REGISTRY-Umgebungsvariable einstellbar
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:33:17 +00:00
admin ff789a68ef .gitignore: Verweis auf geloeschte UEBERGABE.md entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:44 +00:00
admin 1c213634c7 E-Mail-Adressen aus Footer und User-Agent entfernt, vonRemote-Ordner ignoriert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:10 +00:00
admin b2f6fa0993 deploy.sh: Images zusaetzlich nach Gitea pushen
Alle vier Skripte schieben das gebaute Image jetzt auch nach
gitea.fuerst-stuttgart.de/admin, damit es nicht nur an einer Stelle liegt.

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

Abschalten mit  REGISTRY2= ./deploy.sh

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:38:35 +00:00
rbsensors f4bf4fffd6 REGISTRY angepasst 2026-07-29 21:59:52 +02:00
rbsensors d7a9a7a487 Update deploy.sh 2026-07-29 20:06:14 +02:00
rbsensors d081b9f52d Update README.md 2026-07-26 17:39:01 +02:00
37 changed files with 8476 additions and 12 deletions
+3
View File
@@ -6,3 +6,6 @@ log/
*.log
.env
**/.env
# Produktionsstand vom Server, nur Referenz
noisesensors/vonRemote/
+2 -2
View File
@@ -34,7 +34,7 @@ Ablage für die Logdateien der einzelnene Container (**noise.log**, **sensorapi.
hier darunter befinden sich alle Daten, sowohl die Datenbanken als auch Konfigurationen. Außerdem werden jeweils die laufend eingelesenen Daten als **actdata.json** abgelegt. Diese werden bei jedem neuen Einlesen wieder überschrieben.
* **data/mongo**
Verzeichnis für die Mongo-Datenbank
* **data/mngo/data**
* **data/mongo/data**
hier befinden sich die eingelesenen Daten
* **data/mongo/entries**
hier liegt die Datei **create.js**, mit deren Hilfe bei ersten Start der Datenbank die Collections und die Indexe erzeugt werden.
@@ -45,7 +45,7 @@ Hier dann die eingelesenen Daten
### Aufrufe
Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Direktoty *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden.
Entpacken des Tar-Files **noise.tgz** in einem beliebigen Verzeichnis (das Verzeichnis *noisesensors* wird angelegt). Der komplette Pfad zu dem Verzeichnis (also ab root) muss in der Environmentvariablen **LOCALDIR** hinterlegt werden.
Nun das *docker-compose.yml* in das Portainer-Stack einfügen. Zusätzlich folgende Environment-Variablen festlegen:
+15
View File
@@ -0,0 +1,15 @@
.vscode
docs
node_modules
tests
.gitignore
build_and_copy
deploy.sh
docker-compose.yml
Dockerfile_esp2sensor
hashpasswd
.env
.env.local
*.log
log/
+7
View File
@@ -0,0 +1,7 @@
node_modules
.env
*.pdf
.DS_Store
._*
docs/beschreibung_tmp.html
docs/beschreibung.html
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"yzane.markdown-pdf"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/server.js"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine
ADD package.json /tmp/package.json
ADD package-lock.json /tmp/package-lock.json
RUN cd /tmp && npm ci
RUN mkdir -p /opt/app && cp -a /tmp/node_modules /tmp/package.json /opt/app/
WORKDIR /opt/app
ADD . /opt/app/
RUN apk add --no-cache tzdata
ENV TZ=Europe/Berlin
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN deluser --remove-home node
EXPOSE 3000
CMD ["npm", "start"]
+59
View File
@@ -0,0 +1,59 @@
import { MongoClient } from 'mongodb';
import dotenv from 'dotenv';
dotenv.config();
const MONGOHOST = process.env.MONGOHOST || 'localhost'
const MONGOPORT = process.env.MONGOPORT || 27017
const MONGOAUTH = process.env.MONGOAUTH || false
const MONGOUSRP = process.env.MONGOUSRP || ''
const MONGOBASE = process.env.MONGOBASE || 'sensor_data'
let MONGO_URL = 'mongodb://'+MONGOHOST+':'+MONGOPORT; // URL to mongo database
if (MONGOAUTH === 'true') {
MONGO_URL = 'mongodb://'+MONGOUSRP+'@' + MONGOHOST + ':' + MONGOPORT + '/?authSource=admin'; // URL to mongo database
}
const DB_NAME = MONGOBASE
let db, usersCollection, propCollection;
let client = null;
export async function initMongo() {
const client = new MongoClient(MONGO_URL);
await client.connect();
db = client.db(DB_NAME);
usersCollection = db.collection('user');
propCollection = db.collection('properties')
return { db, usersCollection, propCollection};
}
export const clientClose = async () => {
if (client) {
client.close()
}
}
export function getCollections() {
return { db, usersCollection, propCollection};
}
export const update_pflux = async(sn, doc) => {
try {
await propCollection.updateOne({_id: sn},{ $set: { 'chip': doc}})
return {"error": null}
} catch (e) {
return { "error": true, "what": e}
}
}
export const get_pflux = async(sn) => {
try {
let r = await propCollection.findOne({_id: sn})
if (r == null) {
return { "error": true, "what": "Not found", "erg": r}
}
return {"error": null, "what": null, "erg": r}
} catch (e) {
return { "error": true, "what": e, "erg": null}
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -e
# Angepasst aus dem noise-/sensorapi-deploy.sh
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_esp2sensor).
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="esp2sensor"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
DOCKERFILE="Dockerfile_esp2sensor"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "esp2sensor Deploy Script (amd64)"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Platform: ${PLATFORM}"
echo "Dockerfile: ${DOCKERFILE}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# Hinweis: Login wird uebersprungen, weil die Credentials fuer ${REGISTRY}
# bereits in ~/.docker/config.json liegen.
# Bei Bedarf manuell: docker login ${REGISTRY}
echo ">>> Baue Image (nur ${PLATFORM}, nativ) ..."
docker build \
--platform "${PLATFORM}" \
-f "${DOCKERFILE}" \
-t "${FULL_IMAGE}" \
.
echo ""
echo ">>> Pushe ${FULL_IMAGE} ..."
docker push "${FULL_IMAGE}"
echo ""
# Falls ein anderer Tag als 'latest' gebaut wurde, zusaetzlich latest setzen+pushen
if [ "${TAG}" != "latest" ]; then
echo ">>> Tagge zusaetzlich als :latest ..."
docker tag "${FULL_IMAGE}" "${REGISTRY}/${IMAGE_NAME}:latest"
docker push "${REGISTRY}/${IMAGE_NAME}:latest"
echo ""
fi
echo "=========================================="
echo "Build + Push fertig: ${FULL_IMAGE}"
echo "=========================================="
echo ""
echo "Danach Stack aktualisieren:"
echo " cd /opt/stacks/esprimo/noisesensors && docker compose up -d esp2sensor"
+25
View File
@@ -0,0 +1,25 @@
services:
app:
build: .
container_name: esp2sensor
ports:
- "3000:3000"
depends_on:
- mongo
volumes:
- .:/app # bind mount für Live-Reload
- /app/node_modules # node_modules vom Host nicht überschreiben
- ./log:/var/log
restart: unless-stopped
mongo:
image: mongo:6
container_name: esp-mongo
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
+65
View File
@@ -0,0 +1,65 @@
# Bedienungsanleitung esp2sensor
Diese Anleitung richtet sich an die Anwender der Webapp. Technische Hintergründe (Deployment, Erststart/Bootstrap) stehen in `beschreibung.md`.
## Anmeldung
Als Benutzername dient die **E-Mail-Adresse**. Während der Eingabe wird geprüft, ob diese bekannt ist; solange die Eingabe unvollständig ist, erscheint *Benutzer nicht gefunden*.
Passwort vergessen? Bitte an einen Admin wenden — der kann es über den **User**-Tab zurücksetzen.
Nach erfolgreicher Anmeldung gelangt man zur Hauptseite mit den Tabs am oberen Rand. Welche Tabs sichtbar sind, hängt davon ab, ob man als **Admin** oder als normaler **User** angemeldet ist. Ganz rechts in der Tab-Zeile befindet sich der **Logout**-Button, mit dem man sich jederzeit abmelden kann.
## Tab „Eingabe“
Dieser Tab ist nach der Anmeldung standardmäßig ausgewählt.
* In die Zeile **Sensornummer** wird die Sensornummer (von sensor.community) eingetragen. Ist sie bereits bekannt, wird die zugehörige Adresse automatisch in der Zeile **Anschrift** angezeigt. Ist sie unbekannt, erscheint eine Fehlermeldung.
* Danach können **ESP-ID**, **Bezeichnung** und **Beschreibung** eingegeben werden. Nur die ESP-ID ist Pflicht, die anderen beiden Felder sind optional. Die Anschrift kann nicht manuell verändert werden, sie ergibt sich immer aus der Sensornummer.
* Über **Speichern** werden die Daten übernommen. Bereits vorhandene Daten zu dieser Sensornummer werden dabei überschrieben.
* Wurde die Sensornummer bereits einmal erfasst, werden die vorhandenen Daten automatisch geladen und können hier geändert werden.
## Tab „Liste“
Zeigt alle erfassten Datensätze. Über **Aktualisieren** wird die Liste neu geladen, über **Seite** und **Limit** lässt sich blättern bzw. die Anzahl der Einträge pro Seite einstellen.
In der Spalte **Aktionen** gibt es zwei Symbole:
* **Stift** (✏️) — lädt den Eintrag zur Bearbeitung in den Eingabe-Tab.
* **Mülleimer** (🗑️) — löscht den Eintrag nach einer Sicherheitsrückfrage.
Die Liste lässt sich nach **SensorNr**, **ESP-ID** und **Datum** auf- oder absteigend sortieren (Pfeile ↑↓ in der Kopfzeile).
## Tab „Profil“
Hier kann jeder angemeldete User sein eigenes Passwort ändern:
* **Aktuelles Passwort**
* **Neues Passwort**
* **Neues Passwort (Wiederholung)**
Über **Ändern** wird das neue Passwort gespeichert. Stimmen die beiden neuen Passwörter nicht überein oder ist das aktuelle Passwort falsch, erscheint eine entsprechende Fehlermeldung.
## Tab „User“ (nur für Admins)
Dieser Tab ist nur sichtbar, wenn man als Admin angemeldet ist.
**Neuen User anlegen:** Benutzername (E-Mail), Passwort und Rolle (*User* oder *Admin*) eingeben und auf **Anlegen** klicken.
**Angemeldete User:** darunter erscheint eine Liste aller vorhandenen User mit E-Mail und Rolle. Pro Zeile stehen zwei Aktionen zur Verfügung:
* **Passwort zurücksetzen** (🔑) — öffnet ein Eingabefeld für ein neues Passwort für diesen User.
* **User löschen** (🗑️) — löscht den User nach einer Sicherheitsrückfrage. Der eigene Account kann nicht gelöscht werden.
## Abmelden
Über den **Logout**-Button rechts in der Tab-Zeile wird die Sitzung beendet und man gelangt zurück zur Login-Seite.
### History
| Version | Datum | Author |Bemerkung|
|---------|-------|-----|----------|
| 1.0.0 | 2026-08-07 | rxf | erste Fassung
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+30
View File
@@ -0,0 +1,30 @@
# Beschreibung von esp2senor
### Erststart / Bootstrap
Ist die `user`-Collection in der Mongo-Datenbank leer (z.B. bei einem neuen, extern verwalteten Deployment ohne direkten Datenbankzugriff), legt der Server beim Start automatisch einen Admin-User an — vorausgesetzt, die Umgebungsvariablen `ADMIN_EMAIL` und `ADMIN_PASSWORD` sind gesetzt. Fehlen sie, erscheint auf der Login-Seite ein entsprechender Hinweis und im Log eine Warnung.
Nach dem ersten Login mit diesen Zugangsdaten sollte das Passwort über den **Profil**-Tab geändert werden. Sobald mindestens ein User existiert, greift der Bootstrap-Mechanismus nicht mehr — weitere User werden wie gewohnt über den **User**-Tab (nur für Admins) angelegt.
### Darstellung
Die Login-Seite dient der Anmeldung per E-Mail und Passwort. Nach erfolgreicher Anmeldung erscheint die Hauptseite mit den Tabs **Eingabe**, **Liste**, **Profil** und — nur für Admins — **User**, sowie einem **Logout**-Button ganz rechts in der Tab-Zeile.
* **Eingabe**: Erfassen/Ändern eines Datensatzes über Sensornummer (mit automatischer Adressermittlung), ESP-ID, Bezeichnung und Beschreibung.
* **Liste**: paginierte, sortierbare Übersicht aller Datensätze mit Bearbeiten- und Löschen-Aktion je Zeile.
* **Profil**: Ändern des eigenen Passworts (aktuelles Passwort, neues Passwort, Wiederholung).
* **User** (nur Admin): Anlegen neuer User (E-Mail, Passwort, Rolle) sowie eine Liste aller vorhandenen User mit Aktionen zum Passwort-Zurücksetzen und Löschen (der eigene Account kann nicht gelöscht werden).
Eine ausführliche Bedienungsanleitung für Endanwender steht in `Bedienungsanleitung.md`.
#### History
| Version | Datum | Author |Bemerkung|
|---------|-------|-----|----------|
| 1.2.0 | 2026-08-07 | rxf | verkürzt auf technische Dinge, Verweis auf Bedienunsgaleitung
| 1.1.0 | 2025-09-03 | rxf | Ändern,Löschen und Usereingabe |
|1.0.0 | 2025-08-19 | rxf | erste Fassung |
+3
View File
@@ -0,0 +1,3 @@
import bcrypt from 'bcrypt';
const hashedPassword = await bcrypt.hash('Tux4esp', 10);
console.log(hashedPassword)
+6549
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "espid2sensor",
"version": "1.6.3",
"date": "2026-08-07",
"type": "module",
"description": "Kleine Webapp ESP-ID <-> Sensornummer, speichern in MongoDB",
"main": "server.js",
"scripts": {
"start": "node server.js >>/var/log/esp2sensor.log 2>&1",
"dev": "nodemon --watch server.js --watch views --watch public server.js",
"test": "jest"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-session": "^1.18.2",
"mongodb": "^6.19.0",
"pug": "^3.0.3"
},
"devDependencies": {
"jest": "^30.1.3",
"nodemon": "^3.1.10",
"supertest": "^7.1.4"
}
}
+602
View File
@@ -0,0 +1,602 @@
// Tab-Wechsel Funktion aus index.pug
function showTab(tab) {
document.getElementById('tabInputContent').style.display = tab === 'input' ? '' : 'none';
document.getElementById('tabListContent').style.display = tab === 'list' ? '' : 'none';
document.getElementById('tabProfileContent').style.display = tab === 'profile' ? '' : 'none';
const tabUserContent = document.getElementById('tabUserContent');
if (tabUserContent) tabUserContent.style.display = tab === 'user' ? '' : 'none';
document.getElementById('tabInput').classList.toggle('active', tab === 'input');
document.getElementById('tabList').classList.toggle('active', tab === 'list');
document.getElementById('tabProfile').classList.toggle('active', tab === 'profile');
const tabUser = document.getElementById('tabUser');
if (tabUser) tabUser.classList.toggle('active', tab === 'user');
}
// User-Tab Handling (nur für Admins)
document.addEventListener('DOMContentLoaded', () => {
const userSaveBtn = document.getElementById('userSaveBtn');
if (userSaveBtn) {
userSaveBtn.addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const role = document.getElementById('role').value;
const userResult = document.getElementById('userResult');
if (!username || !password) {
userResult.textContent = 'Benutzername und Passwort erforderlich.';
return;
}
try {
const res = await fetch('/api/createUser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
});
const data = await res.json();
if (data.success) {
userResult.textContent = 'User erfolgreich angelegt!';
if (window.loadUsers) window.loadUsers();
} else {
userResult.textContent = data.error || 'Fehler beim Anlegen.';
}
} catch (err) {
userResult.textContent = 'Serverfehler.';
}
});
}
const profileSaveBtn = document.getElementById('profileSaveBtn');
if (profileSaveBtn) {
profileSaveBtn.addEventListener('click', async () => {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const newPasswordRepeat = document.getElementById('newPasswordRepeat').value;
const profileResult = document.getElementById('profileResult');
if (!currentPassword || !newPassword) {
profileResult.textContent = 'Aktuelles und neues Passwort erforderlich.';
return;
}
if (newPassword !== newPasswordRepeat) {
profileResult.textContent = 'Die Wiederholung stimmt nicht mit dem neuen Passwort überein.';
return;
}
try {
const res = await fetch('/api/changePassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (data.success) {
profileResult.textContent = 'Passwort erfolgreich geändert!';
document.getElementById('profileForm').reset();
} else {
profileResult.textContent = data.error || 'Fehler beim Ändern.';
}
} catch (err) {
profileResult.textContent = 'Serverfehler.';
}
});
}
});
function updateSortArrows() {
const arrows = {
sensorNr: document.getElementById('sortArrowSensorNr'),
espId: document.getElementById('sortArrowEspId'),
date: document.getElementById('sortArrowDate')
};
Object.entries(arrows).forEach(([key, el]) => {
if (!el) return;
// Aktiver Pfeil fett, andere ausgegraut
el.textContent = currentSort.key === key
? (currentSort.asc ? '↑' : '↓')
: '↑';
el.style.fontWeight = currentSort.key === key ? 'bold' : 'normal';
el.style.opacity = currentSort.key === key ? '1' : '0.3';
});
}
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
const refreshBtn = document.getElementById('refreshBtn');
const espIdInput = document.getElementById('espId');
const sensorNumberInput = document.getElementById('sensorNumber');
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
const addressInput = document.getElementById('address');
const pageInput = document.getElementById('page');
const limitInput = document.getElementById('limit');
const resultDiv = document.getElementById('result');
const tableBody = document.querySelector('#entriesTable tbody');
const tabInput = document.getElementById('tabInput');
const tabList = document.getElementById('tabList');
const cancelBtn = document.getElementById('cancelBtn');
const userCancelBtn = document.getElementById('userCancelBtn');
// Modal für Fehleranzeige
function showModal(message, showCancelButton, callback) {
// Remove previous modals
document.querySelectorAll('.custom-modal-popup').forEach(m => m.remove());
let modal = document.createElement('div');
modal.className = 'custom-modal-popup';
let box = document.createElement('div');
box.className = 'custom-modal-box';
let msg = document.createElement('div');
msg.className = 'custom-modal-msg';
msg.textContent = message;
box.appendChild(msg);
let btndiv = document.createElement('div')
btndiv.className = 'twobuttons'
// Cancel Button (only if showCancelButton is true)
if (showCancelButton) {
let btnCancel = document.createElement('button');
btnCancel.className = 'custom-modal-btn';
btnCancel.textContent = 'Abbruch';
btnCancel.onclick = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(false); // Pass false for Cancel
};
btndiv.appendChild(btnCancel);
}
// OK Button
let btnOk = document.createElement('button');
btnOk.className = 'custom-modal-btn';
btnOk.textContent = 'OK';
btnOk.onclick = () => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(true); // Pass true for OK
};
btndiv.appendChild(btnOk);
box.appendChild(btndiv)
modal.appendChild(box);
document.body.appendChild(modal);
// Optional: Close modal when clicking outside
modal.onclick = (e) => {
if (e.target === modal) {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (callback) callback(false); // Treat as cancel
}
};
}
// Modal für Passwort-Eingabe (z.B. Passwort-Reset aus der User-Liste)
function showInputModal(message, callback) {
document.querySelectorAll('.custom-modal-popup').forEach(m => m.remove());
let modal = document.createElement('div');
modal.className = 'custom-modal-popup';
let box = document.createElement('div');
box.className = 'custom-modal-box';
let msg = document.createElement('div');
msg.className = 'custom-modal-msg';
msg.textContent = message;
box.appendChild(msg);
let input = document.createElement('input');
input.type = 'password';
input.className = 'custom-modal-input';
box.appendChild(input);
let btndiv = document.createElement('div');
btndiv.className = 'twobuttons';
const close = () => {
if (modal.parentNode) modal.parentNode.removeChild(modal);
};
let btnCancel = document.createElement('button');
btnCancel.className = 'custom-modal-btn';
btnCancel.textContent = 'Abbruch';
btnCancel.onclick = () => {
close();
callback(null);
};
btndiv.appendChild(btnCancel);
let btnOk = document.createElement('button');
btnOk.className = 'custom-modal-btn';
btnOk.textContent = 'OK';
btnOk.onclick = () => {
const value = input.value;
close();
callback(value || null);
};
btndiv.appendChild(btnOk);
box.appendChild(btndiv);
modal.appendChild(box);
document.body.appendChild(modal);
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
btnOk.click();
}
});
modal.onclick = (e) => {
if (e.target === modal) {
close();
callback(null);
}
};
}
// Sensornummer nur Zahlen erlauben
sensorNumberInput.addEventListener('input', () => {
sensorNumberInput.value = sensorNumberInput.value.replace(/\D/g, '');
});
// Adresse vom Server holen, wenn Enter oder Feld verlassen
async function fetchAddressIfValid() {
const value = sensorNumberInput.value.trim();
if (value.length > 0) {
try {
const res = await fetch(`/api/address/${value}`);
const data = await res.json();
console.dir(data)
if (!data.error && data.address) {
addressInput.value = data.address;
// Felder automatisch füllen, wenn props vorhanden
if (!data.props.error) {
if (Object.hasOwn(data.props, "chip")) {
let pp = data.props.chip
espIdInput.value = pp.id || ''
nameInput.value = pp.name || ''
descriptionInput.value = pp.description || ''
// Weitere Felder nach Bedarf
}
}
} else {
addressInput.value = '';
sensorNumberInput.disabled = true;
showModal('Sensor unbekannt', false, () => {
sensorNumberInput.disabled = false;
sensorNumberInput.focus();
});
}
} catch (err) {
console.error('Fehler beim Abrufen der Adresse:', err);
addressInput.value = '';
sensorNumberInput.disabled = true;
showModal('Sensor unbekannt', false, () => {
sensorNumberInput.disabled = false;
sensorNumberInput.focus();
});
}
}
}
// Enter-Taste
sensorNumberInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
fetchAddressIfValid();
}
});
// Feld verlassen
sensorNumberInput.addEventListener('blur', fetchAddressIfValid);
async function saveEntry() {
const espId = espIdInput.value.trim();
const sensorNumber = sensorNumberInput.value.trim();
const name = nameInput.value.trim();
const description = descriptionInput.value.trim();
const address = addressInput.value.trim();
if (!espId || !sensorNumber) {
resultDiv.textContent = 'ESP-ID und Sensornummer sind Pflichtfelder.';
return;
}
try {
const url = '/api/save';
const method = 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ espId, sensorNumber, name, description, address })
});
const data = await res.json();
if (data.error) {
resultDiv.textContent = data.error;
} else {
resultDiv.textContent = 'OK!';
setTimeout(() => {
resultDiv.textContent = ''
saveBtn.textContent = 'Speichern';
}, 5000)
clearForm(false);
await loadEntries();
}
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Speichern.';
}
}
function clearForm(mitButton) {
espIdInput.value = '';
sensorNumberInput.value = '';
nameInput.value = '';
descriptionInput.value = '';
addressInput.value = '';
if (mitButton) {
saveBtn.textContent = 'Speichern';
}
}
const clearUserForm = () => {
document.getElementById('username').value = ''
document.getElementById('password').value = ''
document.getElementById('role').value = 'user'
}
// Globale Sortier-Variable
window.currentSort = window.currentSort || { key: null, asc: true };
async function loadEntries() {
const page = parseInt(pageInput.value) || 1;
const limit = parseInt(limitInput.value) || 50;
try {
const res = await fetch(`/api/list?page=${page}&limit=${limit}`);
const erg = await res.json();
const items = erg.items
const gz = document.getElementById('gzahl');
gz.innerHTML = `Gesamtzahl: ${erg.anzahl}`
let currentSort = window.currentSort || { key: null, asc: true };
function renderTable(sortedItems) {
tableBody.innerHTML = '';
sortedItems.forEach(item => {
const date = new Date(item.chip.lastUpdatedAt).toISOString().split('T')[0];
const tr = document.createElement('tr');
tr.innerHTML = `
<td id="tdSensornumber">${item._id}</td>
<td>${item.chip.id}</td>
<td>${item.chip.name || ''}</td>
<td id="tdBeschreibung">${item.chip.description || ''}</td>
<td id="tdDate">${date}</td>
<td>
<div class="twobuttons">
<button data-id="${item._id}" class="editBtn" title="Bearbeiten">✏️</button>
<button data-id="${item._id}" class="deleteBtn" title="Löschen">🗑️</button>
</div>
</td>
`;
tableBody.appendChild(tr);
});
}
function sortItems(items, key, asc) {
return items.slice().sort((a, b) => {
let valA, valB;
if (key === 'sensorNr') {
valA = a._id;
valB = b._id;
} else if (key === 'espId') {
valA = a.chip.id;
valB = b.chip.id;
} else if (key === 'date') {
valA = new Date(a.chip.lastUpdatedAt);
valB = new Date(b.chip.lastUpdatedAt);
}
if (valA < valB) return asc ? -1 : 1;
if (valA > valB) return asc ? 1 : -1;
return 0;
});
}
// Initial render: Standard nach SensorNr, ESP-ID oder Datum aufsteigend
// Ändere hier die Spalte für die Standardsortierung:
const defaultSortKey = window.currentSort && window.currentSort.key ? window.currentSort.key : 'sensorNr';
const defaultSortAsc = window.currentSort && typeof window.currentSort.asc === 'boolean' ? window.currentSort.asc : true;
currentSort.key = defaultSortKey;
currentSort.asc = defaultSortAsc;
window.currentSort = currentSort;
renderTable(sortItems(items, defaultSortKey, defaultSortAsc));
updateSortArrows();
// Add sort listeners
document.getElementById('thSensorNr').onclick = () => {
currentSort.asc = currentSort.key === 'sensorNr' ? !currentSort.asc : true;
currentSort.key = 'sensorNr';
window.currentSort = currentSort;
renderTable(sortItems(items, 'sensorNr', currentSort.asc));
updateSortArrows();
};
document.getElementById('thEspId').onclick = () => {
currentSort.asc = currentSort.key === 'espId' ? !currentSort.asc : true;
currentSort.key = 'espId';
window.currentSort = currentSort;
renderTable(sortItems(items, 'espId', currentSort.asc));
updateSortArrows();
};
document.getElementById('thDate').onclick = () => {
currentSort.asc = currentSort.key === 'date' ? !currentSort.asc : true;
currentSort.key = 'date';
window.currentSort = currentSort;
renderTable(sortItems(items, 'date', currentSort.asc));
updateSortArrows();
};
document.querySelectorAll('.deleteBtn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
await deleteEntry(id);
});
});
document.querySelectorAll('.editBtn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-id');
const res = await fetch(`/api/list?page=1&limit=1&id=${id}`);
const items = await res.json();
const item = items.find(e => e._id === parseInt(id));
if (item) {
espIdInput.value = item.chip.id;
sensorNumberInput.value = item._id;
nameInput.value = item.chip.name || '';
descriptionInput.value = item.chip.description || '';
addressInput.value = '';
saveBtn.textContent = 'Aktualisieren';
showTab('input')
try {
const rt = await fetch(`api/holAdresse/${item._id}`)
const data = await rt.json();
console.dir(data)
if (!data.error && data.address) {
addressInput.value = data.address;
}
} catch (e) {
console.log("Fehler beim Adresse holen", e)
}
}
});
});
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Laden.';
}
}
async function deleteEntry(id) {
showModal('Wirklich löschen?', true, async (confirmed) => {
if (confirmed) {
try {
const res = await fetch(`/api/delete/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
await loadEntries();
resultDiv.textContent = 'Eintrag gelöscht.';
setTimeout(() => resultDiv.textContent = '', 3000);
} else {
resultDiv.textContent = 'Fehler beim Löschen.';
}
} catch (err) {
console.error(err);
resultDiv.textContent = 'Fehler beim Löschen.';
}
}
});
}
async function loadUsers() {
const usersTableBody = document.querySelector('#usersTable tbody');
const usersResult = document.getElementById('usersResult');
if (!usersTableBody) return;
try {
const res = await fetch('/api/users');
const users = await res.json();
usersTableBody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.email}</td>
<td>${u.role}</td>
<td>
<div class="twobuttons">
<button data-id="${u._id}" data-email="${u.email}" class="editBtn resetUserBtn" title="Passwort zurücksetzen">🔑</button>
<button data-id="${u._id}" class="deleteBtn deleteUserBtn" title="Löschen">🗑️</button>
</div>
</td>
`;
usersTableBody.appendChild(tr);
});
document.querySelectorAll('.resetUserBtn').forEach(btn => {
btn.addEventListener('click', () => {
const email = btn.getAttribute('data-email');
showInputModal(`Neues Passwort für ${email}:`, async (newPassword) => {
if (!newPassword) return;
try {
const res = await fetch('/api/resetPassword', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, newPassword })
});
const data = await res.json();
usersResult.textContent = data.success ? `Passwort für ${email} zurückgesetzt.` : (data.error || 'Fehler beim Zurücksetzen.');
} catch (err) {
usersResult.textContent = 'Serverfehler.';
}
});
});
});
document.querySelectorAll('.deleteUserBtn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-id');
showModal('User wirklich löschen?', true, async (confirmed) => {
if (!confirmed) return;
try {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
usersResult.textContent = 'User gelöscht.';
await loadUsers();
} else {
usersResult.textContent = data.error || 'Fehler beim Löschen.';
}
} catch (err) {
usersResult.textContent = 'Serverfehler.';
}
});
});
});
} catch (err) {
console.error(err);
}
}
saveBtn.addEventListener('click', saveEntry);
refreshBtn.addEventListener('click', loadEntries);
cancelBtn.addEventListener('click', () => clearForm(true));
if (userCancelBtn) userCancelBtn.addEventListener('click', () => clearUserForm(true));
tabInput.addEventListener('click', () => showTab('input'))
tabList.addEventListener('click', () => showTab('list'))
const tabUser = document.getElementById('tabUser');
if (tabUser) tabUser.addEventListener('click', () => {
showTab('user');
clearUserForm();
const userResult = document.getElementById('userResult');
if (userResult) userResult.textContent = '';
const usersResult = document.getElementById('usersResult');
if (usersResult) usersResult.textContent = '';
loadUsers();
})
loadEntries();
window.loadUsers = loadUsers;
});
window.showTab = showTab;
+34
View File
@@ -0,0 +1,34 @@
// public/login.js
document.addEventListener('DOMContentLoaded', () => {
const emailInput = document.getElementById('email');
const emailStatus = document.getElementById('emailStatus');
let debounceTimeout;
emailInput.addEventListener('input', () => {
clearTimeout(debounceTimeout);
const email = emailInput.value.trim();
if (!email) {
emailStatus.textContent = '';
return;
}
debounceTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const data = await res.json();
if (data.exists) {
emailStatus.textContent = '✅ Benutzer existiert';
emailStatus.style.color = 'green';
} else {
emailStatus.textContent = '❌ Benutzer nicht gefunden';
emailStatus.style.color = 'red';
}
} catch (err) {
console.error(err);
emailStatus.textContent = 'Fehler bei der Prüfung';
emailStatus.style.color = 'orange';
}
}, 300);
});
});
+261
View File
@@ -0,0 +1,261 @@
*, *::before, *::after {
box-sizing: border-box;
}
/* Tab Navigation */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab-btn {
background: #eee;
border: none;
padding: 0.7rem 2rem;
font-size: 1.1rem;
border-radius: 6px 6px 0 0;
cursor: pointer;
color: #333;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.tab-btn.active {
background: #007bff;
color: #fff;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
}
#tabUser {
margin-left: 50px;
}
#logoutBtn {
margin-left: auto;
text-decoration: none;
}
/* Modal Fehlerfenster */
.custom-modal-popup {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.custom-modal-box {
background: #fff;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
text-align: center;
min-width: 350px;
max-width: 90vw;
}
.custom-modal-msg {
margin-bottom: 2rem;
font-size: 1.5rem;
color: red;
}
.custom-modal-input {
display: block;
width: 80%;
margin: 0 auto 1.5rem auto;
}
.custom-modal-btn {
padding: 0.8rem 2.5rem;
font-size: 1.1rem;
background: #007bff;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
body {
font-family: system-ui, sans-serif;
padding: 20px;
max-width: 800px;
margin: auto;
}
input, button {
font-size: 1rem;
padding: 8px;
margin: 2px;
}
.card {
border: 1px solid #ddd;
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
background-color: antiquewhite;
}
#result {
margin-top: 10px;
}
.controls {
margin-bottom: 8px;
}
.controls input {
width: 50px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 30px;
}
th, td {
text-align: left;
padding: 4px;
border-bottom: 1px solid #888;
}
/* Spaltenbreiten über colgroup steuern */
col.col-sensornumber { width: 7em; }
col.col-espid {width: 9em}
col.col-bezeichnung { width: 8em; }
col.col-beschreibung{ width: 15em; }
col.col-date { width: 10em; }
col.col-aktionen { width: 2em; }
.controls input#page,
.controls input#limit {
width: 50px;
margin-left: 10px;
}
.error {
color: red;
font-weight: bold;
margin: 10px 0;
}
form {
max-width: 400px;
margin: 0 auto;
padding: 1rem;
border-radius: 8px;
background: #f9f9f9;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color:burlywood
}
label {
font-weight: bold;
}
input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.6rem;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
font-size: 1rem;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
.editBtn, .deleteBtn {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s ease;
margin: 0 2px;
}
.editBtn:hover {
background: rgba(0, 123, 255, 0.1);
transform: scale(1.1);
}
.deleteBtn:hover {
background: rgba(220, 53, 69, 0.1);
transform: scale(1.1);
}
.twobuttons {
display: flex;
justify-content: space-between;
width: 100%;
gap: 5px;
}
p.error {
color: red;
font-weight: bold;
text-align: center;
}
.card form {
display: flex;
flex-direction: column;
align-items: flex-start; /* Links bündig */
gap: 0.5rem;
}
.card form label {
font-weight: bold;
}
.card form input,
.card form textarea {
width: 100%;
max-width: 400px; /* gleiche Breite */
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.card form textarea {
min-height: 60px;
resize: vertical;
}
#gzahl {
margin-left: 30px;
}
#role {
font-size: 12pt;
padding: 5px 0 5px 3px;
margin-bottom: 20px;
}
#version {
width: 100%;
display: flex;
justify-content: flex-end;
font-size: 70%;
color: #007bff;
margin-top: 15px;
}
+64
View File
@@ -0,0 +1,64 @@
import { MongoClient } from 'mongodb';
import { get_pflux } from '../db/mongo.js';
import { getCollections, update_pflux, clientClose } from '../db/mongo.js';
export function registerAddressRoute(app, requireLogin) {
const APIHOST = process.env.APIHOST || 'https://noise.fuerst-stuttgart.de/srv/';
const holAdresse = async (id) => {
// Adresse wie bisher holen (über die Sensornummer via nominative)
let addressString = '';
let addrParts = {};
try {
const url = APIHOST + 'getaddress/' + `?sensorid=${id}`;
console.log(url)
const r = await fetch(url, { headers: { 'Accept': 'application/json' } });
if (r.ok) {
const data = await r.json();
const addrObj = data?.erg?.address || data?.address || {};
const street = addrObj.street ?? '';
const plz = addrObj.plz ?? '';
const city = addrObj.city ?? '';
const rightPart = [plz, city].filter(Boolean).join(' ').trim();
addressString = [street, rightPart].filter(Boolean).join(', ');
addrParts = { street, plz, city };
}
} catch (err) {
console.error('Address lookup failed:', err);
}
return {
address: addressString,
parts: addrParts,
}
}
app.get('/api/holAdresse/:sensorNumber', requireLogin, async (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
const addr = await holAdresse(sensorNumber)
res.json(addr)
})
app.get('/api/address/:sensorNumber', requireLogin, async (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
const { propCollection, prop_fluxCollection } = getCollections();
// Suche nach Sensornummer als _id
const propEntry = await propCollection.findOne({ _id: sensorNumber });
if (!propEntry) {
await clientClose()
return res.status(404).json({ error: 'Sensor nicht gefunden' });
}
const adr = await holAdresse(encodeURIComponent(propEntry._id))
adr.props = propEntry
return res.json(adr)
})
}
+155
View File
@@ -0,0 +1,155 @@
import { MongoAPIError, ObjectId } from 'mongodb';
import bcrypt from 'bcrypt';
import { getCollections, update_pflux } from '../db/mongo.js';
export function registerApiRoutes(app, requireLogin) {
const { usersCollection, propCollection } = getCollections();
app.get('/api/check-email', async (req, res) => {
const email = (req.query.email || '').toLowerCase().trim();
if (!email) return res.json({ exists: false });
try {
const existingUser = await usersCollection.findOne({ email:`${email}` });
res.json({ exists: !!existingUser });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler bei der E-Mail-Prüfung' });
}
});
app.post('/api/save', requireLogin, async (req, res) => {
let { espId, sensorNumber, name, description} = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
try {
const doc = {
id: espId,
name: name || '',
description: description || '',
lastUpdatedAt: new Date()
};
await update_pflux(sensorNumber, doc)
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler beim Speichern' });
}
});
app.get('/api/list', requireLogin, async (req, res) => {
const { id } = req.query;
if (id) {
try {
const item = await propCollection.findOne({ _id: parseInt(id) });
if (item) return res.json([item]);
return res.json([]);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Fehler beim Laden' });
}
}
let gesamtZahl = 0
try {
gesamtZahl = await propCollection.countDocuments({chip: {$exists: true}})
} catch (err) {
console.error(err);
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const skip = (page - 1) * limit;
try {
const items = await propCollection.find({chip: {$exists: true}})
.sort({ "chip.lastUpdatedAt": -1 })
.skip(skip)
.limit(limit)
.toArray();
const data = {items: items, anzahl: gesamtZahl}
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Fehler beim Laden' });
}
});
app.delete('/api/delete/:id', requireLogin, async (req, res) => {
await propCollection.deleteOne({ _id: parseInt(req.params.id) });
res.json({ success: true });
});
app.post('/api/createUser', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
try {
const hash = await bcrypt.hash(password, 10);
await usersCollection.insertOne({ email: username.toLowerCase(), passwordHash: hash, role: role || 'user' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Anlegen' });
}
});
app.post('/api/changePassword', requireLogin, async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich' });
}
try {
const user = await usersCollection.findOne({ _id: new ObjectId(req.session.userId) });
if (!user) return res.status(404).json({ error: 'User nicht gefunden' });
const match = await bcrypt.compare(currentPassword, user.passwordHash);
if (!match) return res.status(403).json({ error: 'Aktuelles Passwort ist falsch' });
const hash = await bcrypt.hash(newPassword, 10);
await usersCollection.updateOne({ _id: user._id }, { $set: { passwordHash: hash } });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Ändern des Passworts' });
}
});
app.post('/api/resetPassword', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { email, newPassword } = req.body;
if (!email || !newPassword) return res.status(400).json({ error: 'Email und neues Passwort erforderlich' });
try {
const hash = await bcrypt.hash(newPassword, 10);
const result = await usersCollection.updateOne(
{ email: email.toLowerCase() },
{ $set: { passwordHash: hash } }
);
if (result.matchedCount === 0) return res.status(404).json({ error: 'User nicht gefunden' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Zurücksetzen' });
}
});
app.get('/api/users', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
try {
const users = await usersCollection.find({}, { projection: { email: 1, role: 1 } }).sort({ email: 1 }).toArray();
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Fehler beim Laden der User' });
}
});
app.delete('/api/users/:id', requireLogin, async (req, res) => {
if (!req.session.isAdmin) return res.status(403).json({ error: 'Nur Admins erlaubt' });
const { id } = req.params;
if (id === String(req.session.userId)) {
return res.status(400).json({ error: 'Der eigene Account kann nicht gelöscht werden' });
}
try {
const result = await usersCollection.deleteOne({ _id: new ObjectId(id) });
if (result.deletedCount === 0) return res.status(404).json({ error: 'User nicht gefunden' });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen' });
}
});
}
+40
View File
@@ -0,0 +1,40 @@
import bcrypt from 'bcrypt';
import { getCollections } from '../db/mongo.js';
import pkg from '../package.json' with { type: "json" }
export function registerAuthRoutes(app) {
const { usersCollection } = getCollections();
const errText = 'Falsche Email oder falsches Passwort.'
app.get('/login', async (req, res) => {
const version = pkg.version
const vdate = pkg.date
const noUsers = (await usersCollection.countDocuments()) === 0;
res.render('login', {
error: null,
version: version,
vdate: vdate,
noUsers: noUsers
})
});
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const version = pkg.version
const vdate = pkg.date
const noUsers = (await usersCollection.countDocuments()) === 0;
const renderError = () => res.render('login', { error: errText, version, vdate, noUsers });
const user = await usersCollection.findOne({ email: email.toLowerCase() });
if (!user) return renderError();
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) return renderError();
req.session.userId = user._id;
req.session.isAdmin = user.role === 'admin';
res.redirect('/');
});
app.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
}
+87
View File
@@ -0,0 +1,87 @@
import express from 'express';
import session from 'express-session';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import pkg from './package.json' with { type: "json" }
dotenv.config();
import { initMongo, getCollections } from './db/mongo.js';
import { registerApiRoutes } from './routes/api.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerAddressRoute } from './routes/address.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const SESSION_SECRET = process.env.SESSION_SECRET || 'supersecret';
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 }
}));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
// DB verbinden
await initMongo();
await bootstrapAdmin();
// Legt beim allerersten Start (leere user-Collection) einen Admin aus
// ADMIN_EMAIL/ADMIN_PASSWORD an - notwendig für extern verwaltete Deployments
// ohne direkten Mongo-Zugriff (z.B. per Portainer betriebene Server).
async function bootstrapAdmin() {
const { usersCollection } = getCollections();
const count = await usersCollection.countDocuments();
if (count > 0) return;
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
console.warn('Keine User in der Datenbank und ADMIN_EMAIL/ADMIN_PASSWORD nicht gesetzt — es kann sich niemand einloggen.');
return;
}
const passwordHash = await bcrypt.hash(password, 10);
await usersCollection.insertOne({ email: email.toLowerCase(), passwordHash, role: 'admin' });
console.log(`Bootstrap-Admin angelegt: ${email.toLowerCase()}`);
}
// Login-Middleware
function requireLogin(req, res, next) {
// Entwicklungs-Bypass: Automatischer Login als Admin
if (process.env.DEV_AUTO_LOGIN === 'true') {
req.session.userId = 'dev-user';
req.session.isAdmin = true;
return next();
}
if (req.session.userId) {
return next();
}
res.redirect('/login');
}
// Routen registrieren
registerAuthRoutes(app);
registerApiRoutes(app, requireLogin);
registerAddressRoute(app, requireLogin);
// Hauptseite
app.get('/', requireLogin, (req, res) => {
const version = pkg.version
const vdate = pkg.date
const isAdmin = req.session && req.session.isAdmin;
res.render('index', { isAdmin, version, vdate });
});
app.listen(PORT, () => console.log(`Server läuft auf http://localhost:${PORT}`));
+166
View File
@@ -0,0 +1,166 @@
const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
// ...existing code...
// tests/server.test.js
describe('Server.js API', () => {
let app;
let entries = [];
let users = [];
beforeEach(() => {
app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Mock session middleware
app.use((req, res, next) => { req.session = {}; next(); });
// /api/check-email
app.get('/api/check-email', (req, res) => {
const email = (req.query.email || '').toLowerCase().trim();
if (!email) return res.json({ exists: false });
const existingUser = users.find(u => u.email === email);
res.json({ exists: !!existingUser });
});
// /api/save
app.post('/api/save', (req, res) => {
let { espId, sensorNumber, name, description, address } = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
const doc = { espId, sensorNumber, name, description, address, lastUpdatedAt: new Date(), _id: String(entries.length + 1) };
entries.push(doc);
res.json({ success: true });
});
// /api/update/:id
app.put('/api/update/:id', (req, res) => {
const { id } = req.params;
let { espId, sensorNumber, name, description, address } = req.body;
if (!espId || !sensorNumber) {
return res.json({ error: 'ESP-ID und Sensornummer sind Pflichtfelder' });
}
sensorNumber = parseInt(sensorNumber, 10);
const idx = entries.findIndex(e => e._id === id);
if (idx === -1) return res.status(404).json({ error: 'Not found' });
entries[idx] = { ...entries[idx], espId, sensorNumber, name, description, address };
res.json({ success: true });
});
// /api/list
app.get('/api/list', (req, res) => {
const { id } = req.query;
if (id) {
const item = entries.find(e => e._id === id);
return res.json(item ? [item] : []);
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
res.json(entries.slice(skip, skip + limit));
});
// /api/delete/:id
app.delete('/api/delete/:id', (req, res) => {
const { id } = req.params;
entries = entries.filter(e => e._id !== id);
res.json({ success: true });
});
// /api/address/:sensorNumber
app.get('/api/address/:sensorNumber', (req, res) => {
const sensorNumber = parseInt(req.params.sensorNumber, 10);
if (isNaN(sensorNumber)) {
return res.status(400).json({ error: 'Ungültige Sensornummer' });
}
// Dummy logic
if (sensorNumber === 1001) {
return res.json({ address: 'Musterstraße 1, 12345 Musterstadt', parts: { street: 'Musterstraße 1', plz: '12345', city: 'Musterstadt' } });
}
return res.status(404).json({ error: 'Sensor unbekannt' });
});
});
beforeEach(() => {
entries = [];
users = [{ email: 'test@example.com', passwordHash: 'hash' }];
});
test('GET /api/check-email returns exists: true for known user', async () => {
const res = await request(app).get('/api/check-email?email=test@example.com');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('exists', true);
});
test('GET /api/check-email returns exists: false for unknown user', async () => {
const res = await request(app).get('/api/check-email?email=unknown@example.com');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('exists', false);
});
test('POST /api/save creates entry', async () => {
const res = await request(app).post('/api/save').send({ espId: 'esp1', sensorNumber: '1001', name: 'Test', description: 'Desc', address: 'Addr' });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(entries.length).toBe(1);
});
test('POST /api/save fails without espId', async () => {
const res = await request(app).post('/api/save').send({ sensorNumber: '1001' });
expect(res.body).toHaveProperty('error');
});
test('PUT /api/update/:id updates entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).put('/api/update/1').send({ espId: 'esp2', sensorNumber: '1002', name: 'Neu', description: 'Neu', address: 'Neu' });
expect(res.body).toHaveProperty('success', true);
expect(entries[0].espId).toBe('esp2');
});
test('PUT /api/update/:id fails for unknown id', async () => {
const res = await request(app).put('/api/update/999').send({ espId: 'esp2', sensorNumber: '1002', name: 'Neu', description: 'Neu', address: 'Neu' });
expect(res.statusCode).toBe(404);
});
test('GET /api/list returns all entries', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).get('/api/list');
expect(res.body.length).toBe(1);
});
test('GET /api/list?id returns specific entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).get('/api/list?id=1');
expect(res.body.length).toBe(1);
expect(res.body[0]._id).toBe('1');
});
test('DELETE /api/delete/:id deletes entry', async () => {
entries.push({ _id: '1', espId: 'esp1', sensorNumber: 1001, name: '', description: '', address: '', lastUpdatedAt: new Date() });
const res = await request(app).delete('/api/delete/1');
expect(res.body).toHaveProperty('success', true);
expect(entries.length).toBe(0);
});
test('GET /api/address/:sensorNumber returns address for known sensor', async () => {
const res = await request(app).get('/api/address/1001');
expect(res.body).toHaveProperty('address', 'Musterstraße 1, 12345 Musterstadt');
});
test('GET /api/address/:sensorNumber returns error for unknown sensor', async () => {
const res = await request(app).get('/api/address/9999');
expect(res.statusCode).toBe(404);
expect(res.body).toHaveProperty('error', 'Sensor unbekannt');
});
test('GET /api/address/:sensorNumber returns error for invalid sensor', async () => {
const res = await request(app).get('/api/address/abc');
expect(res.statusCode).toBe(400);
expect(res.body).toHaveProperty('error', 'Ungültige Sensornummer');
});
});
+118
View File
@@ -0,0 +1,118 @@
doctype html
html(lang="de")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title ESP-ID zu Sensornummer
link(rel="stylesheet", href="/styles.css")
body
h1 ESP-ID → Sensornummer
// Tab Navigation
div.tabs
button.tab-btn#tabInput.active(type="button" onclick="showTab('input')") Eingabe
button.tab-btn#tabList(type="button" onclick="showTab('list')") Liste
button.tab-btn#tabProfile(type="button" onclick="showTab('profile')") Profil
if isAdmin
button.tab-btn#tabUser(type="button" onclick="showTab('user')") User
a.tab-btn#logoutBtn(href="/logout") Logout
// Eingabe-Tab
div#tabInputContent.tab-content
div.card
form#entryForm
label(for="sensorNumber") Sensornummer:
input#sensorNumber(type="text" placeholder="Nur Zahlen erlaubt")
label(for="espId") ESP-ID:
input#espId(type="text")
label(for="name") Bezeichnung:
input#name(type="text")
label(for="description") Beschreibung:
textarea#description
label(for="address") Anschrift:
input#address(type="text" placeholder="Wird automatisch ausgefüllt" readonly disabled)
.twobuttons
button#saveBtn(type="button") Speichern
button#cancelBtn(type="button") Abbrechen
div#result
#version Version: #{version} vom #{vdate}
// Listen-Tab
div#tabListContent.tab-content(style="display:none")
div.controls
button#refreshBtn Aktualisieren
| Seite:
input#page(value="1")
| Limit:
input#limit(value="50")
span#gzahl
table#entriesTable
colgroup
col.col-sensornumber
col.col-espid
col.col-bezeichnung
col.col-beschreibung
col.col-date
col.col-aktionen
thead
tr
th(id="thSensorNr" data-sort="sensorNr" style="cursor:pointer") SensorNr <span id="sortArrowSensorNr">↑</span>
th(id="thEspId" data-sort="espId" style="cursor:pointer") ESP-ID <span id="sortArrowEspId">↑</span>
th Bezeichnung
th Beschreibung
th(id="thDate" data-sort="date" style="cursor:pointer") Datum <span id="sortArrowDate">↑</span>
th Aktionen
tbody
// Profil-Tab (für alle eingeloggten User)
div#tabProfileContent.tab-content(style="display:none")
div.card
h2 Passwort ändern
form#profileForm
label(for="currentPassword") Aktuelles Passwort:
input#currentPassword(type="password" required)
label(for="newPassword") Neues Passwort:
input#newPassword(type="password" required)
label(for="newPasswordRepeat") Neues Passwort (Wiederholung):
input#newPasswordRepeat(type="password" required)
.twobuttons
button#profileSaveBtn(type="button") Ändern
div#profileResult
#version Version: #{version} vom #{vdate}
// User-Tab (nur für Admins)
if isAdmin
div#tabUserContent.tab-content(style="display:none")
div.card
h2 Neuen User anlegen
form#userForm
label(for="username") Benutzername:
input#username(type="text" required)
label(for="password") Passwort:
input#password(type="password" required)
label(for="role") Rolle:
select#role
option(value="user") User
option(value="admin") Admin
.twobuttons
button#userSaveBtn(type="button") Anlegen
button#userCancelBtn(type="button") Abbrechen
div#userResult
div.card
h2 Angemeldete User
table#usersTable
thead
tr
th Email
th Rolle
th Aktionen
tbody
div#usersResult
#version Version: #{version} vom #{vdate}
script(type="module" src="/global.js")
+27
View File
@@ -0,0 +1,27 @@
doctype html
html(lang="de")
head
meta(charset="utf-8")
meta(name="viewport", content="width=device-width, initial-scale=1")
title Login
link(rel="stylesheet", href="/styles.css")
body
h1 ESP-ID → Sensornummer
div.card
h2 Login
form(method="POST" action="/login")
label(for="email") E-Mail:
input#email(type="email" name="email" required)
span#emailStatus
label(for="password") Passwort:
input#password(type="password" name="password" required)
button(type="submit") Login
p.hint Passwort vergessen? Bitte an einen Admin wenden.
if noUsers
p.hint
strong Es ist noch kein User angelegt. Bitte ADMIN_EMAIL und ADMIN_PASSWORD als Umgebungsvariablen setzen und den Container neu starten.
#version Version: #{version} vom #{vdate}
if error
p.error= error
script(type="module" src="/login.js")
+3 -1
View File
@@ -6,7 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_noise). Image-Name: noise.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="noise"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
-1
View File
@@ -26,7 +26,6 @@ html
footer
#author
#mailadr
a(href="mailto:rexfue@gmail.com") mailto:rexfue@gmail.com
#versn #{t('Version')}: #{version} #{t('from')} #{date}
script(src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
+3
View File
@@ -12,6 +12,9 @@ LOCALDIR=<Aktuelle Direcrory ab />
# Datenbank, in welche die laufenden Messwerte gespeichert werden (kannn 'influx' oder 'mongo' sein)
STORE=influx
# Secret zum Signieren der Session-Cookies von esp2sensor (langer Zufallsstring)
ESP2SENSOR_SESSION_SECRET=<zufaelliger_string>
# Nur fuer readarchive-stack.yml: Zeitraum und Ziel des Einmal-Laufs.
# Leer lassen = Default (gestern, ein Tag, TYP=noise, DBASE=influx).
# END ist exklusiv.
+20 -4
View File
@@ -79,8 +79,24 @@ services:
- ${LOCALDIR}/noisesensors/log:/var/log
container_name: noise
environment:
APIHOST: http://sensorapi:3005
ports:
APIHOST: http://sensorapi:3005
ports:
- "3003:3000"
restart: unless-stopped
restart: unless-stopped
esp2sensor:
image: docker.citysensor.de/esp2sensor
container_name: esp2sensor
volumes:
- ${LOCALDIR}/noisesensors/log:/var/log
environment:
MONGOHOST: mongodb
MONGOAUTH: true
MONGOUSRP: ${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}
SESSION_SECRET: ${ESP2SENSOR_SESSION_SECRET}
DEV_AUTO_LOGIN: "false"
APIHOST: http://sensorapi:3005/api/
ports:
- "3004:3000"
restart: unless-stopped
+3 -1
View File
@@ -6,7 +6,9 @@ set -e
# Unterschied: Build-Kontext ist das Repository-Wurzelverzeichnis, weil
# readarchive und readin sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="readarchive"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
+3 -1
View File
@@ -8,7 +8,9 @@ set -e
# Build-Kontext ist das Repository-Wurzelverzeichnis, weil readin und
# readarchive sich die Module unter common/ teilen.
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="readin"
TAG="${1:-latest}"
PLATFORM="linux/amd64"
+1 -1
View File
@@ -8,7 +8,7 @@ const NOMINATIM_URL = `https://nominatim.openstreetmap.org/reverse?lat=${'xx'}&l
const NOMINATIM_CITY_URL = `https://nominatim.openstreetmap.org/?q="${'xx'}"&format=json`
// Nominatim verlangt laut Nutzungsbedingungen einen eigenen User-Agent und
// beantwortet den Default von axios ('axios/1.x') mit 403.
const NOMINATIM_UA = 'laermsensor-stack (rxf@gmx.de)'
const NOMINATIM_UA = 'laermsensor-stack'
export const getCityCoords = async (params) => {
let ret = {coords: [], city: params.city, err: null}
+3 -1
View File
@@ -6,7 +6,9 @@ set -e
# Nur linux/amd64, nativer Build auf esprimo (amd64) -> kein buildx/qemu,
# eigener Dockerfile-Name (Dockerfile_sensorapi).
REGISTRY="docker.citysensor.de"
# Ziel-Registry, per Umgebungsvariable ueberschreibbar:
# REGISTRY=anderes.registry.example ./deploy.sh
REGISTRY="${REGISTRY:-ghcr.io/dnms-org/laermkarte-sensor-community}"
IMAGE_NAME="sensorapi"
TAG="${1:-latest}"
PLATFORM="linux/amd64"