Grafik 2 spaltig auch bein iFrame

Übertragen nach sternwarte funktioniert
This commit is contained in:
rxf
2026-02-08 15:37:44 +01:00
parent 9c5f985cab
commit 4b0ac3a652
9 changed files with 502 additions and 96 deletions

155
DEPLOY.md Normal file
View File

@@ -0,0 +1,155 @@
# Deployment auf externen Webserver
## Übersicht
Das System generiert alle 4 Minuten statische HTML-Dateien mit aktuellen Wetterdaten und lädt sie auf einen externen Webserver hoch.
## Voraussetzungen
1. **SSH-Zugang** zum Webserver (passwortlos mit SSH-Key empfohlen)
2. **rsync** installiert (auf macOS bereits vorhanden)
3. **Python 3** mit venv
4. **API läuft lokal** (localhost:8000)
## Setup
### 1. Python-Abhängigkeiten installieren
```bash
source .venv/bin/activate
pip install requests
```
### 2. Konfiguration anpassen
Bearbeiten Sie `generate-static.py`:
```python
REMOTE_SERVER = "user@ihr-server.de" # Ihr SSH-Zugang
REMOTE_PATH = "/var/www/html/wetterstation" # Pfad auf dem Server
```
### 3. SSH-Key Setup (einmalig)
Für automatisches Upload ohne Passwort-Eingabe:
```bash
# SSH-Key generieren (falls noch nicht vorhanden)
ssh-keygen -t ed25519
# Public Key auf Server kopieren
ssh-copy-id user@ihr-server.de
# Test
ssh user@ihr-server.de "echo 'Verbindung OK'"
```
### 4. Frontend für statische Nutzung vorbereiten
```bash
# App.jsx durch statische Version ersetzen
cp frontend/src/App-static.jsx frontend/src/App.jsx
```
### 5. Manueller Test
```bash
python generate-static.py
```
Dies sollte:
- Wetterdaten laden ✓
- Frontend bauen ✓
- Daten einbetten ✓
- Dateien hochladen ✓
### 6. Cronjob installieren
```bash
chmod +x setup-cronjob.sh
./setup-cronjob.sh
```
Der Cronjob läuft dann automatisch alle 4 Minuten.
## Zeitplan
Der Cronjob läuft zu folgenden Zeiten:
- 00:00, 00:04, 00:08, 00:12, ...
- 01:00, 01:04, 01:08, 01:12, ...
- etc.
Um ihn z.B. immer 1 Minute nach dem 5-Minuten-Schritt zu starten:
```cron
1,6,11,16,21,26,31,36,41,46,51,56 * * * * /pfad/zum/script
```
## Logs überwachen
```bash
# Live-Logs anzeigen
tail -f upload.log
# Letzte Uploads anzeigen
tail -20 upload.log
```
## iframe in bestehende Webseite einbauen
```html
<iframe
src="https://ihre-domain.de/wetterstation/"
width="100%"
height="1200"
frameborder="0"
style="border: none;">
</iframe>
```
## Fehlerbehebung
### Upload schlägt fehl
```bash
# SSH-Verbindung testen
ssh user@ihr-server.de
# rsync manuell testen
rsync -avz frontend/dist/ user@ihr-server.de:/var/www/html/wetterstation/
```
### API nicht erreichbar
```bash
# API-Status prüfen
curl http://localhost:8000/health
```
### Cronjob läuft nicht
```bash
# Cronjob-Log prüfen
tail -f upload.log
# Cronjobs anzeigen
crontab -l
# Script manuell ausführen
python generate-static.py
```
## Cronjob entfernen
```bash
crontab -e
# Zeile mit generate-static.py löschen
```
## Verzeichnisstruktur auf dem Server
Nach dem Upload sollte der Server folgende Struktur haben:
```
/var/www/html/wetterstation/
├── index.html (mit eingebetteten Daten)
├── assets/
│ ├── index-xxx.js
│ └── index-xxx.css
└── ...
```

View File

@@ -0,0 +1,59 @@
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)
useEffect(() => {
// Prüfe ob eingebettete Daten vorhanden sind
if (window.__WEATHER_DATA__) {
setWeatherData(window.__WEATHER_DATA__)
setLastUpdate(new Date())
setLoading(false)
} else {
setError('Keine Wetterdaten verfügbar')
setLoading(false)
}
}, [])
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>
</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

@@ -8,63 +8,16 @@ function App() {
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null) const [lastUpdate, setLastUpdate] = useState(null)
const fetchWeatherData = async () => { useEffect(() => {
try { // Prüfe ob eingebettete Daten vorhanden sind
const apiUrl = import.meta.env.VITE_API_URL || '/api' if (window.__WEATHER_DATA__) {
const response = await fetch(`${apiUrl}/weather/history?hours=24`) setWeatherData(window.__WEATHER_DATA__)
if (!response.ok) {
throw new Error('Fehler beim Laden der Daten')
}
const data = await response.json()
setWeatherData(data)
setLastUpdate(new Date()) setLastUpdate(new Date())
setError(null) setLoading(false)
} catch (err) { } else {
setError(err.message) setError('Keine Wetterdaten verfügbar')
console.error('Fehler beim Laden der Wetterdaten:', err)
} finally {
setLoading(false) 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) { if (loading) {
@@ -81,7 +34,6 @@ function App() {
<div className="error-container"> <div className="error-container">
<h2>Fehler beim Laden der Daten</h2> <h2>Fehler beim Laden der Daten</h2>
<p>{error}</p> <p>{error}</p>
<button onClick={fetchWeatherData}>Erneut versuchen</button>
</div> </div>
) )
} }

107
frontend/src/App.jsx_org 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

@@ -1,18 +1,19 @@
.dashboard { .dashboard {
width: 100%; width: 100%;
max-width: 795px;
} }
.current-values { .current-values {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem; gap: 0.75rem;
margin-bottom: 2rem; margin-bottom: 1.5rem;
} }
.value-card { .value-card {
background: white; background: white;
padding: 1.5rem; padding: 1rem;
border-radius: 12px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -20,13 +21,13 @@
} }
.value-label { .value-label {
font-size: 0.9rem; font-size: 0.8rem;
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.value-number { .value-number {
font-size: 2rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
color: #333; color: #333;
} }
@@ -34,47 +35,23 @@
.charts-grid { .charts-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 1.5rem; gap: 1rem;
} }
.chart-container { .chart-container {
background: white; background: white;
padding: 1.5rem; padding: 1rem;
border-radius: 12px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.chart-container h3 { .chart-container h3 {
margin-bottom: 1rem; margin-bottom: 0.75rem;
color: #333; color: #333;
font-size: 1.2rem; font-size: 1rem;
} }
.chart-wrapper { .chart-wrapper {
height: 300px; height: 250px;
position: relative; 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

@@ -93,7 +93,7 @@ const WeatherDashboard = ({ data }) => {
data: sortedData.map(item => item.temperature), data: sortedData.map(item => item.temperature),
borderColor: 'rgb(255, 99, 132)', borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)', backgroundColor: 'rgba(255, 99, 132, 0.1)',
fill: true, fill: 'start',
tension: 0.4, tension: 0.4,
} }
] ]
@@ -254,9 +254,11 @@ const WeatherDashboard = ({ data }) => {
label: 'Windrichtung (°)', label: 'Windrichtung (°)',
data: sortedData.map(item => item.wind_dir), data: sortedData.map(item => item.wind_dir),
borderColor: 'rgb(255, 205, 86)', borderColor: 'rgb(255, 205, 86)',
backgroundColor: 'rgba(255, 205, 86, 0.1)', backgroundColor: 'rgb(255, 205, 86)',
fill: true, pointRadius: 4,
tension: 0, pointHoverRadius: 6,
showLine: false,
fill: false,
} }
] ]
} }

View File

@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: './',
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: 3000,

112
generate-static.py Executable file
View File

@@ -0,0 +1,112 @@
#!/Users/rxf/Projekte/wetterstation/.venv/bin/python
"""
Generiert statische HTML-Dateien mit aktuellen Wetterdaten und lädt sie auf den Server hoch
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import requests
from datetime import datetime
# Konfiguration
API_URL = "http://localhost:8000"
FRONTEND_DIR = Path(__file__).parent / "frontend"
DIST_DIR = FRONTEND_DIR / "dist"
REMOTE_SERVER = "ssh-310927-rxf@sternwarte-welzheim.de" # SSH-Zugang zum Webserver
REMOTE_PATH = "webroot/wetter/wetterstation" # Pfad auf dem Webserver
def fetch_weather_data():
"""Holt aktuelle Wetterdaten von der API"""
try:
response = requests.get(f"{API_URL}/weather/history?hours=24", timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"Fehler beim Laden der Daten: {e}")
sys.exit(1)
def build_frontend():
"""Baut das Frontend"""
print("Baue Frontend...")
try:
subprocess.run(
["npm", "run", "build"],
cwd=FRONTEND_DIR,
check=True,
capture_output=True
)
print("✓ Frontend gebaut")
except subprocess.CalledProcessError as e:
print(f"Fehler beim Build: {e.stderr.decode()}")
sys.exit(1)
def inject_data_into_html(weather_data):
"""Fügt Wetterdaten in die index.html ein"""
index_file = DIST_DIR / "index.html"
if not index_file.exists():
print("index.html nicht gefunden!")
sys.exit(1)
with open(index_file, 'r', encoding='utf-8') as f:
html_content = f.read()
# Füge Daten als inline Script vor dem schließenden </body> Tag ein
data_script = f"""
<script>
// Eingebettete Wetterdaten - generiert am {datetime.now().isoformat()}
window.__WEATHER_DATA__ = {json.dumps(weather_data)};
</script>
</body>"""
html_content = html_content.replace('</body>', data_script)
with open(index_file, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"✓ Daten in HTML eingebettet ({len(weather_data)} Datensätze)")
def upload_to_server():
"""Lädt die Dateien per rsync auf den Server"""
print(f"Lade Dateien auf {REMOTE_SERVER}...")
try:
subprocess.run([
"rsync",
"-avz",
"--delete",
f"{DIST_DIR}/",
f"{REMOTE_SERVER}:{REMOTE_PATH}/"
], check=True)
print(f"✓ Upload erfolgreich")
except subprocess.CalledProcessError as e:
print(f"Fehler beim Upload: {e}")
sys.exit(1)
def main():
print(f"=== Wetterstation Static Generator ===")
print(f"Start: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
# 1. Daten von API holen
print("Lade Wetterdaten...")
weather_data = fetch_weather_data()
print(f"{len(weather_data)} Datensätze geladen\n")
# 2. Frontend bauen
build_frontend()
print()
# 3. Daten in HTML einbetten
inject_data_into_html(weather_data)
print()
# 4. Auf Server hochladen
upload_to_server()
print(f"\n✓ Fertig: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if __name__ == "__main__":
main()

41
setup-cronjob.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Setup Cronjob für automatisches Upload alle 4 Minuten
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/generate-static.py"
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
# Cronjob-Eintrag
CRON_ENTRY="*/4 * * * * $VENV_PYTHON $PYTHON_SCRIPT >> $SCRIPT_DIR/upload.log 2>&1"
echo "=== Cronjob Setup ==="
echo ""
echo "Dieser Cronjob wird alle 4 Minuten ausgeführt:"
echo "$CRON_ENTRY"
echo ""
echo "Möchten Sie den Cronjob jetzt installieren? (j/n)"
read -r response
if [[ "$response" =~ ^[Jj]$ ]]; then
# Prüfe ob Cronjob bereits existiert
if crontab -l 2>/dev/null | grep -q "$PYTHON_SCRIPT"; then
echo "Cronjob existiert bereits!"
else
# Füge Cronjob hinzu
(crontab -l 2>/dev/null; echo "$CRON_ENTRY") | crontab -
echo "✓ Cronjob installiert"
fi
echo ""
echo "Cronjobs:"
crontab -l | grep generate-static
echo ""
echo "Logs finden Sie in: $SCRIPT_DIR/upload.log"
else
echo "Abgebrochen"
echo ""
echo "Um den Cronjob manuell hinzuzufügen:"
echo "1. crontab -e"
echo "2. Folgende Zeile einfügen:"
echo " $CRON_ENTRY"
fi