V 1.4.0 Komplette Absicherung mit Hilfe von Claude
This commit is contained in:
@@ -1,41 +1,130 @@
|
||||
# Nginx-Konfiguration fuer das Frontend (Container).
|
||||
# TLS wird von Traefik vorne dran terminiert; dieser Server lauscht nur auf HTTP intern.
|
||||
|
||||
# Nginx-Version aus Headern und Fehlerseiten raus
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Docker DNS resolver für dynamische Service-Auflösung
|
||||
# Body-Limit (Frontend braucht keine grossen POSTs)
|
||||
client_max_body_size 1m;
|
||||
|
||||
# Docker DNS resolver fuer dynamische Service-Aufloesung
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# Gzip compression
|
||||
# Gzip
|
||||
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;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/x-javascript
|
||||
application/xml+rss
|
||||
application/json;
|
||||
|
||||
# API proxy (wird im Docker-Compose-Netzwerk aufgelöst)
|
||||
# ----------------------------------------------------------------- #
|
||||
# Security-Header — gelten fuer alle Antworten dieses Servers.
|
||||
# 'always' sorgt dafuer, dass sie auch bei 4xx/5xx ausgeliefert werden.
|
||||
# ----------------------------------------------------------------- #
|
||||
|
||||
# HSTS: ein Jahr, inkl. Subdomains. Wenn die Domain noch nicht zu 100%
|
||||
# auf HTTPS laeuft, kann der Wert auf "max-age=300" reduziert werden,
|
||||
# bis sicher ist, dass nichts mehr ueber HTTP geht. preload weglassen,
|
||||
# solange die Domain nicht in der Preload-Liste eingetragen werden soll.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# MIME-Sniffing aus
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Clickjacking-Schutz: keine Einbettung als iframe
|
||||
|
||||
# Referrer nur an gleiche Origin senden
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Browser-APIs deaktivieren, die das Frontend nicht benoetigt
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
|
||||
|
||||
# Content Security Policy — strict, keine externen Quellen
|
||||
# 'unsafe-inline' fuer style-src ist noetig, weil Highcharts inline-styles
|
||||
# fuer dynamische Diagramme setzt. script-src bleibt strikt.
|
||||
# TODO: http://test.sternwarte-welzheim.de entfernen, sobald der Test-Server
|
||||
# auf HTTPS umgestellt ist. Drei Stellen: server-Block + zwei locations.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# API-Proxy
|
||||
# ----------------------------------------------------------------- #
|
||||
location /api/ {
|
||||
set $upstream_api api:8000;
|
||||
proxy_pass http://$upstream_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
# Standard-Header
|
||||
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;
|
||||
|
||||
# Timeouts: lieber sichtbar fehlschlagen als ewig haengen
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 15s;
|
||||
proxy_read_timeout 15s;
|
||||
|
||||
# Wenn das Upstream tot ist, sofort 502 statt Retry-Loops
|
||||
proxy_next_upstream off;
|
||||
|
||||
# WebSockets/Upgrade-Pfad behalten, falls spaeter noch gebraucht
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
}
|
||||
|
||||
# Frontend routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
# ----------------------------------------------------------------- #
|
||||
# Statische Assets — lange Cache-Zeit, da mit Hash im Dateinamen
|
||||
# ----------------------------------------------------------------- #
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
|
||||
# nginx-Quirk: sobald ein add_header in einem location-Block steht,
|
||||
# werden ALLE add_header der server-Ebene ignoriert. Daher hier
|
||||
# alle Security-Header noch einmal explizit.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# Frontend-Routing (SPA)
|
||||
# ----------------------------------------------------------------- #
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# index.html selbst nicht aggressiv cachen, sonst sehen Nutzer
|
||||
# nach einem Deploy alte Asset-Hashes
|
||||
add_header Cache-Control "no-cache" always;
|
||||
|
||||
# Security-Header hier nochmal explizit (nginx-Quirk, s.o.)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' https://sternwarte-welzheim.de https://www.sternwarte-welzheim.de https://test.sternwarte-welzheim.de http://test.sternwarte-welzheim.de; base-uri 'self'; form-action 'self'; object-src 'none'" always;
|
||||
}
|
||||
|
||||
# Versteckte/Punktdateien blocken (z.B. .env, .git versehentlich im Build)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wetterstation-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,23 +1,91 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import WeatherDashboard from './components/WeatherDashboard'
|
||||
import './App.css'
|
||||
|
||||
// API-Basis-URL: in Dev direkt auf Backend, in Prod ueber Nginx-Proxy
|
||||
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
|
||||
|
||||
// 24-Stunden-URL fuer "Aktuell"-Anzeige (auch bei laengeren Zeitraeumen gebraucht)
|
||||
const CURRENT_URL = `${API_BASE}/weather/history?hours=24&limit=5000`
|
||||
|
||||
// JSON-Fetch-Helfer: liefert {ok, data} oder wirft bei Netzfehler.
|
||||
// Per signal kann der Request abgebrochen werden, wenn timeRange wechselt.
|
||||
async function fetchJson(url, signal) {
|
||||
const res = await fetch(url, { signal })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Bestimmt die URLs fuer den gewaehlten Zeitbereich.
|
||||
// Returns: { weatherUrl, rainUrl, needsCurrent }
|
||||
function buildUrls(timeRange) {
|
||||
// Custom-Range
|
||||
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
|
||||
const start = encodeURIComponent(timeRange.start)
|
||||
const end = encodeURIComponent(timeRange.end)
|
||||
const days = timeRange.days || 1
|
||||
const path = days >= 7 ? 'daily-aggregated-range' : 'hourly-aggregated-range'
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`,
|
||||
rainUrl: null, // TODO: Regen-Aggregation fuer Range implementieren
|
||||
needsCurrent: true,
|
||||
}
|
||||
}
|
||||
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/history?hours=24&limit=5000`,
|
||||
rainUrl: null,
|
||||
needsCurrent: false, // Hauptdaten SIND die aktuellen 24h-Daten
|
||||
}
|
||||
case '7d':
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=7`,
|
||||
rainUrl: `${API_BASE}/weather/rain-daily?days=7`,
|
||||
needsCurrent: true,
|
||||
}
|
||||
case '30d':
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=30`,
|
||||
rainUrl: `${API_BASE}/weather/rain-daily?days=30`,
|
||||
needsCurrent: true,
|
||||
}
|
||||
case '365d':
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/daily-aggregated?days=365`,
|
||||
rainUrl: `${API_BASE}/weather/rain-weekly?days=365`,
|
||||
needsCurrent: true,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
weatherUrl: `${API_BASE}/weather/history?hours=24`,
|
||||
rainUrl: null,
|
||||
needsCurrent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [weatherData, setWeatherData] = useState([])
|
||||
const [currentWeatherData, setCurrentWeatherData] = useState([]) // Immer die aktuellen 24h-Werte
|
||||
const [currentWeatherData, setCurrentWeatherData] = useState([])
|
||||
const [rainData, setRainData] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [lastUpdate, setLastUpdate] = useState(null)
|
||||
const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d', oder {type: 'custom', start, end, days}
|
||||
const [timeRange, setTimeRange] = useState('24h')
|
||||
const [showTable, setShowTable] = useState(false)
|
||||
|
||||
// Handler für Zeitbereich-Änderungen
|
||||
// Erster-Lade-Flag: nur beim allerersten Fetch zeigen wir den Spinner.
|
||||
// Bei spaeteren Re-Fetches (Auto-Refresh, Time-Range-Wechsel) bleiben die
|
||||
// alten Daten sichtbar, bis die neuen da sind — flackert weniger.
|
||||
const isInitialLoadRef = useRef(true)
|
||||
|
||||
const handleTimeRangeChange = (range, customParams) => {
|
||||
if (range === 'custom' && customParams) {
|
||||
const start = new Date(customParams.start)
|
||||
const end = new Date(customParams.end)
|
||||
const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24))
|
||||
const end = new Date(customParams.end)
|
||||
const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24))
|
||||
setTimeRange({ type: 'custom', start: customParams.start, end: customParams.end, days })
|
||||
} else {
|
||||
setTimeRange(range)
|
||||
@@ -25,109 +93,72 @@ function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Prüfe ob eingebettete Daten vorhanden sind (statischer Build)
|
||||
if (window.__WEATHER_DATA__ && timeRange === '24h') {
|
||||
setWeatherData(window.__WEATHER_DATA__)
|
||||
setCurrentWeatherData(window.__WEATHER_DATA__)
|
||||
setRainData([])
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// API-URLs basierend auf Zeitraum
|
||||
let weatherUrl, rainUrl
|
||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
|
||||
|
||||
// Benutzerdefinierter Zeitbereich
|
||||
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
|
||||
const start = encodeURIComponent(timeRange.start)
|
||||
const end = encodeURIComponent(timeRange.end)
|
||||
const days = timeRange.days || 1
|
||||
|
||||
if (days >= 7) {
|
||||
// >= 7 Tage: Tagesaggregation mit Min/Max verwenden
|
||||
weatherUrl = `${baseUrl}/weather/daily-aggregated-range?start=${start}&end=${end}`
|
||||
rainUrl = null // TODO: Regen-Aggregation für Range implementieren
|
||||
} else {
|
||||
// < 7 Tage: Stundenaggregation verwenden
|
||||
weatherUrl = `${baseUrl}/weather/hourly-aggregated-range?start=${start}&end=${end}`
|
||||
rainUrl = null
|
||||
}
|
||||
} else {
|
||||
// Vordefinierte Zeitbereiche
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
weatherUrl = `${baseUrl}/weather/history?hours=24&limit=5000`
|
||||
rainUrl = null
|
||||
break
|
||||
case '7d':
|
||||
weatherUrl = `${baseUrl}/weather/daily-with-minmax?days=7`
|
||||
rainUrl = `${baseUrl}/weather/rain-daily?days=7`
|
||||
break
|
||||
case '30d':
|
||||
weatherUrl = `${baseUrl}/weather/daily-with-minmax?days=30`
|
||||
rainUrl = `${baseUrl}/weather/rain-daily?days=30`
|
||||
break
|
||||
case '365d':
|
||||
weatherUrl = `${baseUrl}/weather/daily-aggregated?days=365`
|
||||
rainUrl = `${baseUrl}/weather/rain-weekly?days=365`
|
||||
break
|
||||
default:
|
||||
weatherUrl = `${baseUrl}/weather/history?hours=24`
|
||||
rainUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
// Wetterdaten laden
|
||||
const weatherResponse = await fetch(weatherUrl)
|
||||
if (!weatherResponse.ok) {
|
||||
throw new Error('API-Fehler: ' + weatherResponse.status)
|
||||
}
|
||||
const weatherDataResult = await weatherResponse.json()
|
||||
setWeatherData(weatherDataResult)
|
||||
|
||||
// Immer die aktuellen 24h-Daten für "Aktuell"-Anzeige laden
|
||||
if (timeRange !== '24h') {
|
||||
const currentUrl = `${baseUrl}/weather/history?hours=24&limit=5000`
|
||||
const currentResponse = await fetch(currentUrl)
|
||||
if (currentResponse.ok) {
|
||||
const currentDataResult = await currentResponse.json()
|
||||
setCurrentWeatherData(currentDataResult)
|
||||
}
|
||||
} else {
|
||||
setCurrentWeatherData(weatherDataResult)
|
||||
}
|
||||
|
||||
// Regendaten laden (falls separater Endpunkt)
|
||||
if (rainUrl) {
|
||||
const rainResponse = await fetch(rainUrl)
|
||||
if (rainResponse.ok) {
|
||||
const rainDataResult = await rainResponse.json()
|
||||
setRainData(rainDataResult)
|
||||
}
|
||||
} else {
|
||||
setRainData([])
|
||||
}
|
||||
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
}
|
||||
// Statische Daten: kein Fetch noetig
|
||||
if (window.__WEATHER_DATA__ && timeRange === '24h') {
|
||||
setWeatherData(window.__WEATHER_DATA__)
|
||||
setCurrentWeatherData(window.__WEATHER_DATA__)
|
||||
setRainData([])
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const fetchData = async () => {
|
||||
if (isInitialLoadRef.current) setLoading(true)
|
||||
|
||||
const { weatherUrl, rainUrl, needsCurrent } = buildUrls(timeRange)
|
||||
|
||||
// Alle drei Requests parallel starten (statt sequentiell wie vorher).
|
||||
// allSettled, damit ein Fehler bei rain/current die Hauptdaten nicht blockiert.
|
||||
const requests = [
|
||||
fetchJson(weatherUrl, controller.signal), // [0] weather - Pflicht
|
||||
needsCurrent ? fetchJson(CURRENT_URL, controller.signal) : null, // [1] current - optional
|
||||
rainUrl ? fetchJson(rainUrl, controller.signal) : null, // [2] rain - optional
|
||||
]
|
||||
const results = await Promise.allSettled(requests.map(p => p ?? Promise.resolve(null)))
|
||||
|
||||
// AbortError ignorieren — passiert, wenn timeRange waehrend des Requests
|
||||
// gewechselt hat. Der nachfolgende Effekt-Lauf macht den richtigen Fetch.
|
||||
const aborted = results.some(
|
||||
r => r.status === 'rejected' && r.reason?.name === 'AbortError'
|
||||
)
|
||||
if (aborted) return
|
||||
|
||||
// Hauptdaten-Fehler ist fatal; ohne die zeigen wir nichts an.
|
||||
if (results[0].status === 'rejected') {
|
||||
setError(results[0].reason?.message || 'Unbekannter Fehler')
|
||||
setLoading(false)
|
||||
isInitialLoadRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const weatherResult = results[0].value
|
||||
const currentResult = results[1].status === 'fulfilled' ? results[1].value : null
|
||||
const rainResult = results[2].status === 'fulfilled' ? results[2].value : null
|
||||
|
||||
setError(null)
|
||||
setWeatherData(weatherResult)
|
||||
// Wenn 24h gewaehlt ist, sind weather und current dieselben Daten
|
||||
setCurrentWeatherData(needsCurrent ? (currentResult ?? []) : weatherResult)
|
||||
setRainData(rainResult ?? [])
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
isInitialLoadRef.current = false
|
||||
}
|
||||
|
||||
fetchData()
|
||||
|
||||
// Automatisches Update alle 5 Minuten (nur für 24h und ohne statische Daten)
|
||||
|
||||
// Auto-Refresh nur bei 24h, nur wenn keine statischen Daten
|
||||
let interval = null
|
||||
if (!window.__WEATHER_DATA__ && timeRange === '24h') {
|
||||
const interval = setInterval(fetchData, 5 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
interval = setInterval(fetchData, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [timeRange])
|
||||
|
||||
@@ -151,19 +182,19 @@ function App() {
|
||||
|
||||
// Aktuelle Zeit formatieren
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
const dateStr = now.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
const timeStr = now.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
const timeStr = now.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
// TODO: Sonnenauf-/untergang und Mondphase berechnen
|
||||
// Aktuell Platzhalter - benötigt Bibliothek wie 'suncalc'
|
||||
// Aktuell Platzhalter - benoetigt Bibliothek wie 'suncalc'
|
||||
const sunrise = "06:45"
|
||||
const sunset = "18:30"
|
||||
const moonPhase = "abnehmend 50%"
|
||||
@@ -179,10 +210,10 @@ function App() {
|
||||
Sonnen-Aufgang: {sunrise} - Untergang: {sunset} Mond-Phase: {moonPhase}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<main className="app-main">
|
||||
<WeatherDashboard
|
||||
data={weatherData}
|
||||
<WeatherDashboard
|
||||
data={weatherData}
|
||||
currentData={currentWeatherData}
|
||||
rainData={rainData}
|
||||
timeRange={timeRange}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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
|
||||
@@ -650,7 +650,6 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
|
||||
const customDays = isCustomRange ? (timeRange.days || 1) : 0
|
||||
const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365)
|
||||
console.log("Gust: ", hideGusts)
|
||||
const windSpeedSeries = {
|
||||
name: 'Windgeschwindigkeit',
|
||||
data: sortedData
|
||||
|
||||
Reference in New Issue
Block a user