Compare commits

..

10 Commits

Author SHA1 Message Date
rxf
0b9d21c24c V1.1.0 Responsive
Footer angepasst
Lauffähigkeit auf Server verbessert
deploy.sh mit for loop
2026-03-22 18:44:22 +01:00
rxf
b71d92646b docker-compose für den server angepasst 2026-02-10 21:22:25 +01:00
rxf
4fde7ed46a neues docker-compose für Profuktion und Anpassungen 2026-02-10 20:22:44 +01:00
rxf
f32e472ea3 HTTP-Empfang geht 2026-02-10 14:06:42 +01:00
rxf
db1e2fd737 Min/Max-Werte unter Graf
Aktuelle Werte oben mit dran
2026-02-09 16:27:44 +01:00
rxf
c03ffe839d Bereichsgrenzen angepasst
Highcharts-Warnunbg unterdrückt
2026-02-08 22:26:10 +01:00
rxf
7139619d28 Anzeige Min/Max unter den Graphen 2026-02-08 22:11:50 +01:00
rxf
19ea455b55 Umrechnung der Windgeschwindigleit in km/h
Anzeige des aktuellen Wertes in der Grafik
2026-02-08 22:04:58 +01:00
rxf
ea0b8dd8f9 Grafiken nun halb so hoch wie breit 2026-02-08 20:08:00 +01:00
rxf
2fc4bd9db6 Highcharts sieht viel besser aus 2026-02-08 19:44:49 +01:00
17 changed files with 1193 additions and 485 deletions

185
DEPLOY-PRODUCTION.md Normal file
View File

@@ -0,0 +1,185 @@
# Production Deployment Guide
## Voraussetzungen
- Docker und Docker Compose auf dem Server installiert
- Traefik läuft im `dockge_default` Netzwerk
- Domain `wetter.fuerst-stuttgart.de` zeigt auf den Server
- `.env` Datei mit Datenbank-Credentials
## 1. Images bauen und pushen
Lokal auf dem Entwicklungsrechner:
```bash
# Alle Images bauen und zur Registry pushen
./push-images.sh
```
Dieser Befehl:
- Baut `wetterstation-collector`
- Baut `wetterstation-api`
- Baut `wetterstation-frontend`
- Pusht alle Images zu `docker.citysensor.de`
## 2. Server vorbereiten
Auf dem Production-Server:
```bash
# Projekt-Verzeichnis erstellen
mkdir -p ~/wetterstation
cd ~/wetterstation
# docker-compose.prod.yml hochladen
# .env Datei erstellen oder hochladen
```
### Beispiel .env für Production:
```env
# Datenbank
DB_NAME=wetterstation
DB_USER=wetterstation_user
DB_PASSWORD=<sicheres-passwort>
DB_HOST=postgres
DB_PORT=5432
# Collector
COLLECTOR_PORT=8001
# Optional: PostgreSQL custom port
# DB_PORT=5432
```
## 3. Deployment starten
```bash
# Images von Registry pullen
docker-compose -f docker-compose.prod.yml pull
# Services starten
docker-compose -f docker-compose.prod.yml up -d
# Logs prüfen
docker-compose -f docker-compose.prod.yml logs -f
```
## 4. Erreichbarkeit
Nach erfolgreichem Start ist die Wetterstation erreichbar unter:
- **Frontend**: https://wetter.fuerst-stuttgart.de/
- **API**: https://wetter.fuerst-stuttgart.de/api/health
- **Collector**: https://wetter.fuerst-stuttgart.de/collector/health
Traefik übernimmt:
- Automatisches HTTPS (Let's Encrypt)
- Routing basierend auf Pfad
- StripPrefix für `/api` und `/collector`
## 5. Updates deployen
Bei Code-Änderungen:
```bash
# Lokal: Images neu bauen und pushen
./push-images.sh
# Server: Neue Images pullen und Container neu starten
ssh user@server
cd ~/wetterstation
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
## 6. Nützliche Befehle
```bash
# Status prüfen
docker-compose -f docker-compose.prod.yml ps
# Logs einzelner Services
docker-compose -f docker-compose.prod.yml logs -f frontend
docker-compose -f docker-compose.prod.yml logs -f api
docker-compose -f docker-compose.prod.yml logs -f collector
docker-compose -f docker-compose.prod.yml logs -f postgres
# Service neu starten
docker-compose -f docker-compose.prod.yml restart api
# Alle Services stoppen
docker-compose -f docker-compose.prod.yml down
# Services stoppen und Volumes löschen (⚠️ löscht Daten!)
docker-compose -f docker-compose.prod.yml down -v
```
## 7. Datenbank-Backup
```bash
# Backup erstellen
docker exec wetterstation_db_prod pg_dump -U wetterstation_user wetterstation > backup_$(date +%Y%m%d_%H%M%S).sql
# Backup wiederherstellen
docker exec -i wetterstation_db_prod psql -U wetterstation_user wetterstation < backup.sql
```
## Architektur
```
Internet
Traefik (dockge_default)
├─→ Frontend (nginx) → API (intern)
├─→ API (FastAPI)
└─→ Collector (FastAPI)
PostgreSQL (intern)
```
**Netzwerke**:
- `dockge_default` (external): Traefik-Netzwerk
- `wetterstation_internal`: Interne Service-Kommunikation
**Container**:
- `wetterstation_frontend_prod`: Nginx + React SPA
- `wetterstation_api_prod`: FastAPI (Weather Data API)
- `wetterstation_collector_prod`: FastAPI (Data Collection)
- `wetterstation_db_prod`: PostgreSQL 16
## Troubleshooting
### SSL-Zertifikat wird nicht erstellt
Prüfe:
- DNS zeigt auf Server: `dig wetter.fuerst-stuttgart.de`
- Traefik läuft: `docker ps | grep traefik`
- Port 80/443 offen: `netstat -tulpn | grep -E ':(80|443)'`
### API nicht erreichbar
```bash
# Prüfe ob Container läuft
docker ps | grep wetterstation_api_prod
# Prüfe Logs
docker logs wetterstation_api_prod
# Teste intern
docker exec wetterstation_api_prod curl localhost:8000/health
```
### Datenbank-Verbindungsfehler
```bash
# Prüfe ob DB läuft
docker ps | grep wetterstation_db_prod
# Prüfe DB-Logs
docker logs wetterstation_db_prod
# Teste Verbindung
docker exec wetterstation_db_prod psql -U wetterstation_user -d wetterstation -c "SELECT 1"
```

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
@@ -5,6 +7,7 @@ WORKDIR /app
# System-Abhängigkeiten installieren # System-Abhängigkeiten installieren
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Python-Abhängigkeiten installieren # Python-Abhängigkeiten installieren

View File

@@ -136,7 +136,11 @@ async def get_latest_weather():
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT * FROM weather_data SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, received_at
FROM weather_data
ORDER BY datetime DESC ORDER BY datetime DESC
LIMIT 1 LIMIT 1
""") """)
@@ -166,7 +170,11 @@ async def get_weather_history(
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT * FROM weather_data SELECT id, datetime, temperature, humidity, pressure,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir, rain, rain_rate, received_at
FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s) WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime DESC ORDER BY datetime DESC
LIMIT %s LIMIT %s
@@ -219,8 +227,8 @@ async def get_weather_statistics(
MAX(temperature) as max_temperature, MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity, AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure, AVG(pressure) as avg_pressure,
AVG(wind_speed) as avg_wind_speed, AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust) as max_wind_gust, MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain, SUM(rain) as total_rain,
COUNT(*) as data_points COUNT(*) as data_points
FROM weather_data FROM weather_data
@@ -252,8 +260,8 @@ async def get_daily_statistics(
MAX(temperature) as max_temperature, MAX(temperature) as max_temperature,
AVG(humidity) as avg_humidity, AVG(humidity) as avg_humidity,
AVG(pressure) as avg_pressure, AVG(pressure) as avg_pressure,
AVG(wind_speed) as avg_wind_speed, AVG(wind_speed * 1.60934) as avg_wind_speed,
MAX(wind_gust) as max_wind_gust, MAX(wind_gust * 1.60934) as max_wind_gust,
SUM(rain) as total_rain, SUM(rain) as total_rain,
COUNT(*) as data_points COUNT(*) as data_points
FROM weather_data FROM weather_data
@@ -299,7 +307,10 @@ async def get_wind_data(
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT datetime, wind_speed, wind_gust, wind_dir SELECT datetime,
wind_speed * 1.60934 as wind_speed,
wind_gust * 1.60934 as wind_gust,
wind_dir
FROM weather_data FROM weather_data
WHERE datetime >= NOW() - make_interval(hours => %s) WHERE datetime >= NOW() - make_interval(hours => %s)
ORDER BY datetime ASC ORDER BY datetime ASC

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app

View File

@@ -1,15 +1,16 @@
# MQTT subscriber that reads weather data and stores in PostgreSQL # HTTP API that receives weather data via POST and stores in PostgreSQL
import os import os
import json import json
import logging import logging
import ssl
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
import paho.mqtt.client as mqtt from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
import uvicorn
# Logging konfigurieren # Logging konfigurieren
logging.basicConfig( logging.basicConfig(
@@ -23,11 +24,7 @@ env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path) load_dotenv(dotenv_path=env_path)
# Konfiguration # Konfiguration
MQTT_BROKER = os.getenv('MQTT_BROKER', 'rexfue.de') COLLECTOR_PORT = int(os.getenv('COLLECTOR_PORT', 8001))
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
MQTT_USERNAME = os.getenv('MQTT_USERNAME')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD')
MQTT_TOPIC = os.getenv('MQTT_TOPIC', 'vantage/live')
DB_HOST = os.getenv('DB_HOST', 'localhost') DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432)) DB_PORT = int(os.getenv('DB_PORT', 5432))
@@ -35,34 +32,119 @@ DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER') DB_USER = os.getenv('DB_USER')
DB_PASSWORD = os.getenv('DB_PASSWORD') DB_PASSWORD = os.getenv('DB_PASSWORD')
# FastAPI App
app = FastAPI(title="Weather Data Collector API")
class WeatherDataCollector:
"""Klasse zum Sammeln und Speichern von Wetterdaten aus MQTT in PostgreSQL"""
def __init__(self): # Pydantic Models
self.db_conn = None class WeatherDataInput(BaseModel):
self.mqtt_client = None # Unterstütze beide Formate: datetime (String) oder dateTime (Unix-Timestamp)
self.setup_database() datetime: str | None = None
self.setup_mqtt() dateTime: int | None = None
def setup_database(self): # Unterstütze beide Feldnamen
"""Datenbankverbindung herstellen und Tabelle erstellen""" temperature: float | None = None
outTemp: float | None = None # Fahrenheit
humidity: int | None = None
outHumidity: float | None = None
pressure: float | None = None
barometer: float | None = None # inHg
windSpeed: float | None = None # mph
wind_speed: float | None = None
windGust: float | None = None # mph
wind_gust: float | None = None
windDir: float | None = None
wind_dir: float | None = None
rain: float | None = None
rainRate: float | None = None
rain_rate: float | None = None
model_config = {"extra": "allow"}
def get_datetime_string(self) -> str:
"""Konvertiere dateTime (Unix-Timestamp) zu datetime (String)"""
if self.datetime:
return self.datetime
elif self.dateTime:
from datetime import datetime as dt
return dt.fromtimestamp(self.dateTime).strftime('%Y-%m-%d %H:%M:%S')
raise ValueError("Weder datetime noch dateTime vorhanden")
def get_temperature_celsius(self) -> float | None:
"""Konvertiere Temperatur von Fahrenheit zu Celsius falls nötig"""
if self.temperature is not None:
return self.temperature
elif self.outTemp is not None:
# Fahrenheit zu Celsius: (F - 32) * 5/9
return (self.outTemp - 32) * 5 / 9
return None
def get_humidity_int(self) -> int | None:
"""Hole Humidity-Wert"""
if self.humidity is not None:
return int(self.humidity)
elif self.outHumidity is not None:
return int(self.outHumidity)
return None
def get_pressure_hpa(self) -> float | None:
"""Konvertiere Druck von inHg zu hPa falls nötig"""
if self.pressure is not None:
return self.pressure
elif self.barometer is not None:
# inHg zu hPa: inHg * 33.8639
return self.barometer * 33.8639
return None
def get_wind_speed(self) -> float | None:
"""Hole Windgeschwindigkeit"""
return self.windSpeed if self.windSpeed is not None else self.wind_speed
def get_wind_gust(self) -> float | None:
"""Hole Windböen"""
return self.windGust if self.windGust is not None else self.wind_gust
def get_wind_dir(self) -> float | None:
"""Hole Windrichtung"""
return self.windDir if self.windDir is not None else self.wind_dir
def get_rain_rate(self) -> float | None:
"""Hole Regenrate"""
return self.rainRate if self.rainRate is not None else self.rain_rate
# Datenbankverbindung
def get_db_connection():
"""Datenbankverbindung herstellen"""
try: try:
self.db_conn = psycopg2.connect( conn = psycopg2.connect(
host=DB_HOST, host=DB_HOST,
port=DB_PORT, port=DB_PORT,
database=DB_NAME, database=DB_NAME,
user=DB_USER, user=DB_USER,
password=DB_PASSWORD password=DB_PASSWORD
) )
logger.info("Datenbankverbindung hergestellt") return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
# Tabelle erstellen falls nicht vorhanden
with self.db_conn.cursor() as cursor: def setup_database():
"""Tabelle erstellen falls nicht vorhanden"""
try:
conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS weather_data ( CREATE TABLE IF NOT EXISTS weather_data (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
datetime TIMESTAMP NOT NULL, datetime TIMESTAMPTZ NOT NULL,
temperature FLOAT, temperature FLOAT,
humidity INTEGER, humidity INTEGER,
pressure FLOAT, pressure FLOAT,
@@ -75,59 +157,98 @@ class WeatherDataCollector:
UNIQUE(datetime) UNIQUE(datetime)
) )
""") """)
self.db_conn.commit() conn.commit()
logger.info("Tabelle weather_data bereit") logger.info("Tabelle weather_data bereit")
conn.close()
except Exception as e: except Exception as e:
logger.error(f"Fehler bei Datenbankverbindung: {e}") logger.error(f"Fehler bei Datenbanksetup: {e}")
raise raise
def setup_mqtt(self):
"""MQTT Client konfigurieren"""
self.mqtt_client = mqtt.Client()
self.mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# Callbacks setzen # API Endpoints
self.mqtt_client.on_connect = self.on_connect @app.on_event("startup")
self.mqtt_client.on_message = self.on_message async def startup_event():
self.mqtt_client.on_disconnect = self.on_disconnect """Bei Start die Datenbank initialisieren"""
logger.info("Collector API startet...")
setup_database()
logger.info(f"API läuft auf Port {COLLECTOR_PORT}")
logger.info(f"MQTT Client konfiguriert für {MQTT_BROKER}:{MQTT_PORT}")
def on_connect(self, client, userdata, flags, rc): @app.get("/")
"""Callback wenn MQTT Verbindung hergestellt wird""" async def root():
if rc == 0: """Root Endpoint - GET zeigt Info"""
logger.info("Mit MQTT Broker verbunden") return {
client.subscribe(MQTT_TOPIC) "message": "Weather Data Collector API",
logger.info(f"Topic abonniert: {MQTT_TOPIC}") "version": "1.0.0",
else: "endpoint": "POST /weather or POST /"
logger.error(f"Verbindung fehlgeschlagen mit Code {rc}") }
def on_disconnect(self, client, userdata, rc):
"""Callback wenn MQTT Verbindung getrennt wird"""
if rc != 0:
logger.warning(f"Unerwartete Trennung vom Broker. Code: {rc}")
def on_message(self, client, userdata, msg): @app.post("/")
"""Callback wenn MQTT Nachricht empfangen wird""" async def root_post(request: Request):
"""Root Endpoint - POST akzeptiert Wetterdaten (Alias für /weather)"""
try: try:
payload = msg.payload.decode('utf-8') # Rohen Body lesen
logger.info(f"Nachricht empfangen auf {msg.topic}: {payload}") body = await request.body()
body_str = body.decode('utf-8')
logger.info(f"POST auf Root - Raw Body: {body_str}")
# JSON parsen # Als JSON parsen
data = json.loads(payload) data_dict = json.loads(body_str)
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
# In Datenbank speichern
self.save_to_database(data)
# Zu Pydantic Model konvertieren
data = WeatherDataInput(**data_dict)
return await receive_weather_data(data)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Fehler beim JSON-Parsen: {e}") logger.error(f"JSON Parse Error: {e}")
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Fehler bei Nachrichtenverarbeitung: {e}") logger.error(f"Fehler bei Root POST: {e}")
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
def save_to_database(self, data):
"""Wetterdaten in PostgreSQL speichern""" @app.post("/debug")
async def debug_post(request: dict):
"""Debug Endpoint - akzeptiert beliebige JSON und loggt sie"""
logger.info(f"Debug: Empfangene Rohdaten: {request}")
return {"status": "logged", "data": request}
@app.get("/health")
async def health_check():
"""Health Check"""
try: try:
with self.db_conn.cursor() as cursor: conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
conn.close()
return {"status": "healthy", "database": "connected"}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Database error: {str(e)}")
@app.post("/weather")
async def receive_weather_data(data: WeatherDataInput):
"""Wetterdaten empfangen und speichern"""
logger.info(f"Empfangene Daten: {data.model_dump()}")
try:
conn = get_db_connection()
try:
# Konvertiere zu den richtigen Werten
dt_string = data.get_datetime_string()
temp_c = data.get_temperature_celsius()
humidity = data.get_humidity_int()
pressure = data.get_pressure_hpa()
wind_speed = data.get_wind_speed()
wind_gust = data.get_wind_gust()
wind_dir = data.get_wind_dir()
rain = data.rain
rain_rate = data.get_rain_rate()
logger.info(f"Konvertierte Daten - datetime: {dt_string}, temp: {temp_c}°C, humidity: {humidity}%, pressure: {pressure} hPa")
with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
INSERT INTO weather_data INSERT INTO weather_data
(datetime, temperature, humidity, pressure, wind_speed, (datetime, temperature, humidity, pressure, wind_speed,
@@ -143,51 +264,35 @@ class WeatherDataCollector:
rain = EXCLUDED.rain, rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate rain_rate = EXCLUDED.rain_rate
""", ( """, (
data.get('datetime'), dt_string,
data.get('temperature'), temp_c,
data.get('humidity'), humidity,
data.get('pressure'), pressure,
data.get('wind_speed'), wind_speed,
data.get('wind_gust'), wind_gust,
data.get('wind_dir'), wind_dir,
data.get('rain'), rain,
data.get('rain_rate') rain_rate
)) ))
self.db_conn.commit() conn.commit()
logger.info(f"Daten gespeichert für {data.get('datetime')}") logger.info(f"Daten gespeichert für {dt_string} (UTC)")
except Exception as e:
logger.error(f"Fehler beim Speichern in Datenbank: {e}")
self.db_conn.rollback()
def start(self): return {
"""MQTT Client starten und auf Nachrichten warten""" "status": "success",
try: "message": f"Weather data for {dt_string} saved successfully"
self.mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60) }
logger.info("Starte MQTT Loop...")
self.mqtt_client.loop_forever()
except KeyboardInterrupt:
logger.info("Programm wird beendet...")
except Exception as e:
logger.error(f"Fehler beim Start: {e}")
finally: finally:
self.cleanup() conn.close()
def cleanup(self): except Exception as e:
"""Ressourcen aufräumen""" logger.error(f"Fehler beim Speichern: {e}")
if self.mqtt_client: raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
self.mqtt_client.disconnect()
logger.info("MQTT Verbindung getrennt")
if self.db_conn:
self.db_conn.close()
logger.info("Datenbankverbindung geschlossen")
def main(): def main():
"""Hauptfunktion""" """Hauptfunktion"""
logger.info("Wetterstation Collector startet...")
# Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind # Prüfen ob alle nötigen Umgebungsvariablen gesetzt sind
required_vars = ['MQTT_USERNAME', 'MQTT_PASSWORD', 'DB_USER', 'DB_PASSWORD'] required_vars = ['DB_USER', 'DB_PASSWORD']
missing_vars = [var for var in required_vars if not os.getenv(var)] missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars: if missing_vars:
@@ -195,8 +300,8 @@ def main():
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen") logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
return return
collector = WeatherDataCollector() uvicorn.run(app, host="0.0.0.0", port=COLLECTOR_PORT)
collector.start()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,3 +1,4 @@
paho-mqtt==1.6.1 fastapi==0.115.5
uvicorn==0.34.0
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
python-dotenv==1.0.0 python-dotenv==1.0.0

89
deploy.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Deploy Script für laufschrift
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
set -e
# Konfiguration
REGISTRY="docker.citysensor.de"
PROJEKT="wetterstation"
IMAGE_NAME=("${PROJEKT}-frontend" "${PROJEKT}-collector" "${PROJEKT}-api")
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
# Build-Datum
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo " Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Images: ${IMAGE_NAME[*]}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Richte Multiplatform Builder ein..."
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
for image in "${IMAGE_NAME[@]}"; do
# Entferne Projekt-Präfix für Verzeichnisnamen
IMAGE_DIR="${image#${PROJEKT}-}"
FULL_IMAGE="${REGISTRY}/${image}:${TAG}"
echo "=========================================="
echo ">>> Baue ${image}..."
echo ">>> Image: ${FULL_IMAGE}"
echo "=========================================="
# Build-Args vorbereiten (für Frontend Version und Build-Date)
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
if [ "${IMAGE_DIR}" = "frontend" ]; then
VERSION=$(grep '"version"' "${IMAGE_DIR}/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
fi
# 3. Docker Image bauen und pushen (Multiplatform)
docker buildx build \
--platform linux/amd64,linux/arm64 \
${BUILD_ARGS} \
-t "${FULL_IMAGE}" \
--push \
"./${IMAGE_DIR}"
# 4. Tagge auch als :latest
echo ">>> Tagge ${image} als :latest..."
docker buildx imagetools create \
-t "${REGISTRY}/${image}:latest" \
"${FULL_IMAGE}"
echo "${image} erfolgreich gebaut und gepusht!"
echo ""
done
echo ">>> Alle Builds erfolgreich!"
echo ""
echo "=========================================="
echo "✓ Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Projekt: ${PROJEKT}"
echo "Tag: ${TAG}"
echo ""
echo "Auf dem Server ausführen:"
echo " docker compose -f docker-compose.prod.yml pull"
echo " docker compose -f docker-compose.prod.yml up -d"
echo ""

105
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,105 @@
services:
postgres:
image: postgres:16-alpine
container_name: wetterstation_db_prod
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
collector:
image: docker.citysensor.de/wetterstation-collector:latest
container_name: wetterstation_collector_prod
restart: unless-stopped
env_file:
- ./.env
environment:
DB_HOST: postgres
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=dockge_default"
- "traefik.http.routers.wetterstation-collector.rule=Host(`stwwetter.fuerst-stuttgart.de`) && PathPrefix(`/collector`)"
- "traefik.http.routers.wetterstation-collector.entrypoints=https"
- "traefik.http.routers.wetterstation-collector.tls=true"
- "traefik.http.routers.wetterstation-collector.tls.certresolver=letsencrypt"
- "traefik.http.middlewares.wetterstation-collector-stripprefix.stripprefix.prefixes=/collector"
- "traefik.http.routers.wetterstation-collector.middlewares=wetterstation-collector-stripprefix"
- "traefik.http.services.wetterstation-collector.loadbalancer.server.port=8001"
api:
image: docker.citysensor.de/wetterstation-api:latest
container_name: wetterstation_api_prod
restart: unless-stopped
env_file:
- ./.env
environment:
DB_HOST: postgres
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- proxy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
labels:
- "traefik.enable=true"
- "traefik.docker.network=dockge_default"
- "traefik.http.routers.wetterstation-api.rule=Host(`stwwetter.fuerst-stuttgart.de`) && PathPrefix(`/api`)"
- "traefik.http.routers.wetterstation-api.entrypoints=https"
- "traefik.http.routers.wetterstation-api.tls=true"
- "traefik.http.routers.wetterstation-api.tls.certresolver=letsencrypt"
- "traefik.http.middlewares.wetterstation-api-stripprefix.stripprefix.prefixes=/api"
- "traefik.http.routers.wetterstation-api.middlewares=wetterstation-api-stripprefix"
- "traefik.http.services.wetterstation-api.loadbalancer.server.port=8000"
frontend:
image: docker.citysensor.de/wetterstation-frontend:latest
container_name: wetterstation_frontend_prod
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=dockge_default"
- "traefik.http.routers.wetterstation.rule=Host(`stwwetter.fuerst-stuttgart.de`)"
- "traefik.http.routers.wetterstation.entrypoints=https"
- "traefik.http.routers.wetterstation.tls=true"
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
- "traefik.http.services.wetterstation.loadbalancer.server.port=80"
volumes:
postgres_data:
name: wetterstation_postgres_data_prod
networks:
internal:
name: wetterstation_internal
driver: bridge
proxy:
name: dockge_default
external: true

View File

@@ -41,6 +41,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: wetterstation_collector container_name: wetterstation_collector
restart: unless-stopped restart: unless-stopped
ports:
- "8001:8001"
env_file: env_file:
- ./.env - ./.env
environment: environment:

View File

@@ -1,5 +1,11 @@
# syntax=docker/dockerfile:1
# Build stage # Build stage
FROM node:20-alpine AS builder FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
# Build arguments
ARG BUILD_DATE=unknown
ARG VERSION=unknown
WORKDIR /app WORKDIR /app
@@ -12,14 +18,18 @@ RUN npm ci
# Copy source code # Copy source code
COPY . . COPY . .
# Build app # Build app with build info
ENV VITE_BUILD_DATE=${BUILD_DATE}
ENV VITE_VERSION=${VERSION}
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM nginx:alpine
WORKDIR /usr/share/nginx/html
# Copy built app from builder # Copy built app from builder
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist .
# Copy nginx configuration # Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -4,15 +4,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Docker DNS resolver für dynamische Service-Auflösung
resolver 127.0.0.11 valid=30s;
resolver_timeout 5s;
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# API proxy # API proxy (wird im Docker-Compose-Netzwerk aufgelöst)
location /api/ { location /api/ {
proxy_pass http://api:8000/; set $upstream_api api:8000;
proxy_pass http://$upstream_api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';

View File

@@ -1,7 +1,7 @@
{ {
"name": "wetterstation-frontend", "name": "wetterstation-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,11 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"highcharts": "^11.4.0",
"highcharts-react-official": "^3.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,15 +9,43 @@ function App() {
const [lastUpdate, setLastUpdate] = useState(null) const [lastUpdate, setLastUpdate] = useState(null)
useEffect(() => { useEffect(() => {
// Prüfe ob eingebettete Daten vorhanden sind const fetchData = async () => {
try {
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
if (window.__WEATHER_DATA__) { if (window.__WEATHER_DATA__) {
setWeatherData(window.__WEATHER_DATA__) setWeatherData(window.__WEATHER_DATA__)
setLastUpdate(new Date()) setLastUpdate(new Date())
setLoading(false) setLoading(false)
} else { } else {
setError('Keine Wetterdaten verfügbar') // Development oder Production: Daten von API holen
// Im Development: localhost:8000
// Im Production: /api/ (nginx proxy)
const apiUrl = import.meta.env.DEV
? 'http://localhost:8000/weather/history?hours=24'
: '/api/weather/history?hours=24'
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error('API-Fehler: ' + response.status)
}
const data = await response.json()
setWeatherData(data)
setLastUpdate(new Date())
setLoading(false) setLoading(false)
} }
} catch (err) {
setError(err.message)
setLoading(false)
}
}
fetchData()
// Automatisches Update alle 5 Minuten (nur im Entwicklungsmodus)
if (!window.__WEATHER_DATA__) {
const interval = setInterval(fetchData, 5 * 60 * 1000)
return () => clearInterval(interval)
}
}, []) }, [])
if (loading) { if (loading) {

View File

@@ -1,6 +1,7 @@
.dashboard { .dashboard {
width: 100%; width: 100%;
max-width: 795px; max-width: 1900px;
margin: 0 auto;
} }
.current-values { .current-values {
@@ -52,6 +53,95 @@
} }
.chart-wrapper { .chart-wrapper {
height: 250px; width: 100%;
aspect-ratio: 2 / 1;
position: relative; position: relative;
} }
.chart-stats {
margin-top: 0.5rem;
text-align: center;
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
.dashboard-footer {
/* margin-top: 2rem;
*/ padding-top: 1rem;
}
.footer-divider {
border: none;
border-top: 1px solid #ccc;
margin: 0 0 1rem 0;
}
.footer-credits {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #666;
margin-bottom: 0.75rem;
}
.footer-left {
text-align: left;
}
.footer-right {
text-align: right;
}
.footer-sponsor {
text-align: center;
font-size: 0.85rem;
color: #666;
}
.footer-sponsor a {
color: #0066cc;
text-decoration: none;
}
.footer-sponsor a:hover {
text-decoration: underline;
}
.version-line {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
}
.version-short {
display: none;
}
.version-full {
display: inline;
}
/* Responsive Design für schmale Bildschirme (Smartphones) */
@media (max-width: 768px) {
.charts-grid {
grid-template-columns: 1fr;
}
.current-values {
grid-template-columns: repeat(2, 1fr);
}
.dashboard {
padding: 0 0.5rem;
}
.version-short {
display: inline;
}
.version-full {
display: none;
}
}

View File

@@ -1,33 +1,24 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { import Highcharts from 'highcharts'
Chart as ChartJS, import HighchartsReact from 'highcharts-react-official'
CategoryScale,
LinearScale,
TimeScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import 'chartjs-adapter-date-fns'
import { Line } from 'react-chartjs-2'
import { format } from 'date-fns' import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import './WeatherDashboard.css' import './WeatherDashboard.css'
// Build-Informationen (werden beim Build eingef\u00fcgt)
ChartJS.register( const buildDate = __BUILD_DATE__
CategoryScale, const version = __VERSION__
LinearScale, // Deutsche Lokalisierung für Highcharts
TimeScale, Highcharts.setOptions({
PointElement, lang: {
LineElement, months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
Title, shortMonths: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
Tooltip, weekdays: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
Legend, resetZoom: 'Zoom zurücksetzen'
Filler },
) time: {
useUTC: false
}
})
const WeatherDashboard = ({ data }) => { const WeatherDashboard = ({ data }) => {
// Daten vorbereiten und nach Zeit sortieren (älteste zuerst) // Daten vorbereiten und nach Zeit sortieren (älteste zuerst)
@@ -35,366 +26,432 @@ const WeatherDashboard = ({ data }) => {
return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime)) return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime))
}, [data]) }, [data])
// Labels für X-Achse (Zeit) // Gemeinsame Chart-Optionen
const labels = useMemo(() => { const getCommonOptions = () => ({
return sortedData.map(item => chart: {
format(new Date(item.datetime), 'HH:mm', { locale: de }) height: '50%',
) animation: false,
}, [sortedData]) backgroundColor: 'transparent'
// Chart-Konfiguration
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
}, },
elements: { accessibility: {
point: { enabled: false
radius: 0, },
hitRadius: 10, credits: {
hoverRadius: 5, enabled: false
} },
title: {
text: null
}, },
plugins: {
legend: { legend: {
display: false, enabled: false
}, },
tooltip: { tooltip: {
callbacks: { shared: true,
title: (context) => { crosshairs: true,
const index = context[0].dataIndex xDateFormat: '%d.%m.%Y %H:%M'
return format(new Date(sortedData[index].datetime), 'dd.MM.yyyy HH:mm', { locale: de }) },
plotOptions: {
series: {
marker: {
enabled: false,
states: {
hover: {
enabled: true,
radius: 5
}
} }
} }
} }
}, },
scales: { xAxis: {
x: { type: 'datetime',
grid: { tickInterval: 4 * 3600 * 1000, // 4 Stunden in Millisekunden
display: true, labels: {
color: 'rgba(0, 0, 0, 0.1)', format: '{value:%H:%M}',
align: 'center'
}, },
ticks: { gridLineWidth: 1,
type: 'time', gridLineColor: 'rgba(0, 0, 0, 0.1)'
time: {
unit: 'hour',
stepSize: 4
}, },
ticks: { yAxis: {
autoSkip: false gridLineColor: 'rgba(0, 0, 0, 0.05)'
}
/*
maxRotation: 0,
autoSkip: false,
callback: function(value, index) {
if (sortedData.length === 0) return ''
const date = new Date(sortedData[index]?.datetime)
const hours = date.getHours()
const minutes = date.getMinutes()
// Berechne die nächste 4-Stunden-Zeit
const nearestFourHour = Math.round(hours / 4) * 4
// Wenn die Stunde durch 4 teilbar ist UND die Minuten <= 2 sind (also 00:00, 00:05 zählen),
// dann ist dies der Datenpunkt, der der 4-Stunden-Zeit am nächsten liegt
if (hours % 4 === 0 && minutes <= 2) {
return format(new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, 0), 'HH:mm', { locale: de })
}
return ''
}
*/
}
},
y: {
grid: {
color: 'rgba(0, 0, 0, 0.05)',
}
}
}
} }
})
// Temperatur Chart // Temperatur Chart
const temperatureData = { const temperatureOptions = useMemo(() => {
labels, const temps = sortedData.map(item => item.temperature)
datasets: [ const min = Math.min(...temps)
{ const max = Math.max(...temps)
label: 'Temperatur (°C)', const range = max - min
data: sortedData.map(item => item.temperature),
borderColor: 'rgb(255, 99, 132)', let yMin = min
backgroundColor: 'rgba(255, 99, 132, 0.1)', let yMax = max
fill: 'start',
tension: 0.4, if (range < 10) {
const center = (max + min) / 2
yMin = center - 5
yMax = center + 5
} }
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: 'Temperatur (°C)' },
min: yMin,
max: yMax
},
series: [{
name: 'Temperatur',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.temperature]),
color: 'rgb(255, 99, 132)',
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'rgba(255, 99, 132, 0.3)'],
[1, 'rgba(255, 99, 132, 0.1)']
] ]
},
type: 'areaspline',
threshold: null,
tooltip: {
valueSuffix: ' °C'
} }
}]
}
}, [sortedData])
const temperatureOptions = { // Luftfeuchtigkeit Chart
...commonOptions, const humidityOptions = useMemo(() => ({
scales: { ...getCommonOptions(),
...commonOptions.scales, yAxis: {
y: { ...getCommonOptions().yAxis,
...commonOptions.scales.y, title: { text: 'Feuchte (%)' },
afterDataLimits: (axis) => { min: 40,
const range = axis.max - axis.min
if (range < 15) {
const center = (axis.max + axis.min) / 2
axis.max = center + 7.5
axis.min = center - 7.5
}
}
}
}
}
// Feuchte Chart
const humidityData = {
labels,
datasets: [
{
label: 'Luftfeuchtigkeit (%)',
data: sortedData.map(item => item.humidity),
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
fill: true,
tension: 0.4,
}
]
}
const humidityOptions = {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
min: 0,
max: 100 max: 100
} },
} series: [{
} name: 'Feuchte',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.humidity]),
// Druck Chart color: 'rgb(54, 162, 235)',
const pressureData = { fillColor: {
labels, linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
datasets: [ stops: [
{ [0, 'rgba(54, 162, 235, 0.3)'],
label: 'Luftdruck (hPa)', [1, 'rgba(54, 162, 235, 0.1)']
data: sortedData.map(item => item.pressure),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
fill: true,
tension: 0.4,
}
] ]
},
type: 'area',
tooltip: {
valueSuffix: ' %'
}
}]
}), [sortedData])
// Luftdruck Chart
const pressureOptions = useMemo(() => {
const pressures = sortedData.map(item => item.pressure)
const min = Math.min(...pressures)
const max = Math.max(...pressures)
const range = max - min
let yMin = min
let yMax = max
if (range < 40) {
const center = (max + min) / 2
yMin = center - 20
yMax = center + 20
} }
const pressureOptions = { return {
...commonOptions, ...getCommonOptions(),
scales: { yAxis: {
...commonOptions.scales, ...getCommonOptions().yAxis,
y: { title: { text: 'Luftdruck (hPa)' },
...commonOptions.scales.y, min: yMin,
afterDataLimits: (axis) => { max: yMax
const range = axis.max - axis.min },
if (range < 50) { series: [{
const center = (axis.max + axis.min) / 2 name: 'Luftdruck',
axis.max = center + 25 data: sortedData.map(item => [new Date(item.datetime).getTime(), item.pressure]),
axis.min = center - 25 color: 'rgb(75, 192, 192)',
} fillColor: {
} linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
} stops: [
[0, 'rgba(75, 192, 192, 0.3)'],
[1, 'rgba(75, 192, 192, 0.1)']
]
},
type: 'area',
tooltip: {
valueSuffix: ' hPa'
} }
}]
} }
}, [sortedData])
// Regen Chart // Regen Chart
const rainData = { const rainOptions = useMemo(() => ({
labels, ...getCommonOptions(),
datasets: [ yAxis: {
{ ...getCommonOptions().yAxis,
label: 'Regen (mm)', title: { text: 'Regen (mm) / Rate (mm/h)' }
data: sortedData.map(item => item.rain),
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.3)',
fill: true,
tension: 0.4,
}, },
{ series: [{
label: 'Regenrate (mm/h)', name: 'Regen',
data: sortedData.map(item => item.rain_rate), data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]),
borderColor: 'rgb(59, 130, 246)', color: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(59, 130, 246, 0.1)', fillColor: 'rgba(54, 162, 235, 0.3)',
borderDash: [5, 5], type: 'area',
fill: false, tooltip: {
tension: 0.4, valueSuffix: ' mm'
}
]
}
const rainOptions = {
...commonOptions,
plugins: {
...commonOptions.plugins,
legend: {
display: true,
position: 'top',
}
} }
}, {
name: 'Regenrate',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]),
color: 'rgb(59, 130, 246)',
dashStyle: 'Dash',
type: 'line',
tooltip: {
valueSuffix: ' mm/h'
} }
}]
}), [sortedData])
// Windgeschwindigkeit Chart // Windgeschwindigkeit Chart
const windSpeedData = { const windSpeedOptions = useMemo(() => ({
labels, ...getCommonOptions(),
datasets: [ plotOptions: {
{ series: {
label: 'Windgeschwindigkeit (km/h)', marker: {
data: sortedData.map(item => item.wind_speed), enabled: false
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
fill: true,
tension: 0,
}, },
{ lineWidth: 2
label: 'Windböen (km/h)', },
data: sortedData.map(item => item.wind_gust), line: {
borderColor: 'rgb(255, 159, 64)', step: 'left' // Keine Glättung
backgroundColor: 'rgba(255, 159, 64, 0.1)',
fill: true,
tension: 0,
} }
] },
} yAxis: {
...getCommonOptions().yAxis,
const windSpeedOptions = { title: {
...commonOptions, text: 'Windspeed (km/h)',
plugins: { style: {
...commonOptions.plugins, whiteSpace: 'nowrap'
legend: {
display: true,
position: 'top',
} }
} }
},
series: [{
name: 'Windgeschwindigkeit',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_speed]),
color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area',
tooltip: {
valueSuffix: ' km/h'
} }
}, {
name: 'Windböen',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_gust]),
color: 'rgb(255, 159, 64)',
fillColor: 'rgba(255, 159, 64, 0.1)',
type: 'area',
tooltip: {
valueSuffix: ' km/h'
}
}]
}), [sortedData])
// Windrichtung Chart // Windrichtung Chart
const windDirData = { const windDirOptions = useMemo(() => ({
labels, ...getCommonOptions(),
datasets: [ plotOptions: {
{ scatter: {
label: 'Windrichtung (°)', marker: {
data: sortedData.map(item => item.wind_dir), enabled: true,
borderColor: 'rgb(255, 205, 86)', radius: 2,
backgroundColor: 'rgb(255, 205, 86)', states: {
pointRadius: 4, hover: {
pointHoverRadius: 6, enabled: true,
showLine: false, radius: 3
fill: false,
} }
]
} }
}
const windDirOptions = { }
...commonOptions, },
scales: { yAxis: {
...commonOptions.scales, ...getCommonOptions().yAxis,
y: { title: { text: 'Windrichtung' },
...commonOptions.scales.y,
min: 0, min: 0,
max: 360, max: 360,
ticks: { tickInterval: 45,
stepSize: 45, labels: {
callback: (value) => { formatter: function() {
if (value === 0 || value === 360) return 'N' const directions = {
if (value === 45) return 'NO' 0: 'N', 45: 'NO', 90: 'O', 135: 'SO',
if (value === 90) return 'O' 180: 'S', 225: 'SW', 270: 'W', 315: 'NW', 360: 'N'
if (value === 135) return 'SO' }
if (value === 180) return 'S' return directions[this.value] || ''
if (value === 225) return 'SW'
if (value === 270) return 'W'
if (value === 315) return 'NW'
return ''
}
}
} }
} }
},
series: [{
name: 'Windrichtung',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_dir]),
color: 'rgb(54, 162, 235)',
type: 'scatter',
tooltip: {
valueSuffix: ' °'
} }
}]
}), [sortedData])
// Aktuellste Werte für Übersicht // Aktuellste Werte für Übersicht
const current = sortedData[sortedData.length - 1] || {} const current = sortedData[sortedData.length - 1] || {}
// Berechne Min/Max für den aktuellen Tag
const todayStats = useMemo(() => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayData = sortedData.filter(item => {
const itemDate = new Date(item.datetime)
return itemDate >= todayStart
})
if (todayData.length === 0) {
return {
minTemp: null, maxTemp: null, minTempTime: null, maxTempTime: null,
minHumidity: null, maxHumidity: null, minHumidityTime: null, maxHumidityTime: null,
minPressure: null, maxPressure: null, minPressureTime: null, maxPressureTime: null
}
}
// Temperatur
const minTempItem = todayData.reduce((min, item) =>
item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null)
const maxTempItem = todayData.reduce((max, item) =>
item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null)
// Luftfeuchtigkeit
const minHumidityItem = todayData.reduce((min, item) =>
item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null)
const maxHumidityItem = todayData.reduce((max, item) =>
item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null)
// Luftdruck
const minPressureItem = todayData.reduce((min, item) =>
item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null)
const maxPressureItem = todayData.reduce((max, item) =>
item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null)
// Windgeschwindigkeit
const maxWindGustItem = todayData.reduce((max, item) =>
item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null)
return {
minTemp: minTempItem?.temperature ?? null,
maxTemp: maxTempItem?.temperature ?? null,
minTempTime: minTempItem ? format(new Date(minTempItem.datetime), 'HH:mm', { locale: de }) : null,
maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), 'HH:mm', { locale: de }) : null,
minHumidity: minHumidityItem?.humidity ?? null,
maxHumidity: maxHumidityItem?.humidity ?? null,
minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), 'HH:mm', { locale: de }) : null,
maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), 'HH:mm', { locale: de }) : null,
minPressure: minPressureItem?.pressure ?? null,
maxPressure: maxPressureItem?.pressure ?? null,
minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), 'HH:mm', { locale: de }) : null,
maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), 'HH:mm', { locale: de }) : null,
maxWindGust: maxWindGustItem?.wind_gust ?? null,
maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), 'HH:mm', { locale: de }) : null
}
}, [sortedData])
return ( return (
<div className="dashboard"> <div className="dashboard">
{/* Aktuelle Werte Übersicht */}
<div className="current-values">
<div className="value-card">
<span className="value-label">Temperatur</span>
<span className="value-number">{current.temperature?.toFixed(1) || '-'}°C</span>
</div>
<div className="value-card">
<span className="value-label">Luftfeuchtigkeit</span>
<span className="value-number">{current.humidity || '-'}%</span>
</div>
<div className="value-card">
<span className="value-label">Luftdruck</span>
<span className="value-number">{current.pressure?.toFixed(1) || '-'} hPa</span>
</div>
<div className="value-card">
<span className="value-label">Wind</span>
<span className="value-number">{current.wind_speed?.toFixed(1) || '-'} km/h</span>
</div>
<div className="value-card">
<span className="value-label">Regen</span>
<span className="value-number">{current.rain?.toFixed(1) || '-'} mm</span>
</div>
</div>
{/* Charts Grid */} {/* Charts Grid */}
<div className="charts-grid"> <div className="charts-grid">
<div className="chart-container"> <div className="chart-container">
<h3>🌡 Temperatur</h3> <h3>🌡 Temperatur - Aktuell: {current.temperature?.toFixed(1) || '-'}°C</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={temperatureData} options={temperatureOptions} /> <HighchartsReact highcharts={Highcharts} options={temperatureOptions} />
</div>
<div className="chart-stats">
Min: {todayStats.minTemp?.toFixed(1) || '-'}°C ({todayStats.minTempTime || '-'}) | Max: {todayStats.maxTemp?.toFixed(1) || '-'}°C ({todayStats.maxTempTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>💧 Luftfeuchtigkeit</h3> <h3>🌐 Luftdruck - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={humidityData} options={humidityOptions} /> <HighchartsReact highcharts={Highcharts} options={pressureOptions} />
</div>
<div className="chart-stats">
Min: {todayStats.minPressure?.toFixed(1) || '-'} hPa ({todayStats.minPressureTime || '-'}) | Max: {todayStats.maxPressure?.toFixed(1) || '-'} hPa ({todayStats.maxPressureTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🌐 Luftdruck</h3> <h3>💧 Luftfeuchtigkeit - Aktuell: {current.humidity || '-'}%</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={pressureData} options={pressureOptions} /> <HighchartsReact highcharts={Highcharts} options={humidityOptions} />
</div>
<div className="chart-stats">
Min: {todayStats.minHumidity || '-'}% ({todayStats.minHumidityTime || '-'}) | Max: {todayStats.maxHumidity || '-'}% ({todayStats.maxHumidityTime || '-'})
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🌧 Regen</h3> <h3>🌧 Regen - Aktuell: {current.rain?.toFixed(1) || '-'} mm</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={rainData} options={rainOptions} /> <HighchartsReact highcharts={Highcharts} options={rainOptions} />
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>💨 Windgeschwindigkeit</h3> <h3>🧭 Windrichtung - Aktuell: {current.wind_dir ?? '-'}°</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={windSpeedData} options={windSpeedOptions} /> <HighchartsReact highcharts={Highcharts} options={windDirOptions} />
</div> </div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<h3>🧭 Windrichtung</h3> <h3>💨 Windspeed - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</h3>
<div className="chart-wrapper"> <div className="chart-wrapper">
<Line data={windDirData} options={windDirOptions} /> <HighchartsReact highcharts={Highcharts} options={windSpeedOptions} />
</div>
<div className="chart-stats">
Max: {todayStats.maxWindGust?.toFixed(1) || '-'} km/h ({todayStats.maxWindGustTime || '-'})
</div> </div>
</div> </div>
</div> </div>
{/* Footer */}
<div className="dashboard-footer">
<div className="version-line">
<div><a href="mailto:rxf@gmx.de">
mailto:rxf@gmx.de
</a>
</div>
<div>
<span className="version-full">Version</span>
<span className="version-short">V</span>
{' '}{version} {buildDate}
</div>
</div>
<hr />
<div className="footer-credits">
<div className="footer-left">Daten-Erfassung mit einer Davis VantagePro.</div>
<div className="footer-right">Grafiken erzeugt mit HighCharts</div>
</div>
<div className="footer-sponsor">
Die Wetterstation wurde vom Zeitungsverlag Waiblingen <a href="https://www.zvw.de" target="_blank" rel="noopener noreferrer">www.zvw.de</a> gestiftet.
</div>
</div>
</div> </div>
) )
} }

View File

@@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: './', base: './', define: {
server: { __BUILD_DATE__: JSON.stringify(process.env.VITE_BUILD_DATE || 'dev'),
__VERSION__: JSON.stringify(process.env.VITE_VERSION || '0.0.0')
}, server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 3000,
proxy: { proxy: {

View File

@@ -1,24 +1,38 @@
#!/bin/bash #!/bin/bash
# Script zum Bauen und Pushen der Docker Images zur Registry # Script zum Bauen und Pushen der Docker Images zur Registry (Multi-Plattform)
set -e set -e
REGISTRY="docker.citysensor.de" REGISTRY="docker.citysensor.de"
PROJECT="wetterstation" PROJECT="wetterstation"
PLATFORMS="linux/amd64,linux/arm64"
echo "🔨 Building Docker images..." echo "🔧 Setting up buildx builder..."
docker compose build collector # Erstelle oder verwende existierenden Builder
docker compose build api docker buildx create --name multiplatform --use 2>/dev/null || docker buildx use multiplatform
docker compose build frontend
echo "" echo ""
echo "📤 Pushing images to ${REGISTRY}..." echo "🔨 Building and pushing Docker images for ${PLATFORMS}..."
docker compose push collector
docker compose push api # Baue und pushe alle Images mit buildx
docker compose push frontend docker buildx build --platform ${PLATFORMS} \
-t ${REGISTRY}/${PROJECT}/collector:latest \
--push \
./collector
docker buildx build --platform ${PLATFORMS} \
-t ${REGISTRY}/${PROJECT}/api:latest \
--push \
./api
docker buildx build --platform ${PLATFORMS} \
-t ${REGISTRY}/${PROJECT}/frontend:latest \
--push \
./frontend
echo "" echo ""
echo "✅ Done! Images successfully pushed to ${REGISTRY}" echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
echo " Platforms: ${PLATFORMS}"
echo "" echo ""
echo "To pull and run on another machine:" echo "To pull and run on another machine:"
echo " docker compose pull" echo " docker compose pull"