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

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