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>
This commit is contained in:
@@ -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"]
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Build Docker-Container
|
||||
#
|
||||
# Call: buildit.sh name [target]
|
||||
#
|
||||
# The Dockerfile must be named like Dockerfile_name
|
||||
#
|
||||
# 2018-09-20 rxf
|
||||
# - before sending docker image to remote, tag actual remote image
|
||||
#
|
||||
# 2018-09-14 rxf
|
||||
# - first Version
|
||||
#
|
||||
|
||||
set -x
|
||||
port=""
|
||||
orgName=esp2sensor
|
||||
name=esp2sensor
|
||||
|
||||
usage()
|
||||
{
|
||||
echo "Usage build_and_copy.sh [-p port] [-n name] target"
|
||||
echo " Build docker container $name and copy to target"
|
||||
echo "Params:"
|
||||
echo " target: Where to copy the container to "
|
||||
echo " -p port: ssh port (default 22)"
|
||||
echo " -n name: new name for container (default: $orgName)"
|
||||
}
|
||||
|
||||
while getopts n:p:h? o
|
||||
do
|
||||
case "$o" in
|
||||
n) name="$OPTARG";;
|
||||
p) port="-p $OPTARG";;
|
||||
h) usage; exit 0;;
|
||||
*) usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND-1))
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
if [[ -z "$target" ]]; then
|
||||
target=$1
|
||||
shift
|
||||
else
|
||||
echo "bad option $1"
|
||||
# exit 1
|
||||
shift
|
||||
fi
|
||||
done
|
||||
|
||||
docker build -f Dockerfile_$orgName --no-cache -t $name .
|
||||
|
||||
dat=`date +%Y%m%d%H%M`
|
||||
|
||||
if [ "$target" == "localhost" ]
|
||||
then
|
||||
docker tag $name $name:V_$dat
|
||||
exit
|
||||
fi
|
||||
|
||||
ssh $port $target "docker tag $name $name:V_$dat"
|
||||
docker save $name | bzip2 | pv | ssh $port $target 'bunzip2 | docker load'
|
||||
@@ -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
+36
@@ -0,0 +1,36 @@
|
||||
# Deplay ein Sensor-File auf das docker registry (docker.citysensor.de)
|
||||
#
|
||||
# v 1.0 2024-09-01 rxf
|
||||
# erste Version
|
||||
|
||||
#set -x
|
||||
|
||||
registry=docker.citysensor.de
|
||||
name=esp2sensor
|
||||
|
||||
|
||||
usage()
|
||||
{
|
||||
echo "Usage ./deploy.sh"
|
||||
echo " Build docker container '$name' and deploy to $registry"
|
||||
echo "Params:"
|
||||
echo " -h show this usage"
|
||||
}
|
||||
|
||||
while getopts h? o
|
||||
do
|
||||
case "$o" in
|
||||
h) usage; exit 0;;
|
||||
*) usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND-1))
|
||||
|
||||
|
||||
./build_and_copy.sh localhost
|
||||
docker tag $name docker.citysensor.de/$name:latest
|
||||
dat=`date +%Y%m%d%H%M`
|
||||
docker tag $name docker.citysensor.de/$name:V_$dat
|
||||
docker push docker.citysensor.de/$name
|
||||
|
||||
|
||||
@@ -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:
|
||||
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 |
@@ -0,0 +1,15 @@
|
||||
# ToDos
|
||||
2025-11-09
|
||||
|
||||
### Login
|
||||
|
||||
* Passwort vergessen
|
||||
* Passwort selber ändern
|
||||
* Abmelden
|
||||
* evtl. auch automatsich, wenn 10min keine Aktion war
|
||||
|
||||
### Allgemein
|
||||
|
||||
* saubere Anpassung an Handy
|
||||
* evtl. doch nochmal mit React versuchen
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
# Beschreibung von esp2senor
|
||||
|
||||
|
||||
### Aufruf
|
||||
Für die Tests:
|
||||
|
||||
```
|
||||
esp2sensor.fuerst-stuttgart.de
|
||||
```
|
||||
|
||||
### Darstellung
|
||||
|
||||
Die Einstigsseite ist die **Login** Seite:
|
||||

|
||||
|
||||
Hier wird die **E-Mail** als Username und das **Passwort** eingegeben. Während der Eingabe der E-Mail wird diese überprüft. Unterhalb der Eingabezeile wird angezeigt, ob die E-Mail bekannt ist, so dass Tippfehler sofort verbessert werdern können. Solange die E-Mail nicht vollständig ist, wird *Benutzer nicht gefunden* angezeigt.
|
||||
Wurden die richtige E-Mail und das richtige Passwort eingegeben, gelangt man zu nächsten Seite.
|
||||
|
||||
|
||||
Dies ist die **Eingabe**-Seite und sieht folgendermaßen aus:
|
||||

|
||||
|
||||
Ganz oben sind zwei oder drei Tabs, ja nachdem ob der eingeloggte User ein Admin ist oder nicht. Der Admin kann über den Tab **User** einen neuen User anlegen. Diser Tab erschein bei 'Nicht-Admins' nicht.
|
||||
Der Tab **Eingabe** wird automatisch als Standard ausgewählt. Hier werden nun folgende Daten eingegeben:
|
||||
|
||||
* In die Zeile **Sensornummer** wird die Sensornummer (die von sensor.community) eingetragen. Das Programm sucht daraufhin, ob die Sensornummer in der bisherigen Datenbank bekannt ist. Wenn ja, wird die Adresse aus den Koordinaten gelesen und in der Zeile **Anschrift** angezeigt. Falls nicht, erfolgt eine Fehlermeldung.
|
||||
|
||||
* Nun können die restlichen Daten, nämlich ESP-ID, Bezeichnung und Beschreibung eigegeben werden. Nur die ESP_ID **muss** eingegeben werden, die anderen beiden sind optional. Die Anschrift kann *nicht* eingegeben und *nicht* verändert werden.
|
||||
|
||||
* Durch Klick auf **Speichern** werden die Daten in die Datenbank übernommen. Eventuell schon in der DB vorhandene Daten werden überschrieben. Die Anschrift wird *nicht* gespeichert, da diese sich immer aus den schon vohandenen Koordinaten berechnet.
|
||||
|
||||
* Sollte die Sensornummer schon mal eingegeben worden sein, so werden alle eingegebenen Daten aus der Datenbank geholt und angezeigt. Sie können hier nun auch geändert werden (außer Anschrift).
|
||||
|
||||
|
||||
Die zweite Seite ist die **Liste**:
|
||||

|
||||
|
||||
Je mehr Daten vorliegen, desto länger wird die Liste. Über den Button **Aktualisieren** kann die Liste neu geladen werden. Über **Seite** kann die angezeigte Seite angewählt werde, über **Limit** wird die Anzahl der Einträge einer Seite festgelegt.
|
||||
Am rechten Rand in der Spalte **Aktionen** sind zwei Symbole: einmal der **Stift** (✏️). Beim Klick darauf werde die Daten dieser Zeile wieder in die Eingabe-Maske geschrieben und können hier dann ggf. verändert werden. Das zweite Symbol ist der **Mülleimer** (🗑️), mit dem der komplette Eintrag (nach einer Sicherheitsrückfrage) gelöscht wird.
|
||||
Die Liste kann nach folgenden Kriterien auf- oder absteigend sortiert werden: SensorNr, ESP-ID und Datum. Erkenntlich ist das an den kleine Pfeilen (↑↓)
|
||||
|
||||
Wenn der angemeldete user ein **Admin** ist, so kann über den Tab **User** ein neuer User angelegt werden:
|
||||

|
||||
|
||||
Folgen Daten müssen eingegeben werden:
|
||||
* der **Benutzername** ( == E-Mail)
|
||||
* das **Passwort** für diesen Benutzer
|
||||
* und die **Rolle**, also entweder normaler Benutzer (*User*) oder Administrator (*Admin*).
|
||||
|
||||
Durch Klicken auf **Anlegen** wird der neue User in der Datenbank gespeichert.
|
||||
|
||||
|
||||
#### History
|
||||
| Version | Datum | Author |Bemerkung|
|
||||
|---------|-------|-----|----------|
|
||||
| 1.2.0 | 2025-11-21 | rxf | extra properties-collection entfernt |
|
||||
| 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.3.1",
|
||||
"date": "2025-11-19 14:00 UTC",
|
||||
"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,418 @@
|
||||
// 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';
|
||||
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');
|
||||
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!';
|
||||
} else {
|
||||
userResult.textContent = data.error || 'Fehler beim Anlegen.';
|
||||
}
|
||||
} catch (err) {
|
||||
userResult.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');
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
};
|
||||
}
|
||||
// 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.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', saveEntry);
|
||||
refreshBtn.addEventListener('click', loadEntries);
|
||||
cancelBtn.addEventListener('click', () => clearForm(true));
|
||||
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'))
|
||||
|
||||
loadEntries();
|
||||
});
|
||||
|
||||
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,246 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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-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,95 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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', (req, res) => {
|
||||
const version = pkg.version
|
||||
const vdate = pkg.date
|
||||
res.render('login', {
|
||||
error: null,
|
||||
version: version,
|
||||
vdate: vdate
|
||||
})
|
||||
});
|
||||
|
||||
app.post('/login', async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const user = await usersCollection.findOne({ email: email.toLowerCase() });
|
||||
if (!user) return res.render('login', { error: errText });
|
||||
const match = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!match) return res.render('login', { error: errText });
|
||||
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,66 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import pkg from './package.json' with { type: "json" }
|
||||
dotenv.config();
|
||||
|
||||
import { initMongo } 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();
|
||||
|
||||
// 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,90 @@
|
||||
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
|
||||
if isAdmin
|
||||
button.tab-btn#tabUser(type="button" onclick="showTab('user')") User
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
#version Version: #{version} vom #{vdate}
|
||||
|
||||
script(type="module" src="/global.js")
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
#version Version: #{version} vom #{vdate}
|
||||
|
||||
if error
|
||||
p.error= error
|
||||
script(type="module" src="/login.js")
|
||||
Reference in New Issue
Block a user