diff --git a/.gitignore b/.gitignore
index f988da3..62693a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,21 @@ docker-compose.override.yml
# pgAdmin
pgadmin_data/
+
+# Node.js
+node_modules/
+package-lock.json
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Frontend Build
+frontend/dist/
+frontend/build/
+
+# Temporal Files
+*.tmp
+*.bak
+
+# PostgreSQL Data
+postgres_data/
diff --git a/api/.dockerignore b/api/.dockerignore
new file mode 100644
index 0000000..99fb33d
--- /dev/null
+++ b/api/.dockerignore
@@ -0,0 +1,20 @@
+.venv/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.env
+.env.local
+*.log
+.git/
+.gitignore
+README.md
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+tests/
+*.db
+*.sqlite
diff --git a/api/Dockerfile b/api/Dockerfile
new file mode 100644
index 0000000..8df84fa
--- /dev/null
+++ b/api/Dockerfile
@@ -0,0 +1,21 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# System-Abhängigkeiten installieren
+RUN apt-get update && apt-get install -y \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Python-Abhängigkeiten installieren
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Anwendung kopieren
+COPY main.py .
+
+# Port freigeben
+EXPOSE 8000
+
+# Anwendung starten
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/api/main.py b/api/main.py
new file mode 100644
index 0000000..f47fa97
--- /dev/null
+++ b/api/main.py
@@ -0,0 +1,337 @@
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+from typing import List, Optional
+from datetime import datetime, timedelta
+import os
+from pathlib import Path
+from dotenv import load_dotenv
+import psycopg
+from psycopg.rows import dict_row
+import logging
+
+# Logging konfigurieren
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# Umgebungsvariablen laden
+env_path = Path(__file__).parent.parent / '.env'
+load_dotenv(dotenv_path=env_path)
+
+# Datenbank-Konfiguration
+DB_HOST = os.getenv('DB_HOST', 'localhost')
+DB_PORT = int(os.getenv('DB_PORT', 5432))
+DB_NAME = os.getenv('DB_NAME', 'wetterstation')
+DB_USER = os.getenv('DB_USER')
+DB_PASSWORD = os.getenv('DB_PASSWORD')
+
+# FastAPI App erstellen
+app = FastAPI(
+ title="Wetterstation API",
+ description="API zum Auslesen von Wetterdaten",
+ version="1.0.0"
+)
+
+# CORS Middleware
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+# Pydantic Models
+class WeatherData(BaseModel):
+ id: int
+ datetime: datetime
+ temperature: Optional[float] = None
+ humidity: Optional[int] = None
+ pressure: Optional[float] = None
+ wind_speed: Optional[float] = None
+ wind_gust: Optional[float] = None
+ wind_dir: Optional[float] = None
+ rain: Optional[float] = None
+ rain_rate: Optional[float] = None
+ received_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class WeatherStats(BaseModel):
+ avg_temperature: Optional[float] = None
+ min_temperature: Optional[float] = None
+ max_temperature: Optional[float] = None
+ avg_humidity: Optional[float] = None
+ avg_pressure: Optional[float] = None
+ avg_wind_speed: Optional[float] = None
+ max_wind_gust: Optional[float] = None
+ total_rain: Optional[float] = None
+ data_points: int
+
+
+class HealthResponse(BaseModel):
+ status: str
+ database: str
+ timestamp: datetime
+
+
+# Datenbankverbindung
+def get_db_connection():
+ """Erstellt eine Datenbankverbindung"""
+ try:
+ conn = psycopg.connect(
+ host=DB_HOST,
+ port=DB_PORT,
+ dbname=DB_NAME,
+ user=DB_USER,
+ password=DB_PASSWORD,
+ row_factory=dict_row
+ )
+ return conn
+ except Exception as e:
+ logger.error(f"Datenbankverbindungsfehler: {e}")
+ raise HTTPException(status_code=500, detail="Datenbankverbindung fehlgeschlagen")
+
+
+# API Endpoints
+@app.get("/", tags=["General"])
+async def root():
+ """Root Endpoint"""
+ return {
+ "message": "Wetterstation API",
+ "version": "1.0.0",
+ "docs": "/docs"
+ }
+
+
+@app.get("/health", response_model=HealthResponse, tags=["General"])
+async def health_check():
+ """Health Check Endpoint"""
+ try:
+ conn = get_db_connection()
+ with conn.cursor() as cursor:
+ cursor.execute("SELECT 1")
+ conn.close()
+ db_status = "connected"
+ except Exception:
+ db_status = "disconnected"
+
+ return {
+ "status": "ok" if db_status == "connected" else "error",
+ "database": db_status,
+ "timestamp": datetime.now()
+ }
+
+
+@app.get("/weather/latest", response_model=WeatherData, tags=["Weather Data"])
+async def get_latest_weather():
+ """Gibt die neuesten Wetterdaten zurück"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT * FROM weather_data
+ ORDER BY datetime DESC
+ LIMIT 1
+ """)
+ result = cursor.fetchone()
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Keine Daten verfügbar")
+
+ return dict(result)
+ finally:
+ conn.close()
+
+
+@app.get("/weather/current", response_model=WeatherData, tags=["Weather Data"])
+async def get_current_weather():
+ """Alias für /weather/latest - gibt aktuelle Wetterdaten zurück"""
+ return await get_latest_weather()
+
+
+@app.get("/weather/history", response_model=List[WeatherData], tags=["Weather Data"])
+async def get_weather_history(
+ hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück (max 168 = 7 Tage)"),
+ limit: int = Query(1000, ge=1, le=10000, description="Maximale Anzahl Datensätze")
+):
+ """Gibt historische Wetterdaten der letzten X Stunden zurück"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT * FROM weather_data
+ WHERE datetime >= NOW() - make_interval(hours => %s)
+ ORDER BY datetime DESC
+ LIMIT %s
+ """, (hours, limit))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+@app.get("/weather/range", response_model=List[WeatherData], tags=["Weather Data"])
+async def get_weather_by_date_range(
+ start: datetime = Query(..., description="Startdatum (ISO 8601)"),
+ end: datetime = Query(..., description="Enddatum (ISO 8601)"),
+ limit: int = Query(10000, ge=1, le=50000, description="Maximale Anzahl Datensätze")
+):
+ """Gibt Wetterdaten für einen bestimmten Zeitraum zurück"""
+ if start >= end:
+ raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen")
+
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT * FROM weather_data
+ WHERE datetime BETWEEN %s AND %s
+ ORDER BY datetime ASC
+ LIMIT %s
+ """, (start, end, limit))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+@app.get("/weather/stats", response_model=WeatherStats, tags=["Statistics"])
+async def get_weather_statistics(
+ hours: int = Query(24, ge=1, le=168, description="Zeitraum in Stunden für Statistiken")
+):
+ """Gibt aggregierte Statistiken für den angegebenen Zeitraum zurück"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT
+ AVG(temperature) as avg_temperature,
+ MIN(temperature) as min_temperature,
+ 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,
+ SUM(rain) as total_rain,
+ COUNT(*) as data_points
+ FROM weather_data
+ WHERE datetime >= NOW() - make_interval(hours => %s)
+ """, (hours,))
+ result = cursor.fetchone()
+
+ if not result or result['data_points'] == 0:
+ raise HTTPException(status_code=404, detail="Keine Daten für den Zeitraum verfügbar")
+
+ return dict(result)
+ finally:
+ conn.close()
+
+
+@app.get("/weather/daily", response_model=List[WeatherStats], tags=["Statistics"])
+async def get_daily_statistics(
+ days: int = Query(7, ge=1, le=90, description="Anzahl Tage zurück (max 90)")
+):
+ """Gibt tägliche Statistiken für die letzten X Tage zurück"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT
+ DATE(datetime) as date,
+ AVG(temperature) as avg_temperature,
+ MIN(temperature) as min_temperature,
+ 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,
+ SUM(rain) as total_rain,
+ COUNT(*) as data_points
+ FROM weather_data
+ WHERE datetime >= NOW() - make_interval(days => %s)
+ GROUP BY DATE(datetime)
+ ORDER BY date DESC
+ """, (days,))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+@app.get("/weather/temperature", response_model=List[dict], tags=["Weather Data"])
+async def get_temperature_data(
+ hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
+):
+ """Gibt nur Temperatur-Zeitreihen zurück (optimiert für Diagramme)"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT datetime, temperature
+ FROM weather_data
+ WHERE datetime >= NOW() - make_interval(hours => %s)
+ AND temperature IS NOT NULL
+ ORDER BY datetime ASC
+ """, (hours,))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+@app.get("/weather/wind", response_model=List[dict], tags=["Weather Data"])
+async def get_wind_data(
+ hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
+):
+ """Gibt nur Wind-Daten zurück (Geschwindigkeit, Richtung, Böen)"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT datetime, wind_speed, wind_gust, wind_dir
+ FROM weather_data
+ WHERE datetime >= NOW() - make_interval(hours => %s)
+ ORDER BY datetime ASC
+ """, (hours,))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+@app.get("/weather/rain", response_model=List[dict], tags=["Weather Data"])
+async def get_rain_data(
+ hours: int = Query(24, ge=1, le=168, description="Anzahl Stunden zurück")
+):
+ """Gibt nur Regen-Daten zurück"""
+ conn = get_db_connection()
+ try:
+ with conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT datetime, rain, rain_rate
+ FROM weather_data
+ WHERE datetime >= NOW() - make_interval(hours => %s)
+ ORDER BY datetime ASC
+ """, (hours,))
+ results = cursor.fetchall()
+
+ return [dict(row) for row in results]
+ finally:
+ conn.close()
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/api/requirements.txt b/api/requirements.txt
new file mode 100644
index 0000000..b8ec64b
--- /dev/null
+++ b/api/requirements.txt
@@ -0,0 +1,5 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+psycopg[binary]>=3.2.0
+python-dotenv>=1.0.0
+pydantic>=2.10.0
diff --git a/collector/.dockerignore b/collector/.dockerignore
index 4b4eebc..99fb33d 100644
--- a/collector/.dockerignore
+++ b/collector/.dockerignore
@@ -4,4 +4,17 @@ __pycache__/
*.pyo
*.pyd
.env
+.env.local
*.log
+.git/
+.gitignore
+README.md
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+.DS_Store
+tests/
+*.db
+*.sqlite
diff --git a/docker-compose.yml b/docker-compose.yml
index e372867..9d73199 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -49,6 +49,35 @@ services:
postgres:
condition: service_healthy
+ api:
+ image: docker.citysensor.de/wetterstation-api:latest
+ build:
+ context: ./api
+ dockerfile: Dockerfile
+ container_name: wetterstation_api
+ restart: unless-stopped
+ env_file:
+ - ./.env
+ environment:
+ DB_HOST: postgres
+ ports:
+ - "8000:8000"
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ frontend:
+ image: docker.citysensor.de/wetterstation-frontend:latest
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ container_name: wetterstation_frontend
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ depends_on:
+ - api
+
volumes:
postgres_data:
driver: local
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..4962c1d
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,22 @@
+node_modules
+dist
+build
+.env
+.env.local
+.env.development
+.env.test
+.vscode
+.idea
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.git/
+.gitignore
+README.md
+*.swp
+*.swo
+*~
+.DS_Store
+tests/
+coverage/
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..5934e2e
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1 @@
+VITE_API_URL=http://localhost:8000
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..ed37093
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,29 @@
+# Build stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy source code
+COPY . .
+
+# Build app
+RUN npm run build
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built app from builder
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# Copy nginx configuration
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..92391ca
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Wetterstation
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..2615e7c
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,36 @@
+server {
+ listen 80;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # 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
+ location /api/ {
+ proxy_pass http://api:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # Frontend routes
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..053b816
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "wetterstation-frontend",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "chart.js": "^4.4.1",
+ "react-chartjs-2": "^5.2.0",
+ "date-fns": "^3.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.1",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "vite": "^5.1.0"
+ }
+}
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..16e8294
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,86 @@
+.app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 2rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.app-header h1 {
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.last-update {
+ font-size: 0.9rem;
+ opacity: 0.9;
+}
+
+.app-main {
+ flex: 1;
+ padding: 2rem;
+ max-width: 1600px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.loading-container,
+.error-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid #f3f3f3;
+ border-top: 4px solid #667eea;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1rem;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.error-container h2 {
+ color: #e53e3e;
+ margin-bottom: 1rem;
+}
+
+.error-container button {
+ margin-top: 1rem;
+ padding: 0.75rem 1.5rem;
+ background-color: #667eea;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background-color 0.3s;
+}
+
+.error-container button:hover {
+ background-color: #5568d3;
+}
+
+@media (max-width: 768px) {
+ .app-header h1 {
+ font-size: 1.8rem;
+ }
+
+ .app-main {
+ padding: 1rem;
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..1d80d99
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,107 @@
+import { useState, useEffect } from 'react'
+import WeatherDashboard from './components/WeatherDashboard'
+import './App.css'
+
+function App() {
+ const [weatherData, setWeatherData] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [lastUpdate, setLastUpdate] = useState(null)
+
+ const fetchWeatherData = async () => {
+ try {
+ const apiUrl = import.meta.env.VITE_API_URL || '/api'
+ const response = await fetch(`${apiUrl}/weather/history?hours=24`)
+
+ if (!response.ok) {
+ throw new Error('Fehler beim Laden der Daten')
+ }
+
+ const data = await response.json()
+ setWeatherData(data)
+ setLastUpdate(new Date())
+ setError(null)
+ } catch (err) {
+ setError(err.message)
+ console.error('Fehler beim Laden der Wetterdaten:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchWeatherData()
+
+ // Berechne Zeit bis zum nächsten 5-Min-Schritt + 1 Minute
+ const scheduleNextRefresh = () => {
+ const now = new Date()
+ const minutes = now.getMinutes()
+ const seconds = now.getSeconds()
+ const milliseconds = now.getMilliseconds()
+
+ // Nächster 5-Minuten-Schritt
+ const nextFiveMinStep = Math.ceil(minutes / 5) * 5
+ // Plus 1 Minute
+ const targetMinute = (nextFiveMinStep + 1) % 60
+
+ let targetTime = new Date(now)
+ targetTime.setMinutes(targetMinute, 0, 0)
+
+ // Wenn die Zielzeit in der Vergangenheit liegt, füge eine Stunde hinzu
+ if (targetTime <= now) {
+ targetTime.setHours(targetTime.getHours() + 1)
+ }
+
+ const timeUntilRefresh = targetTime - now
+
+ console.log(`Nächster Refresh: ${targetTime.toLocaleTimeString('de-DE')} (in ${Math.round(timeUntilRefresh / 1000)}s)`)
+
+ return setTimeout(() => {
+ fetchWeatherData()
+ scheduleNextRefresh()
+ }, timeUntilRefresh)
+ }
+
+ const timeout = scheduleNextRefresh()
+
+ return () => clearTimeout(timeout)
+ }, [])
+
+ if (loading) {
+ return (
+
+
+
Lade Wetterdaten...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
Fehler beim Laden der Daten
+
{error}
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default App
diff --git a/frontend/src/components/WeatherDashboard.css b/frontend/src/components/WeatherDashboard.css
new file mode 100644
index 0000000..4c13c84
--- /dev/null
+++ b/frontend/src/components/WeatherDashboard.css
@@ -0,0 +1,80 @@
+.dashboard {
+ width: 100%;
+}
+
+.current-values {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.value-card {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.value-label {
+ font-size: 0.9rem;
+ color: #666;
+ font-weight: 500;
+}
+
+.value-number {
+ font-size: 2rem;
+ font-weight: bold;
+ color: #333;
+}
+
+.charts-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1.5rem;
+}
+
+.chart-container {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.chart-container h3 {
+ margin-bottom: 1rem;
+ color: #333;
+ font-size: 1.2rem;
+}
+
+.chart-wrapper {
+ height: 300px;
+ position: relative;
+}
+
+@media (max-width: 1024px) {
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .chart-container.chart-full {
+ grid-column: 1;
+ }
+}
+
+@media (max-width: 768px) {
+ .current-values {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .value-number {
+ font-size: 1.5rem;
+ }
+
+ .chart-wrapper {
+ height: 250px;
+ }
+}
diff --git a/frontend/src/components/WeatherDashboard.jsx b/frontend/src/components/WeatherDashboard.jsx
new file mode 100644
index 0000000..91c40a6
--- /dev/null
+++ b/frontend/src/components/WeatherDashboard.jsx
@@ -0,0 +1,367 @@
+import { useMemo } from 'react'
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler
+} from 'chart.js'
+import { Line } from 'react-chartjs-2'
+import { format } from 'date-fns'
+import { de } from 'date-fns/locale'
+import './WeatherDashboard.css'
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ Filler
+)
+
+const WeatherDashboard = ({ data }) => {
+ // Daten vorbereiten und nach Zeit sortieren (älteste zuerst)
+ const sortedData = useMemo(() => {
+ 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,
+ },
+ elements: {
+ point: {
+ radius: 0,
+ hitRadius: 10,
+ hoverRadius: 5,
+ }
+ },
+ 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 })
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ grid: {
+ display: false,
+ },
+ ticks: {
+ maxTicksLimit: 12,
+ }
+ },
+ y: {
+ grid: {
+ color: '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: true,
+ tension: 0.4,
+ }
+ ]
+ }
+
+ 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
+ }
+ }
+ }
+ }
+ }
+
+ // Feuchte Chart
+ const humidityData = {
+ labels,
+ datasets: [
+ {
+ label: 'Luftfeuchtigkeit (%)',
+ data: sortedData.map(item => item.humidity),
+ borderColor: 'rgb(54, 162, 235)',
+ backgroundColor: 'rgba(54, 162, 235, 0.1)',
+ fill: true,
+ tension: 0.4,
+ }
+ ]
+ }
+
+ const humidityOptions = {
+ ...commonOptions,
+ scales: {
+ ...commonOptions.scales,
+ y: {
+ ...commonOptions.scales.y,
+ min: 0,
+ max: 100
+ }
+ }
+ }
+
+ // 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
+ }
+ }
+ }
+ }
+ }
+
+ // 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 = {
+ ...commonOptions,
+ plugins: {
+ ...commonOptions.plugins,
+ legend: {
+ display: true,
+ position: 'top',
+ }
+ }
+ }
+
+ // 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,
+ },
+ {
+ 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,
+ }
+ ]
+ }
+
+ const windSpeedOptions = {
+ ...commonOptions,
+ plugins: {
+ ...commonOptions.plugins,
+ legend: {
+ display: true,
+ position: 'top',
+ }
+ }
+ }
+
+ // Windrichtung Chart
+ const windDirData = {
+ labels,
+ datasets: [
+ {
+ label: 'Windrichtung (°)',
+ data: sortedData.map(item => item.wind_dir),
+ borderColor: 'rgb(255, 205, 86)',
+ backgroundColor: 'rgba(255, 205, 86, 0.1)',
+ fill: true,
+ tension: 0,
+ }
+ ]
+ }
+
+ 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 ''
+ }
+ }
+ }
+ }
+ }
+
+ // Aktuellste Werte für Übersicht
+ const current = sortedData[sortedData.length - 1] || {}
+
+ return (
+
+ {/* Aktuelle Werte Übersicht */}
+
+
+ Temperatur
+ {current.temperature?.toFixed(1) || '-'}°C
+
+
+ Luftfeuchtigkeit
+ {current.humidity || '-'}%
+
+
+ Luftdruck
+ {current.pressure?.toFixed(1) || '-'} hPa
+
+
+ Wind
+ {current.wind_speed?.toFixed(1) || '-'} km/h
+
+
+ Regen
+ {current.rain?.toFixed(1) || '-'} mm
+
+
+
+ {/* Charts Grid */}
+
+
+
+
+
💧 Luftfeuchtigkeit
+
+
+
+
+
+
+
+
+
+
+
💨 Windgeschwindigkeit
+
+
+
+
+
+
+
+
+ )
+}
+
+export default WeatherDashboard
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..8dc8889
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,19 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: #f5f5f5;
+ color: #333;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..9af0bb6
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+)
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..03d414e
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '0.0.0.0',
+ port: 3000,
+ proxy: {
+ '/api': {
+ target: process.env.VITE_API_URL || 'http://localhost:8000',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, '')
+ }
+ }
+ }
+})
diff --git a/push-images.sh b/push-images.sh
index cbc6eca..5698281 100755
--- a/push-images.sh
+++ b/push-images.sh
@@ -8,10 +8,14 @@ PROJECT="wetterstation"
echo "🔨 Building Docker images..."
docker compose build collector
+docker compose build api
+docker compose build frontend
echo ""
echo "📤 Pushing images to ${REGISTRY}..."
docker compose push collector
+docker compose push api
+docker compose push frontend
echo ""
echo "✅ Done! Images successfully pushed to ${REGISTRY}"
diff --git a/start-dev.sh b/start-dev.sh
new file mode 100755
index 0000000..cfd2826
--- /dev/null
+++ b/start-dev.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+
+# Script zum lokalen Starten aller Services
+
+set -e
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+echo "🚀 Starte Wetterstation Services..."
+echo ""
+
+# API starten
+echo "📡 Starte API auf Port 8000..."
+cd "$SCRIPT_DIR"
+source .venv/bin/activate
+python -m uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload &
+API_PID=$!
+echo "API gestartet mit PID $API_PID"
+echo ""
+
+# Kurz warten bis API bereit ist
+sleep 3
+
+# Frontend starten
+echo "🎨 Starte Frontend auf Port 3000..."
+cd "$SCRIPT_DIR/frontend"
+npm run dev &
+FRONTEND_PID=$!
+echo "Frontend gestartet mit PID $FRONTEND_PID"
+echo ""
+
+echo "✅ Alle Services gestartet!"
+echo ""
+echo "📊 API: http://localhost:8000"
+echo "📊 API Docs: http://localhost:8000/docs"
+echo "🌐 Frontend: http://localhost:3000"
+echo ""
+echo "Drücken Sie Ctrl+C um alle Services zu stoppen..."
+echo ""
+
+# Trap zum Beenden aller Prozesse
+trap "echo ''; echo '🛑 Stoppe Services...'; kill $API_PID $FRONTEND_PID 2>/dev/null; exit 0" INT TERM
+
+# Warte auf Beendigung
+wait