From 9c5f985cabae08329db985521cef7ef8b998dbd7 Mon Sep 17 00:00:00 2001 From: rxf Date: Sat, 7 Feb 2026 14:12:13 +0100 Subject: [PATCH] nun mal erste komplette Version mit API und Frontend (React !) --- .gitignore | 18 + api/.dockerignore | 20 + api/Dockerfile | 21 ++ api/main.py | 337 +++++++++++++++++ api/requirements.txt | 5 + collector/.dockerignore | 13 + docker-compose.yml | 29 ++ frontend/.dockerignore | 22 ++ frontend/.env.example | 1 + frontend/Dockerfile | 29 ++ frontend/index.html | 12 + frontend/nginx.conf | 36 ++ frontend/package.json | 24 ++ frontend/src/App.css | 86 +++++ frontend/src/App.jsx | 107 ++++++ frontend/src/components/WeatherDashboard.css | 80 ++++ frontend/src/components/WeatherDashboard.jsx | 367 +++++++++++++++++++ frontend/src/index.css | 19 + frontend/src/main.jsx | 10 + frontend/vite.config.js | 17 + push-images.sh | 4 + start-dev.sh | 45 +++ 22 files changed, 1302 insertions(+) create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 api/main.py create mode 100644 api/requirements.txt create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/WeatherDashboard.css create mode 100644 frontend/src/components/WeatherDashboard.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js create mode 100755 start-dev.sh 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 ( +
+
+

🌤️ Wetterstation

+ {lastUpdate && ( +

+ Letzte Aktualisierung: {lastUpdate.toLocaleTimeString('de-DE')} +

+ )} +
+ +
+ +
+
+ ) +} + +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 */} +
+
+

🌡️ Temperatur

+
+ +
+
+ +
+

💧 Luftfeuchtigkeit

+
+ +
+
+ +
+

🌐 Luftdruck

+
+ +
+
+ +
+

🌧️ Regen

+
+ +
+
+ +
+

💨 Windgeschwindigkeit

+
+ +
+
+ +
+

🧭 Windrichtung

+
+ +
+
+
+
+ ) +} + +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