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
WORKDIR /app
@@ -5,6 +7,7 @@ WORKDIR /app
# System-Abhängigkeiten installieren
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python-Abhängigkeiten installieren

View File

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

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim
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 json
import logging
import ssl
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
import paho.mqtt.client as mqtt
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
import psycopg2
from psycopg2.extras import RealDictCursor
import uvicorn
# Logging konfigurieren
logging.basicConfig(
@@ -23,11 +24,7 @@ env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
# Konfiguration
MQTT_BROKER = os.getenv('MQTT_BROKER', 'rexfue.de')
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')
COLLECTOR_PORT = int(os.getenv('COLLECTOR_PORT', 8001))
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432))
@@ -35,99 +32,223 @@ DB_NAME = os.getenv('DB_NAME', 'wetterstation')
DB_USER = os.getenv('DB_USER')
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"""
# Pydantic Models
class WeatherDataInput(BaseModel):
# Unterstütze beide Formate: datetime (String) oder dateTime (Unix-Timestamp)
datetime: str | None = None
dateTime: int | None = None
def __init__(self):
self.db_conn = None
self.mqtt_client = None
self.setup_database()
self.setup_mqtt()
# Unterstütze beide Feldnamen
temperature: float | None = None
outTemp: float | None = None # Fahrenheit
def setup_database(self):
"""Datenbankverbindung herstellen und Tabelle erstellen"""
try:
self.db_conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
logger.info("Datenbankverbindung hergestellt")
# Tabelle erstellen falls nicht vorhanden
with self.db_conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS weather_data (
id SERIAL PRIMARY KEY,
datetime TIMESTAMP NOT NULL,
temperature FLOAT,
humidity INTEGER,
pressure FLOAT,
wind_speed FLOAT,
wind_gust FLOAT,
wind_dir FLOAT,
rain FLOAT,
rain_rate FLOAT,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(datetime)
)
""")
self.db_conn.commit()
logger.info("Tabelle weather_data bereit")
except Exception as e:
logger.error(f"Fehler bei Datenbankverbindung: {e}")
raise
humidity: int | None = None
outHumidity: float | None = None
def setup_mqtt(self):
"""MQTT Client konfigurieren"""
self.mqtt_client = mqtt.Client()
self.mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
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:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
return conn
except Exception as e:
logger.error(f"Datenbankverbindungsfehler: {e}")
raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
def setup_database():
"""Tabelle erstellen falls nicht vorhanden"""
try:
conn = get_db_connection()
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS weather_data (
id SERIAL PRIMARY KEY,
datetime TIMESTAMPTZ NOT NULL,
temperature FLOAT,
humidity INTEGER,
pressure FLOAT,
wind_speed FLOAT,
wind_gust FLOAT,
wind_dir FLOAT,
rain FLOAT,
rain_rate FLOAT,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(datetime)
)
""")
conn.commit()
logger.info("Tabelle weather_data bereit")
conn.close()
except Exception as e:
logger.error(f"Fehler bei Datenbanksetup: {e}")
raise
# API Endpoints
@app.on_event("startup")
async def startup_event():
"""Bei Start die Datenbank initialisieren"""
logger.info("Collector API startet...")
setup_database()
logger.info(f"API läuft auf Port {COLLECTOR_PORT}")
@app.get("/")
async def root():
"""Root Endpoint - GET zeigt Info"""
return {
"message": "Weather Data Collector API",
"version": "1.0.0",
"endpoint": "POST /weather or POST /"
}
@app.post("/")
async def root_post(request: Request):
"""Root Endpoint - POST akzeptiert Wetterdaten (Alias für /weather)"""
try:
# Rohen Body lesen
body = await request.body()
body_str = body.decode('utf-8')
logger.info(f"POST auf Root - Raw Body: {body_str}")
# Callbacks setzen
self.mqtt_client.on_connect = self.on_connect
self.mqtt_client.on_message = self.on_message
self.mqtt_client.on_disconnect = self.on_disconnect
# Als JSON parsen
data_dict = json.loads(body_str)
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
logger.info(f"MQTT Client konfiguriert für {MQTT_BROKER}:{MQTT_PORT}")
def on_connect(self, client, userdata, flags, rc):
"""Callback wenn MQTT Verbindung hergestellt wird"""
if rc == 0:
logger.info("Mit MQTT Broker verbunden")
client.subscribe(MQTT_TOPIC)
logger.info(f"Topic abonniert: {MQTT_TOPIC}")
else:
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):
"""Callback wenn MQTT Nachricht empfangen wird"""
# Zu Pydantic Model konvertieren
data = WeatherDataInput(**data_dict)
return await receive_weather_data(data)
except json.JSONDecodeError as e:
logger.error(f"JSON Parse Error: {e}")
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
except Exception as e:
logger.error(f"Fehler bei Root POST: {e}")
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
@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:
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:
payload = msg.payload.decode('utf-8')
logger.info(f"Nachricht empfangen auf {msg.topic}: {payload}")
# 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()
# JSON parsen
data = json.loads(payload)
logger.info(f"Konvertierte Daten - datetime: {dt_string}, temp: {temp_c}°C, humidity: {humidity}%, pressure: {pressure} hPa")
# In Datenbank speichern
self.save_to_database(data)
except json.JSONDecodeError as e:
logger.error(f"Fehler beim JSON-Parsen: {e}")
except Exception as e:
logger.error(f"Fehler bei Nachrichtenverarbeitung: {e}")
def save_to_database(self, data):
"""Wetterdaten in PostgreSQL speichern"""
try:
with self.db_conn.cursor() as cursor:
with conn.cursor() as cursor:
cursor.execute("""
INSERT INTO weather_data
(datetime, temperature, humidity, pressure, wind_speed,
@@ -143,51 +264,35 @@ class WeatherDataCollector:
rain = EXCLUDED.rain,
rain_rate = EXCLUDED.rain_rate
""", (
data.get('datetime'),
data.get('temperature'),
data.get('humidity'),
data.get('pressure'),
data.get('wind_speed'),
data.get('wind_gust'),
data.get('wind_dir'),
data.get('rain'),
data.get('rain_rate')
dt_string,
temp_c,
humidity,
pressure,
wind_speed,
wind_gust,
wind_dir,
rain,
rain_rate
))
self.db_conn.commit()
logger.info(f"Daten gespeichert für {data.get('datetime')}")
except Exception as e:
logger.error(f"Fehler beim Speichern in Datenbank: {e}")
self.db_conn.rollback()
def start(self):
"""MQTT Client starten und auf Nachrichten warten"""
try:
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}")
conn.commit()
logger.info(f"Daten gespeichert für {dt_string} (UTC)")
return {
"status": "success",
"message": f"Weather data for {dt_string} saved successfully"
}
finally:
self.cleanup()
def cleanup(self):
"""Ressourcen aufräumen"""
if self.mqtt_client:
self.mqtt_client.disconnect()
logger.info("MQTT Verbindung getrennt")
if self.db_conn:
self.db_conn.close()
logger.info("Datenbankverbindung geschlossen")
conn.close()
except Exception as e:
logger.error(f"Fehler beim Speichern: {e}")
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
def main():
"""Hauptfunktion"""
logger.info("Wetterstation Collector startet...")
# 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)]
if missing_vars:
@@ -195,8 +300,8 @@ def main():
logger.error("Bitte .env Datei mit den erforderlichen Werten erstellen")
return
collector = WeatherDataCollector()
collector.start()
uvicorn.run(app, host="0.0.0.0", port=COLLECTOR_PORT)
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
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
container_name: wetterstation_collector
restart: unless-stopped
ports:
- "8001:8001"
env_file:
- ./.env
environment:

View File

@@ -1,5 +1,11 @@
# syntax=docker/dockerfile:1
# 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
@@ -12,14 +18,18 @@ RUN npm ci
# Copy source code
COPY . .
# Build app
# Build app with build info
ENV VITE_BUILD_DATE=${BUILD_DATE}
ENV VITE_VERSION=${VERSION}
RUN npm run build
# Production stage
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
# Copy built app from builder
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/dist .
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -4,15 +4,20 @@ server {
root /usr/share/nginx/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 on;
gzip_vary on;
gzip_min_length 1024;
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/ {
proxy_pass http://api:8000/;
set $upstream_api api:8000;
proxy_pass http://$upstream_api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

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

View File

@@ -9,14 +9,42 @@ function App() {
const [lastUpdate, setLastUpdate] = useState(null)
useEffect(() => {
// Prüfe ob eingebettete Daten vorhanden sind
if (window.__WEATHER_DATA__) {
setWeatherData(window.__WEATHER_DATA__)
setLastUpdate(new Date())
setLoading(false)
} else {
setError('Keine Wetterdaten verfügbar')
setLoading(false)
const fetchData = async () => {
try {
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
if (window.__WEATHER_DATA__) {
setWeatherData(window.__WEATHER_DATA__)
setLastUpdate(new Date())
setLoading(false)
} else {
// 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)
}
} 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)
}
}, [])

View File

@@ -1,6 +1,7 @@
.dashboard {
width: 100%;
max-width: 795px;
max-width: 1900px;
margin: 0 auto;
}
.current-values {
@@ -52,6 +53,95 @@
}
.chart-wrapper {
height: 250px;
width: 100%;
aspect-ratio: 2 / 1;
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 {
Chart as ChartJS,
CategoryScale,
LinearScale,
TimeScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import 'chartjs-adapter-date-fns'
import { Line } from 'react-chartjs-2'
import Highcharts from 'highcharts'
import HighchartsReact from 'highcharts-react-official'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import './WeatherDashboard.css'
ChartJS.register(
CategoryScale,
LinearScale,
TimeScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
// Build-Informationen (werden beim Build eingef\u00fcgt)
const buildDate = __BUILD_DATE__
const version = __VERSION__
// Deutsche Lokalisierung für Highcharts
Highcharts.setOptions({
lang: {
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
shortMonths: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
weekdays: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
resetZoom: 'Zoom zurücksetzen'
},
time: {
useUTC: false
}
})
const WeatherDashboard = ({ data }) => {
// 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))
}, [data])
// Labels für X-Achse (Zeit)
const labels = useMemo(() => {
return sortedData.map(item =>
format(new Date(item.datetime), 'HH:mm', { locale: de })
)
}, [sortedData])
// Chart-Konfiguration
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
// Gemeinsame Chart-Optionen
const getCommonOptions = () => ({
chart: {
height: '50%',
animation: false,
backgroundColor: 'transparent'
},
elements: {
point: {
radius: 0,
hitRadius: 10,
hoverRadius: 5,
}
accessibility: {
enabled: false
},
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
title: (context) => {
const index = context[0].dataIndex
return format(new Date(sortedData[index].datetime), 'dd.MM.yyyy HH:mm', { locale: de })
}
}
}
credits: {
enabled: false
},
scales: {
x: {
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.1)',
},
ticks: {
type: 'time',
time: {
unit: 'hour',
stepSize: 4
},
ticks: {
autoSkip: false
}
/*
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 })
title: {
text: null
},
legend: {
enabled: false
},
tooltip: {
shared: true,
crosshairs: true,
xDateFormat: '%d.%m.%Y %H:%M'
},
plotOptions: {
series: {
marker: {
enabled: false,
states: {
hover: {
enabled: true,
radius: 5
}
return ''
}
*/
}
},
y: {
grid: {
color: 'rgba(0, 0, 0, 0.05)',
}
}
},
xAxis: {
type: 'datetime',
tickInterval: 4 * 3600 * 1000, // 4 Stunden in Millisekunden
labels: {
format: '{value:%H:%M}',
align: 'center'
},
gridLineWidth: 1,
gridLineColor: 'rgba(0, 0, 0, 0.1)'
},
yAxis: {
gridLineColor: 'rgba(0, 0, 0, 0.05)'
}
}
})
// Temperatur Chart
const temperatureData = {
labels,
datasets: [
{
label: 'Temperatur (°C)',
data: sortedData.map(item => item.temperature),
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
fill: 'start',
tension: 0.4,
}
]
}
const temperatureOptions = useMemo(() => {
const temps = sortedData.map(item => item.temperature)
const min = Math.min(...temps)
const max = Math.max(...temps)
const range = max - min
const temperatureOptions = {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
afterDataLimits: (axis) => {
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
}
let yMin = min
let yMax = max
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])
// 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,
// Luftfeuchtigkeit Chart
const humidityOptions = useMemo(() => ({
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: 'Feuchte (%)' },
min: 40,
max: 100
},
series: [{
name: 'Feuchte',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.humidity]),
color: 'rgb(54, 162, 235)',
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'rgba(54, 162, 235, 0.3)'],
[1, 'rgba(54, 162, 235, 0.1)']
]
},
type: 'area',
tooltip: {
valueSuffix: ' %'
}
]
}
}]
}), [sortedData])
const humidityOptions = {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
min: 0,
max: 100
}
// 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
}
}
// Druck Chart
const pressureData = {
labels,
datasets: [
{
label: 'Luftdruck (hPa)',
data: sortedData.map(item => item.pressure),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
fill: true,
tension: 0.4,
}
]
}
const pressureOptions = {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
afterDataLimits: (axis) => {
const range = axis.max - axis.min
if (range < 50) {
const center = (axis.max + axis.min) / 2
axis.max = center + 25
axis.min = center - 25
}
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: 'Luftdruck (hPa)' },
min: yMin,
max: yMax
},
series: [{
name: 'Luftdruck',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.pressure]),
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
const rainData = {
labels,
datasets: [
{
label: 'Regen (mm)',
data: sortedData.map(item => item.rain),
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.3)',
fill: true,
tension: 0.4,
},
{
label: 'Regenrate (mm/h)',
data: sortedData.map(item => item.rain_rate),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderDash: [5, 5],
fill: false,
tension: 0.4,
const rainOptions = useMemo(() => ({
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: 'Regen (mm) / Rate (mm/h)' }
},
series: [{
name: 'Regen',
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]),
color: 'rgb(54, 162, 235)',
fillColor: 'rgba(54, 162, 235, 0.3)',
type: 'area',
tooltip: {
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
const windSpeedData = {
labels,
datasets: [
{
label: 'Windgeschwindigkeit (km/h)',
data: sortedData.map(item => item.wind_speed),
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
fill: true,
tension: 0,
const windSpeedOptions = useMemo(() => ({
...getCommonOptions(),
plotOptions: {
series: {
marker: {
enabled: false
},
lineWidth: 2
},
{
label: 'Windböen (km/h)',
data: sortedData.map(item => item.wind_gust),
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
fill: true,
tension: 0,
line: {
step: 'left' // Keine Glättung
}
]
}
const windSpeedOptions = {
...commonOptions,
plugins: {
...commonOptions.plugins,
legend: {
display: true,
position: 'top',
},
yAxis: {
...getCommonOptions().yAxis,
title: {
text: 'Windspeed (km/h)',
style: {
whiteSpace: 'nowrap'
}
}
}
}
},
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
const windDirData = {
labels,
datasets: [
{
label: 'Windrichtung (°)',
data: sortedData.map(item => item.wind_dir),
borderColor: 'rgb(255, 205, 86)',
backgroundColor: 'rgb(255, 205, 86)',
pointRadius: 4,
pointHoverRadius: 6,
showLine: false,
fill: false,
}
]
}
const windDirOptions = {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
min: 0,
max: 360,
ticks: {
stepSize: 45,
callback: (value) => {
if (value === 0 || value === 360) return 'N'
if (value === 45) return 'NO'
if (value === 90) return 'O'
if (value === 135) return 'SO'
if (value === 180) return 'S'
if (value === 225) return 'SW'
if (value === 270) return 'W'
if (value === 315) return 'NW'
return ''
const windDirOptions = useMemo(() => ({
...getCommonOptions(),
plotOptions: {
scatter: {
marker: {
enabled: true,
radius: 2,
states: {
hover: {
enabled: true,
radius: 3
}
}
}
}
}
}
},
yAxis: {
...getCommonOptions().yAxis,
title: { text: 'Windrichtung' },
min: 0,
max: 360,
tickInterval: 45,
labels: {
formatter: function() {
const directions = {
0: 'N', 45: 'NO', 90: 'O', 135: 'SO',
180: 'S', 225: 'SW', 270: 'W', 315: 'NW', 360: 'N'
}
return directions[this.value] || ''
}
}
},
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
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 (
<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 */}
<div className="charts-grid">
<div className="chart-container">
<h3>🌡 Temperatur</h3>
<h3>🌡 Temperatur - Aktuell: {current.temperature?.toFixed(1) || '-'}°C</h3>
<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 className="chart-container">
<h3>💧 Luftfeuchtigkeit</h3>
<h3>🌐 Luftdruck - Aktuell: {current.pressure?.toFixed(1) || '-'} hPa</h3>
<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 className="chart-container">
<h3>🌐 Luftdruck</h3>
<h3>💧 Luftfeuchtigkeit - Aktuell: {current.humidity || '-'}%</h3>
<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 className="chart-container">
<h3>🌧 Regen</h3>
<h3>🌧 Regen - Aktuell: {current.rain?.toFixed(1) || '-'} mm</h3>
<div className="chart-wrapper">
<Line data={rainData} options={rainOptions} />
<HighchartsReact highcharts={Highcharts} options={rainOptions} />
</div>
</div>
<div className="chart-container">
<h3>💨 Windgeschwindigkeit</h3>
<h3>🧭 Windrichtung - Aktuell: {current.wind_dir ?? '-'}°</h3>
<div className="chart-wrapper">
<Line data={windSpeedData} options={windSpeedOptions} />
<HighchartsReact highcharts={Highcharts} options={windDirOptions} />
</div>
</div>
<div className="chart-container">
<h3>🧭 Windrichtung</h3>
<h3>💨 Windspeed - Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</h3>
<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>
{/* 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>
)
}

View File

@@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: {
base: './', define: {
__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',
port: 3000,
proxy: {

View File

@@ -1,24 +1,38 @@
#!/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
REGISTRY="docker.citysensor.de"
PROJECT="wetterstation"
PLATFORMS="linux/amd64,linux/arm64"
echo "🔨 Building Docker images..."
docker compose build collector
docker compose build api
docker compose build frontend
echo "🔧 Setting up buildx builder..."
# Erstelle oder verwende existierenden Builder
docker buildx create --name multiplatform --use 2>/dev/null || docker buildx use multiplatform
echo ""
echo "📤 Pushing images to ${REGISTRY}..."
docker compose push collector
docker compose push api
docker compose push frontend
echo "🔨 Building and pushing Docker images for ${PLATFORMS}..."
# Baue und pushe alle Images mit buildx
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 "✅ Done! Images successfully pushed to ${REGISTRY}"
echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
echo " Platforms: ${PLATFORMS}"
echo ""
echo "To pull and run on another machine:"
echo " docker compose pull"