nun mal erste komplette Version mit API und Frontend (React !)

This commit is contained in:
rxf
2026-02-07 14:12:13 +01:00
parent 6e1f5744f9
commit 9c5f985cab
22 changed files with 1302 additions and 0 deletions

18
.gitignore vendored
View File

@@ -52,3 +52,21 @@ docker-compose.override.yml
# pgAdmin # pgAdmin
pgadmin_data/ 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/

20
api/.dockerignore Normal file
View File

@@ -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

21
api/Dockerfile Normal file
View File

@@ -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"]

337
api/main.py Normal file
View File

@@ -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)

5
api/requirements.txt Normal file
View File

@@ -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

View File

@@ -4,4 +4,17 @@ __pycache__/
*.pyo *.pyo
*.pyd *.pyd
.env .env
.env.local
*.log *.log
.git/
.gitignore
README.md
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
tests/
*.db
*.sqlite

View File

@@ -49,6 +49,35 @@ services:
postgres: postgres:
condition: service_healthy 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: volumes:
postgres_data: postgres_data:
driver: local driver: local

22
frontend/.dockerignore Normal file
View File

@@ -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/

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000

29
frontend/Dockerfile Normal file
View File

@@ -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;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wetterstation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

36
frontend/nginx.conf Normal file
View File

@@ -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";
}
}

24
frontend/package.json Normal file
View File

@@ -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"
}
}

86
frontend/src/App.css Normal file
View File

@@ -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;
}
}

107
frontend/src/App.jsx Normal file
View File

@@ -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 (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Lade Wetterdaten...</p>
</div>
)
}
if (error) {
return (
<div className="error-container">
<h2>Fehler beim Laden der Daten</h2>
<p>{error}</p>
<button onClick={fetchWeatherData}>Erneut versuchen</button>
</div>
)
}
return (
<div className="app">
<header className="app-header">
<h1>🌤 Wetterstation</h1>
{lastUpdate && (
<p className="last-update">
Letzte Aktualisierung: {lastUpdate.toLocaleTimeString('de-DE')}
</p>
)}
</header>
<main className="app-main">
<WeatherDashboard data={weatherData} />
</main>
</div>
)
}
export default App

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="dashboard">
{/* Aktuelle Werte Übersicht */}
<div className="current-values">
<div className="value-card">
<span className="value-label">Temperatur</span>
<span className="value-number">{current.temperature?.toFixed(1) || '-'}°C</span>
</div>
<div className="value-card">
<span className="value-label">Luftfeuchtigkeit</span>
<span className="value-number">{current.humidity || '-'}%</span>
</div>
<div className="value-card">
<span className="value-label">Luftdruck</span>
<span className="value-number">{current.pressure?.toFixed(1) || '-'} hPa</span>
</div>
<div className="value-card">
<span className="value-label">Wind</span>
<span className="value-number">{current.wind_speed?.toFixed(1) || '-'} km/h</span>
</div>
<div className="value-card">
<span className="value-label">Regen</span>
<span className="value-number">{current.rain?.toFixed(1) || '-'} mm</span>
</div>
</div>
{/* Charts Grid */}
<div className="charts-grid">
<div className="chart-container">
<h3>🌡 Temperatur</h3>
<div className="chart-wrapper">
<Line data={temperatureData} options={temperatureOptions} />
</div>
</div>
<div className="chart-container">
<h3>💧 Luftfeuchtigkeit</h3>
<div className="chart-wrapper">
<Line data={humidityData} options={humidityOptions} />
</div>
</div>
<div className="chart-container">
<h3>🌐 Luftdruck</h3>
<div className="chart-wrapper">
<Line data={pressureData} options={pressureOptions} />
</div>
</div>
<div className="chart-container">
<h3>🌧 Regen</h3>
<div className="chart-wrapper">
<Line data={rainData} options={rainOptions} />
</div>
</div>
<div className="chart-container">
<h3>💨 Windgeschwindigkeit</h3>
<div className="chart-wrapper">
<Line data={windSpeedData} options={windSpeedOptions} />
</div>
</div>
<div className="chart-container">
<h3>🧭 Windrichtung</h3>
<div className="chart-wrapper">
<Line data={windDirData} options={windDirOptions} />
</div>
</div>
</div>
</div>
)
}
export default WeatherDashboard

19
frontend/src/index.css Normal file
View File

@@ -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;
}

10
frontend/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)

17
frontend/vite.config.js Normal file
View File

@@ -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/, '')
}
}
}
})

View File

@@ -8,10 +8,14 @@ PROJECT="wetterstation"
echo "🔨 Building Docker images..." echo "🔨 Building Docker images..."
docker compose build collector docker compose build collector
docker compose build api
docker compose build frontend
echo "" echo ""
echo "📤 Pushing images to ${REGISTRY}..." echo "📤 Pushing images to ${REGISTRY}..."
docker compose push collector docker compose push collector
docker compose push api
docker compose push frontend
echo "" echo ""
echo "✅ Done! Images successfully pushed to ${REGISTRY}" echo "✅ Done! Images successfully pushed to ${REGISTRY}"

45
start-dev.sh Executable file
View File

@@ -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