backend rework- full test 1

This commit is contained in:
derlole
2025-05-07 11:08:43 +00:00
parent f7d9a640ee
commit 76bfa50995
19 changed files with 397 additions and 127 deletions

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"spellright.language": [
"de",
"en"
],
"spellright.documentTypes": [
"markdown",
"latex",
"plaintext"
]
}

20
db/README.md Normal file
View File

@@ -0,0 +1,20 @@
# COMMANDS DB description
## one command contains:
- command
- status
- command_id
- tstamp
### command may contain one out of following strings:
```["toggle_machine","","","","","",""]```
### status may contain one out of following strings:
```["pending","failed","served","rejected"]```
### command_id is a random generated 4 chars long integer to identify the exact command between Server frontend Database and ESP
### tstamp is the exact Date at the first ever creation of the db entry and does not say anything about completion or rejection.
## NOTE:
### A created command is marked as failed after 5 minutes if its status is still pending, to ensure that no processes continue after a communication failure in the chain.

Binary file not shown.

View File

@@ -1,3 +1,6 @@
# -*- coding: utf-8 -*-
# Dieses Skript erstellt eine SQLite-Datenbank mit einer Tabelle für Befehle.
import os import os
import sqlite3 import sqlite3

29
init.py Normal file
View File

@@ -0,0 +1,29 @@
from modules.persistence import save_dict
from datetime import datetime, UTC
# Defaults
water = {
"lastFilled": str(datetime.now(UTC)),
"fill": 100,
"coffeesOnFill": 0
}
beans = {
"lastFilled": str(datetime.now(UTC)),
"fill": 100,
"coffeesOnFill": 0
}
machine = {
"state": "idle",
"connected": False,
"ready": False,
"peding_command": False,
"error": False,
"lastConnectionProof": str(datetime.now(UTC))
}
# In JSON-Dateien speichern
save_dict("water", water)
save_dict("beans", beans)
save_dict("machine", machine)

23
modules/persistence.py Normal file
View File

@@ -0,0 +1,23 @@
import os
import json
from datetime import datetime
BASE_PATH = os.path.join(os.path.dirname(__file__), "..", "persistence")
BASE_PATH = os.path.abspath(BASE_PATH)
def save_dict(name, data):
path = os.path.join(BASE_PATH, f"{name}.json")
os.makedirs(BASE_PATH, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, default=str, indent=2)
def load_dict(name):
path = os.path.join(BASE_PATH, f"{name}.json")
if os.path.exists(path):
with open(path, "r") as f:
return json.load(f)
return {} # fallback falls Datei fehlt
# no persistence but global variable important for tracking the esp-connection over runtime
esp_conn_infos = {"ip_global": None, "ip_local": None, "last_seen": None, "connection_valid": False}

16
modules/socketio.py Normal file
View File

@@ -0,0 +1,16 @@
# extensions.py
from flask_socketio import SocketIO
from modules.persistence import esp_conn_infos,load_dict, save_dict
socketio = SocketIO(cors_allowed_origins="*", async_mode='threading')
def resend_static_data():
water = load_dict("water")
beans = load_dict("beans")
machine = load_dict("machine")
socketio.emit('static_data', {
'water': water,
'beans': beans,
'machine': machine,
'esp_conn_infos': esp_conn_infos
})

6
persistence/beans.json Normal file
View File

@@ -0,0 +1,6 @@
{
"lastFilled": "2025-05-06 20:56:49.436340+00:00",
"fill": 100,
"coffeesOnFill": 0,
"refilled": 0
}

8
persistence/machine.json Normal file
View File

@@ -0,0 +1,8 @@
{
"state": "idle",
"connected": false,
"ready": false,
"peding_command": false,
"error": false,
"lastConnectionProof": "2025-05-06 20:56:49.436343+00:00"
}

6
persistence/water.json Normal file
View File

@@ -0,0 +1,6 @@
{
"lastFilled": "2025-05-06 20:56:49.436328+00:00",
"fill": 21,
"coffeesOnFill": 0,
"refilled": 0
}

View File

@@ -1,11 +1,13 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
import routes.shared as shared
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import json import json
import random import random
import sqlite3 import sqlite3
import os import os
from modules.persistence import esp_conn_infos
import datetime
from modules.socketio import resend_static_data
esp = Blueprint('eps', __name__, url_prefix='/unsecure/esp') esp = Blueprint('eps', __name__, url_prefix='/unsecure/esp')
@@ -15,11 +17,6 @@ MQTT_BROKER = "localhost" # oder IP/Domain
MQTT_PORT = 1883 MQTT_PORT = 1883
MQTT_TOPIC = "coffee/command" MQTT_TOPIC = "coffee/command"
@esp.route('/')
def fetch_command():
pCd = shared.pending_command
shared.reset_command()
return jsonify(pCd)
@esp.route('/online', methods=['POST']) @esp.route('/online', methods=['POST'])
def esp_online(): def esp_online():
@@ -27,6 +24,12 @@ def esp_online():
sender_ip = request.headers.get('X-Forwarded-For', request.remote_addr) sender_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
esp_ip = data.get("ip", "unknown") esp_ip = data.get("ip", "unknown")
esp_conn_infos["ip_local"] = esp_ip
esp_conn_infos["ip_global"] = sender_ip
esp_conn_infos["last_seen"] = datetime.now()
esp_conn_infos["connection_valid"] = True
resend_static_data()
print(f"ESP ONLINE von IP: {esp_ip}, roher IP: {sender_ip}") print(f"ESP ONLINE von IP: {esp_ip}, roher IP: {sender_ip}")
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})

View File

@@ -1,10 +0,0 @@
pending_command = {'command': None, 'command-URL': None, 'command-expected': None, 'command-expected-URL': None}
def reset_command():
global pending_command
pending_command = {
'command': None,
'command-URL': None,
'command-expected': None,
'command-expected-topic': None
}

View File

@@ -1,25 +1,30 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
import routes.shared as shared
unsecure = Blueprint('unsecure', __name__, url_prefix='/unsecure') unsecure = Blueprint('unsecure', __name__, url_prefix='/unsecure')
from modules.persistence import load_dict, save_dict
from modules.persistence import esp_conn_infos
# from flask_socketio import SocketIO
from modules.socketio import resend_static_data
# def resend_static_data():
# water = load_dict("water")
# beans = load_dict("beans")
# machine = load_dict("machine")
# socketio.emit('static_data', {
# 'water': water,
# 'beans': beans,
# 'machine': machine,
# 'esp_conn_infos': esp_conn_infos
# })
@unsecure.route('/') @unsecure.route('/')
def index(): def index():
return render_template('index.html', title='gimmiCoffee') water = load_dict("water")
beans = load_dict("beans")
machine = load_dict("machine")
print(f"Water: {water}, Beans: {beans}, Machine: {machine}")
return render_template('index.html', title='gimmiCoffee', water=water, beans=beans, machine=machine, esp_conn_infos=esp_conn_infos)
@unsecure.route('/send') @unsecure.route('/update')
def send_command(): def update():
pCd = shared.pending_command resend_static_data()
befehl = request.args.get('befehl') return jsonify({"status": "ok"})
if befehl:
pCd['command'] = befehl
pCd['command-URL'] = '/unsecure/esp/someURI'
pCd.update({'extra': 'test'})
print(pCd)
return f"Befehl '{pCd}' gespeichert."
return "Kein Befehl angegeben.", 400
# @unsecure.route('/live')
# def test():
# return render_template('live.html', users=[{'name': 'Max'}, {'name': 'Moritz'}, {'name': 'Hans'}])

View File

@@ -1,19 +1,26 @@
from flask import Flask from flask import Flask
from flask_socketio import SocketIO
from routes.unsecure_routes import unsecure from routes.unsecure_routes import unsecure
from routes.esp_routes import esp from routes.esp_routes import esp
import threading import threading
import time import time
import os
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from datetime import datetime, timedelta
import sqlite3
from modules.persistence import load_dict, save_dict, esp_conn_infos
from modules.socketio import socketio, resend_static_data
MQTT_BROKER = "localhost" MQTT_BROKER = "localhost"
MQTT_PORT = 1883 MQTT_PORT = 1883
MQTT_TOPIC_SUB = "coffee/status" MQTT_TOPIC_SUB = "coffee/status"
MQTT_TOPIC_SEND = "coffee/command" MQTT_TOPIC_SEND = "coffee/command"
app = Flask(__name__, static_url_path='/unsecure/static') app = Flask(__name__, static_url_path='/unsecure/static')
app.config['SECRET_KEY'] = 'super-secret-key' app.config['SECRET_KEY'] = 'super-secret-key'
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
socketio.init_app(app)
# Blueprints registrieren # Blueprints registrieren
app.register_blueprint(unsecure) app.register_blueprint(unsecure)
@@ -33,7 +40,7 @@ def on_message(client, userdata, msg):
'message': msg.payload.decode() 'message': msg.payload.decode()
}) })
# MQTT-Thread starten # MQTT-Thread
def mqtt_thread(): def mqtt_thread():
client = mqtt.Client() client = mqtt.Client()
client.on_connect = on_connect client.on_connect = on_connect
@@ -41,24 +48,69 @@ def mqtt_thread():
client.connect(MQTT_BROKER, MQTT_PORT, 60) client.connect(MQTT_BROKER, MQTT_PORT, 60)
client.loop_forever() client.loop_forever()
# Dummy-Daten-Thread # DB-Cleanup-Thread
# def send_data(): def cleanup_old_commands():
# counter = 0
# while True:
# data = {
# 'test': 'Live-Daten',
# 'status': 'OK',
# 'counter': counter
# }
# socketio.emit('update_data', data)
# counter += 1
# time.sleep(2)
# Beide Threads starten db_path = os.path.join(os.path.dirname(__file__), "db", "commands.db")
#threading.Thread(target=send_data, daemon=True).start()
while True:
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
five_minutes_ago = (datetime.utcnow() - timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("""
UPDATE commands
SET status = 'failed'
WHERE status = 'pending' AND tstamp <= ?
""", (five_minutes_ago,))
updated_rows = cursor.rowcount
conn.commit()
conn.close()
if updated_rows > 0:
print(f"[Cleanup] {updated_rows} Einträge als 'failed' markiert.")
except Exception as e:
print(f"[Cleanup-Fehler] {e}")
time.sleep(60) # jede Minute prüfen ob es pending Einträge gibt, die älter als 5 Minuten sind
# Clear commands DB
def clear_commands_db():
import os
import sqlite3
db_path = os.path.join(os.path.dirname(__file__), "db", "commands.db")
if os.path.exists(db_path):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM commands")
conn.commit()
conn.close()
print("[DB] commands-Tabelle geleert.")
else:
print("[DB] Keine Datenbank gefunden nichts geleert.")
# Motitior ESP-Connection
def monitor_esp_connection():
while True:
if esp_conn_infos["last_seen"]:
time_diff = datetime.now() - esp_conn_infos["last_seen"]
if time_diff > timedelta(minutes=30):
esp_conn_infos["connection_valid"] = False
resend_static_data()
time.sleep(60) # einmal pro Minute die Verbindung zum ESP prüfen
### THREADS START ###
threading.Thread(target=cleanup_old_commands, daemon=True).start()
threading.Thread(target=monitor_esp_connection, daemon=True).start()
#threading.Thread(target=mqtt_thread, daemon=True).start() #threading.Thread(target=mqtt_thread, daemon=True).start()
if __name__ == '__main__': if __name__ == '__main__':
#clear_commands_db()
socketio.run(app, host='0.0.0.0', port=3060, allow_unsafe_werkzeug=True) socketio.run(app, host='0.0.0.0', port=3060, allow_unsafe_werkzeug=True)

View File

@@ -1 +1,55 @@
//im empty function gebId(id) {
return document.getElementById(id);
}
const water = JSON.parse(document.getElementById("waterData").innerText)
const beans = JSON.parse(document.getElementById("beansData").innerText)
const machine = JSON.parse(document.getElementById("machineData").innerText)
const esp_conn_infos = JSON.parse(document.getElementById("espData").innerText)
// console.log(water)
// console.log(beans)
// console.log(machine)
// console.log(esp_conn_infos)
gebId("beans-fill").innerText = beans.fill + "%"
gebId("water-fill").innerText = water.fill + "%"
gebId("beans-filled").innerText = beans.refilled
gebId("water-filled").innerText = water.refilled
gebId("ip_global").innerText = esp_conn_infos.ip_global
gebId("ip_local").innerText = esp_conn_infos.ip_local
gebId("valid_connection").innerText = esp_conn_infos.connection_valid
gebId("last_seen").innerText = esp_conn_infos.last_seen
if (esp_conn_infos.connection_valid) {
gebId("validButt").classList.add("deniePress");
gebId("machine-status-butt").classList.remove("deniePress");
}else {
gebId("infoMain").classList.add("blink-orange");
}
if (water.fill < 20) {
gebId("water-fill").parentElement.classList.add("blink-orange");
}
if (beans.fill < 20) {
gebId("beans-fill").parentElement.classList.add("blink-orange");
}
function toggleMachine() {
if (gebId("machine-status-butt").classList.contains("deniePress")){
return;
}
// console.log("toggleMachine");
const result = confirm("Möchtest du den Vorgang wirklich ausführen?");
if (!result) {
return;
}
console.log("toggleMachine");
document.getElementById("machine-status").innerText = "PENDING";
document.getElementById("machine-status-butt").classList.add("blink-orange");
fetch('/unsecure/esp/toggle-machine', { method: 'POST' })
.then(res => res.json())
.then(data => {
console.log(data);
});
}

31
static/socketio.js Normal file
View File

@@ -0,0 +1,31 @@
function gebId(id) {
return document.getElementById(id);
}
const socket = io();
socket.on('static_data', (data) => {
gebId("beans-fill").innerText = data.beans.fill + "%"
gebId("water-fill").innerText = data.water.fill + "%"
gebId("beans-filled").innerText = data.beans.refilled
gebId("water-filled").innerText = data.water.refilled
if (data.water.fill < 20) {
gebId("water-fill").parentElement.classList.add("blink-orange");
}else {
gebId("water-fill").parentElement.classList.remove("blink-orange");
}
if (data.beans.fill < 20) {
gebId("beans-fill").parentElement.classList.add("blink-orange");
}else {
gebId("beans-fill").parentElement.classList.remove("blink-orange");
}
if (esp_conn_infos.connection_valid) {
gebId("validButt").classList.add("deniePress");
gebId("infoMain").classList.remove("blink-orange");
gebId("machine-status-butt").classList.remove("deniePress");
}else {
gebId("validButt").classList.remove("deniePress");
gebId("infoMain").classList.add("blink-orange");
gebId("machine-status-butt").classList.add("deniePress");
}
});

View File

@@ -115,16 +115,15 @@ header {
.button-grid { .button-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 30px; gap: 20px;
max-height: 70vh; max-height: 73vh;
overflow-y: auto;
} }
.grid-button { .grid-button {
flex: 1 1 calc(50% - 15px); flex: 1 1 calc(50% - 15px);
background: #95a5a6; background: #818e8f;
color: white; /* color: white; */
height: 23vh; height: 22vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -139,7 +138,7 @@ header {
border: 2px solid #000; border: 2px solid #000;
padding: 20px; padding: 20px;
border-radius: 10px; border-radius: 10px;
background: #f0f0f0; background: #c4c4c4;
overflow-y: auto; overflow-y: auto;
} }
@@ -156,13 +155,13 @@ header {
position: relative; position: relative;
width: 100%; width: 100%;
border-radius: 10px; border-radius: 10px;
background: #2ecc71; background: #5f5f5f;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
margin-bottom: 16px; margin-bottom: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: rgb(0, 0, 0); color: rgb(255, 255, 255);
flex: none; flex: none;
height: 25vh; height: 25vh;
transition: background 0.3s; transition: background 0.3s;
@@ -170,21 +169,24 @@ header {
.clickable:hover { .clickable:hover {
background: #27ae60; background: #505050;
cursor: pointer; cursor: pointer;
} }
.not:hover {
background: #5f5f5f;
}
.clickable .top-left-text { .clickable .top-left-text {
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 12px; left: 12px;
font-size: 24px; font-size: 24px;
color: #000000; color: #ffffff;
} }
.clickable .center-number { .clickable .center-number {
font-size: 48px; font-size: 48px;
font-weight: bold; font-weight: bold;
color: #222; color: #ffffff;
} }
.grid-button .top-left-text { .grid-button .top-left-text {
position: absolute; position: absolute;
@@ -202,9 +204,11 @@ header {
color: #222; color: #222;
text-align: center; text-align: center;
} }
.deniePress:hover{ .defaultGray:hover {
cursor: not-allowed; background: #6a7274;
} }
.blink-orange { .blink-orange {
background-color: orange; background-color: orange;
animation: pulse-orange 1.0s infinite; animation: pulse-orange 1.0s infinite;
@@ -219,6 +223,34 @@ header {
background-color: red; background-color: red;
} }
.initBackRed:Hover{ .initBackRed:Hover{
background-color: rgb(255, 37, 37); background-color: rgb(252, 47, 47);
}
.deniePress:hover{
cursor: not-allowed;
}
.initBackRed:hover.deniePress {
background-color: red !important;
cursor: not-allowed;
}
.validationButtonOuter{
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.validButt{
background-color: green;
padding: 10px;
border-radius: 5px;
transition: background 0.3s;
}
.validButt:hover{
cursor: pointer;
background-color: rgb(0, 151, 0);
}
.validButt:hover.deniePress{
background-color: green !important;
}
.deniePress:hover{
cursor: not-allowed;
} }

View File

@@ -7,6 +7,7 @@
<title>{{ title }}</title> <title>{{ title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" href="{{ url_for('static', filename='gimmiCoffee_Logo.png') }}" type="image/png"> <link rel="icon" href="{{ url_for('static', filename='gimmiCoffee_Logo.png') }}" type="image/png">
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
</head> </head>
<body> <body>
<header> <header>
@@ -16,23 +17,43 @@
<a href="/logout" class="logout">Logout</a> <a href="/logout" class="logout">Logout</a>
</div> </div>
</header> </header>
<div id="waterData" style="display: none;">{{ water | tojson }}</div>
<div id="beansData" style="display: none;">{{ beans | tojson }}</div>
<div id="machineData" style="display: none;">{{ machine | tojson }}</div>
<div id="espData" style="display: none;">{{ esp_conn_infos | tojson }}</div>
<main class="main-container"> <main class="main-container">
<section class="left"> <section class="left">
<div class="info">ESP-Conn Infos</div> <div class="info" id="infoMain">
<div style="flex: 1; display: flex; align-items: center;">
<strong>ESP-Connection Infos</strong>
</div>
<div style="flex: 1;">
<p><strong>Global IP:</strong> <span id="ip_global"></span></p>
<p><strong>Local IP:</strong> <span id="ip_local"></span></p>
</div>
<div style="flex: 1;">
<p><strong>Valid Connection:</strong> <span id="valid_connection"></span></p>
<p><strong>Last Seen:</strong> <span id="last_seen"></span></p>
</div>
<div class="validationButtonOuter">
<div onclick="tryValidateConnection()" class="validButt" id="validButt">Validate Connection</div>
</div>
</div>
<div class="button-grid"> <div class="button-grid">
<div class="grid-button deniePress"> <div class="grid-button deniePress">
<div class="top-left-text">Kaffeeee</div> <div class="top-left-text">Kaffeeee</div>
<div class="center-number">Kaffee Machen</div> <div class="center-number">Kaffee Machen</div>
</div> </div>
<div class="grid-button initBackRed" id="machine-status-butt" onclick="toggleMachine()"> <div class="grid-button initBackRed deniePress" id="machine-status-butt" onclick="toggleMachine()">
<div class="top-left-text">Maschine</div> <div class="top-left-text">Maschine</div>
<div class="center-number" id="machine-status">AUS</div> <div class="center-number" id="machine-status">AUS</div>
</div> </div>
<div class="grid-button deniePress"> <div class="grid-button deniePress">
<div class="top-left-text">Maschiene</div> <div class="top-left-text">Maschine</div>
<div class="center-number">Nicht Bereit</div> <div class="center-number">Nicht Bereit</div>
</div> </div>
@@ -40,13 +61,13 @@
<div class="top-left-text">Fehler</div> <div class="top-left-text">Fehler</div>
<div class="center-number">Wasser leer?</div> <div class="center-number">Wasser leer?</div>
</div> </div>
<div class="grid-button deniePress"> <div class="grid-button defaultGray">
<div class="top-left-text">Wasser</div> <div class="top-left-text">Wasser</div>
<div class="center-number">Aufgefüllt</div> <div class="center-number">Nachgefüllt?</div>
</div> </div>
<div class="grid-button deniePress"> <div class="grid-button defaultGray">
<div class="top-left-text">Bohnen</div> <div class="top-left-text">Bohnen</div>
<div class="center-number">Nachgefüllt</div> <div class="center-number">Nachgefüllt?</div>
</div> </div>
</div> </div>
</section> </section>
@@ -59,34 +80,26 @@
</div> </div>
<div class="clickable"> <div class="clickable">
<div class="top-left-text">Bohnen Füllstand</div> <div class="top-left-text">Bohnen Füllstand</div>
<div class="center-number">XX%</div> <div class="center-number" id="beans-fill">XX%</div>
</div> </div>
<div class="clickable"> <div class="clickable">
<div class="top-left-text">Wasser Füllstand</div> <div class="top-left-text">Wasser Füllstand</div>
<div class="center-number">XX%</div> <div class="center-number" id="water-fill">XX%</div>
</div> </div>
<div class="clickable"> <div class="clickable not">
<div class="top-left-text">Bohnen nachgefüllt</div> <div class="top-left-text">Bohnen nachgefüllt</div>
<div class="center-number">XX</div> <div class="center-number" id="beans-filled">XX</div>
</div> </div>
<div class="clickable"> <div class="clickable not">
<div class="top-left-text">Wasser nachgefüllt</div> <div class="top-left-text">Wasser nachgefüllt</div>
<div class="center-number">XX</div> <div class="center-number" id="water-filled">XX</div>
</div> </div>
</section> </section>
</main> </main>
<script>
function toggleMachine() { <script src="{{ url_for('static', filename='script.js') }}"></script>
document.getElementById("machine-status").innerText = "PENDING"; <script src="{{ url_for('static', filename='socketio.js') }}"></script>
document.getElementById("machine-status-butt").classList.add("blink-orange");
fetch('/unsecure/esp/toggle-machine', { method: 'POST' })
.then(res => res.json())
.then(data => {
console.log(data);
});
}
</script>
</body> </body>
</html> </html>
@@ -94,4 +107,4 @@
<!--<script src="{{ url_for('static', filename='script.js') }}"></script>-->

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Live Update</title>
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
</head>
<body>
<h2>Daten vom Server</h2>
<p><strong>Test:</strong> <span id="test"></span></p>
<p><strong>Status:</strong> <span id="status"></span></p>
<p><strong>Counter:</strong> <span id="counter"></span></p>
<h1>Benutzer</h1>
<ul>
{% for user in users %}
<li>{{ user.name }}</li>
{% endfor %}
</ul>
<script>
const socket = io();
socket.on('update_data', (data) => {
document.getElementById('test').textContent = data.test;
document.getElementById('status').textContent = data.status;
document.getElementById('counter').textContent = data.counter;
});
</script>
</body>
</html>
</body>
</html>