Compare commits
10 Commits
251b21fa4f
...
0b9d21c24c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b9d21c24c | |||
| b71d92646b | |||
| 4fde7ed46a | |||
| f32e472ea3 | |||
| db1e2fd737 | |||
| c03ffe839d | |||
| 7139619d28 | |||
| 19ea455b55 | |||
| ea0b8dd8f9 | |||
| 2fc4bd9db6 |
185
DEPLOY-PRODUCTION.md
Normal file
185
DEPLOY-PRODUCTION.md
Normal 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"
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
25
api/main.py
25
api/main.py
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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,99 +32,223 @@ 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:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Als JSON parsen
|
||||||
|
data_dict = json.loads(body_str)
|
||||||
|
logger.info(f"POST auf Root - Parsed JSON: {data_dict}")
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
self.db_conn = psycopg2.connect(
|
# Konvertiere zu den richtigen Werten
|
||||||
host=DB_HOST,
|
dt_string = data.get_datetime_string()
|
||||||
port=DB_PORT,
|
temp_c = data.get_temperature_celsius()
|
||||||
database=DB_NAME,
|
humidity = data.get_humidity_int()
|
||||||
user=DB_USER,
|
pressure = data.get_pressure_hpa()
|
||||||
password=DB_PASSWORD
|
wind_speed = data.get_wind_speed()
|
||||||
)
|
wind_gust = data.get_wind_gust()
|
||||||
logger.info("Datenbankverbindung hergestellt")
|
wind_dir = data.get_wind_dir()
|
||||||
|
rain = data.rain
|
||||||
|
rain_rate = data.get_rain_rate()
|
||||||
|
|
||||||
# Tabelle erstellen falls nicht vorhanden
|
logger.info(f"Konvertierte Daten - datetime: {dt_string}, temp: {temp_c}°C, humidity: {humidity}%, pressure: {pressure} hPa")
|
||||||
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
|
|
||||||
|
|
||||||
def setup_mqtt(self):
|
with conn.cursor() as cursor:
|
||||||
"""MQTT Client konfigurieren"""
|
|
||||||
self.mqtt_client = mqtt.Client()
|
|
||||||
self.mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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"""
|
|
||||||
try:
|
|
||||||
payload = msg.payload.decode('utf-8')
|
|
||||||
logger.info(f"Nachricht empfangen auf {msg.topic}: {payload}")
|
|
||||||
|
|
||||||
# JSON parsen
|
|
||||||
data = json.loads(payload)
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
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__":
|
||||||
|
|||||||
@@ -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
89
deploy.sh
Executable 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
105
docker-compose.prod.yml
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -9,14 +9,42 @@ function App() {
|
|||||||
const [lastUpdate, setLastUpdate] = useState(null)
|
const [lastUpdate, setLastUpdate] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prüfe ob eingebettete Daten vorhanden sind
|
const fetchData = async () => {
|
||||||
if (window.__WEATHER_DATA__) {
|
try {
|
||||||
setWeatherData(window.__WEATHER_DATA__)
|
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
|
||||||
setLastUpdate(new Date())
|
if (window.__WEATHER_DATA__) {
|
||||||
setLoading(false)
|
setWeatherData(window.__WEATHER_DATA__)
|
||||||
} else {
|
setLastUpdate(new Date())
|
||||||
setError('Keine Wetterdaten verfügbar')
|
setLoading(false)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
hoverRadius: 5,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
plugins: {
|
credits: {
|
||||||
legend: {
|
enabled: false
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scales: {
|
title: {
|
||||||
x: {
|
text: null
|
||||||
grid: {
|
},
|
||||||
display: true,
|
legend: {
|
||||||
color: 'rgba(0, 0, 0, 0.1)',
|
enabled: false
|
||||||
},
|
},
|
||||||
ticks: {
|
tooltip: {
|
||||||
type: 'time',
|
shared: true,
|
||||||
time: {
|
crosshairs: true,
|
||||||
unit: 'hour',
|
xDateFormat: '%d.%m.%Y %H:%M'
|
||||||
stepSize: 4
|
},
|
||||||
},
|
plotOptions: {
|
||||||
ticks: {
|
series: {
|
||||||
autoSkip: false
|
marker: {
|
||||||
}
|
enabled: false,
|
||||||
/*
|
states: {
|
||||||
maxRotation: 0,
|
hover: {
|
||||||
autoSkip: false,
|
enabled: true,
|
||||||
callback: function(value, index) {
|
radius: 5
|
||||||
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)',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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
|
// 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)',
|
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
|
||||||
fill: 'start',
|
|
||||||
tension: 0.4,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const temperatureOptions = {
|
let yMin = min
|
||||||
...commonOptions,
|
let yMax = max
|
||||||
scales: {
|
|
||||||
...commonOptions.scales,
|
if (range < 10) {
|
||||||
y: {
|
const center = (max + min) / 2
|
||||||
...commonOptions.scales.y,
|
yMin = center - 5
|
||||||
afterDataLimits: (axis) => {
|
yMax = center + 5
|
||||||
const range = axis.max - axis.min
|
}
|
||||||
if (range < 15) {
|
|
||||||
const center = (axis.max + axis.min) / 2
|
return {
|
||||||
axis.max = center + 7.5
|
...getCommonOptions(),
|
||||||
axis.min = center - 7.5
|
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
|
// Luftfeuchtigkeit Chart
|
||||||
const humidityData = {
|
const humidityOptions = useMemo(() => ({
|
||||||
labels,
|
...getCommonOptions(),
|
||||||
datasets: [
|
yAxis: {
|
||||||
{
|
...getCommonOptions().yAxis,
|
||||||
label: 'Luftfeuchtigkeit (%)',
|
title: { text: 'Feuchte (%)' },
|
||||||
data: sortedData.map(item => item.humidity),
|
min: 40,
|
||||||
borderColor: 'rgb(54, 162, 235)',
|
max: 100
|
||||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
},
|
||||||
fill: true,
|
series: [{
|
||||||
tension: 0.4,
|
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 = {
|
// Luftdruck Chart
|
||||||
...commonOptions,
|
const pressureOptions = useMemo(() => {
|
||||||
scales: {
|
const pressures = sortedData.map(item => item.pressure)
|
||||||
...commonOptions.scales,
|
const min = Math.min(...pressures)
|
||||||
y: {
|
const max = Math.max(...pressures)
|
||||||
...commonOptions.scales.y,
|
const range = max - min
|
||||||
min: 0,
|
|
||||||
max: 100
|
let yMin = min
|
||||||
}
|
let yMax = max
|
||||||
|
|
||||||
|
if (range < 40) {
|
||||||
|
const center = (max + min) / 2
|
||||||
|
yMin = center - 20
|
||||||
|
yMax = center + 20
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Druck Chart
|
return {
|
||||||
const pressureData = {
|
...getCommonOptions(),
|
||||||
labels,
|
yAxis: {
|
||||||
datasets: [
|
...getCommonOptions().yAxis,
|
||||||
{
|
title: { text: 'Luftdruck (hPa)' },
|
||||||
label: 'Luftdruck (hPa)',
|
min: yMin,
|
||||||
data: sortedData.map(item => item.pressure),
|
max: yMax
|
||||||
borderColor: 'rgb(75, 192, 192)',
|
},
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
series: [{
|
||||||
fill: true,
|
name: 'Luftdruck',
|
||||||
tension: 0.4,
|
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: [
|
||||||
const pressureOptions = {
|
[0, 'rgba(75, 192, 192, 0.3)'],
|
||||||
...commonOptions,
|
[1, 'rgba(75, 192, 192, 0.1)']
|
||||||
scales: {
|
]
|
||||||
...commonOptions.scales,
|
},
|
||||||
y: {
|
type: 'area',
|
||||||
...commonOptions.scales.y,
|
tooltip: {
|
||||||
afterDataLimits: (axis) => {
|
valueSuffix: ' hPa'
|
||||||
const range = axis.max - axis.min
|
|
||||||
if (range < 50) {
|
|
||||||
const center = (axis.max + axis.min) / 2
|
|
||||||
axis.max = center + 25
|
|
||||||
axis.min = center - 25
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}]
|
||||||
}
|
}
|
||||||
}
|
}, [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)',
|
series: [{
|
||||||
backgroundColor: 'rgba(54, 162, 235, 0.3)',
|
name: 'Regen',
|
||||||
fill: true,
|
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]),
|
||||||
tension: 0.4,
|
color: 'rgb(54, 162, 235)',
|
||||||
},
|
fillColor: 'rgba(54, 162, 235, 0.3)',
|
||||||
{
|
type: 'area',
|
||||||
label: 'Regenrate (mm/h)',
|
tooltip: {
|
||||||
data: sortedData.map(item => item.rain_rate),
|
valueSuffix: ' mm'
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
borderDash: [5, 5],
|
|
||||||
fill: false,
|
|
||||||
tension: 0.4,
|
|
||||||
}
|
}
|
||||||
]
|
}, {
|
||||||
}
|
name: 'Regenrate',
|
||||||
|
data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]),
|
||||||
const rainOptions = {
|
color: 'rgb(59, 130, 246)',
|
||||||
...commonOptions,
|
dashStyle: 'Dash',
|
||||||
plugins: {
|
type: 'line',
|
||||||
...commonOptions.plugins,
|
tooltip: {
|
||||||
legend: {
|
valueSuffix: ' mm/h'
|
||||||
display: true,
|
|
||||||
position: 'top',
|
|
||||||
}
|
}
|
||||||
}
|
}]
|
||||||
}
|
}), [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)',
|
lineWidth: 2
|
||||||
fill: true,
|
|
||||||
tension: 0,
|
|
||||||
},
|
},
|
||||||
{
|
line: {
|
||||||
label: 'Windböen (km/h)',
|
step: 'left' // Keine Glättung
|
||||||
data: sortedData.map(item => item.wind_gust),
|
|
||||||
borderColor: 'rgb(255, 159, 64)',
|
|
||||||
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: {
|
|
||||||
...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 ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
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
|
// 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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user