Compare commits
21 Commits
035c21ba23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa528ff5b | |||
| 9754ffabaa | |||
| 9c2855fa98 | |||
| 4f89db49b6 | |||
| dfdd4943e1 | |||
| 4abaf5ee17 | |||
| 779a76dd92 | |||
| 4499313baa | |||
| 77f31d0509 | |||
| 4695419565 | |||
| eb8f609876 | |||
| a07a2f54a0 | |||
| 795835043a | |||
| ebcca2c7d8 | |||
| 7deccea768 | |||
| 6d8ff752f5 | |||
| 634a6d31a4 | |||
| cc663487e0 | |||
| fc35e9b6e7 | |||
| 6f7673358d | |||
| 41dbf46881 |
@@ -70,3 +70,6 @@ frontend/build/
|
||||
|
||||
# PostgreSQL Data
|
||||
postgres_data/
|
||||
|
||||
#Backups
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"alert_active": false,
|
||||
"last_alert_sent": null,
|
||||
"last_daily_report": "2026-05-03"
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wetterstation – Bedienungsanleitung</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a2e;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a3a5c 0%, #2d6a9f 100%);
|
||||
color: #fff;
|
||||
padding: 3rem 2rem 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
header h1 { font-size: 2.2rem; font-weight: 700; letter-spacing: -0.5px; }
|
||||
header p { margin-top: .5rem; font-size: 1.05rem; opacity: .85; }
|
||||
|
||||
/* ── Layout ── */
|
||||
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||
|
||||
/* ── Table of Contents ── */
|
||||
.toc {
|
||||
background: #fff;
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 10px;
|
||||
padding: 1.4rem 1.8rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.toc h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: .08em; color: #5a6a7a; margin-bottom: .7rem; }
|
||||
.toc ol { padding-left: 1.4rem; }
|
||||
.toc li { margin: .25rem 0; }
|
||||
.toc a { color: #2d6a9f; text-decoration: none; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Sections ── */
|
||||
section {
|
||||
background: #fff;
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 10px;
|
||||
padding: 1.8rem 2rem;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
section h2 {
|
||||
font-size: 1.35rem;
|
||||
color: #1a3a5c;
|
||||
border-bottom: 2px solid #e4eaf2;
|
||||
padding-bottom: .5rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
section h3 {
|
||||
font-size: 1.05rem;
|
||||
color: #2d6a9f;
|
||||
margin: 1.3rem 0 .5rem;
|
||||
}
|
||||
p { margin-bottom: .8rem; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ── Buttons – visual replicas ── */
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .35rem .85rem;
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid #2d6a9f;
|
||||
color: #2d6a9f;
|
||||
background: #fff;
|
||||
}
|
||||
.btn.active { background: #2d6a9f; color: #fff; }
|
||||
|
||||
/* ── Charts grid preview ── */
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: .8rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.chart-card {
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 8px;
|
||||
padding: .8rem 1rem;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
.chart-card .icon { font-size: 1.4rem; }
|
||||
.chart-card strong { display: block; margin-top: .3rem; font-size: .92rem; color: #1a3a5c; }
|
||||
.chart-card span { font-size: .82rem; color: #5a6a7a; }
|
||||
|
||||
/* ── Info boxes ── */
|
||||
.info-box {
|
||||
background: #eef5ff;
|
||||
border-left: 4px solid #2d6a9f;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
.tip-box {
|
||||
background: #f0faf3;
|
||||
border-left: 4px solid #27ae60;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
.warn-box {
|
||||
background: #fffbea;
|
||||
border-left: 4px solid #f0a500;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
table { width: 100%; border-collapse: collapse; font-size: .93rem; margin: 1rem 0; }
|
||||
th, td { padding: .55rem .8rem; border: 1px solid #dce3ec; text-align: left; }
|
||||
th { background: #eef2f8; font-weight: 600; color: #1a3a5c; }
|
||||
tr:nth-child(even) td { background: #f9fbfd; }
|
||||
|
||||
/* ── Trend arrows ── */
|
||||
.trend-table td:first-child { font-size: 1.1rem; text-align: center; }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
text-align: center;
|
||||
color: #8a9ab0;
|
||||
font-size: .85rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header h1 { font-size: 1.6rem; }
|
||||
.page { padding: 1.2rem 1rem 3rem; }
|
||||
section { padding: 1.2rem 1.1rem; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; font-size: 12pt; }
|
||||
header { background: #1a3a5c !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.btn.active { background: #2d6a9f !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>🌤️ Wetterstation – Bedienungsanleitung</h1>
|
||||
<p>Dashboard auf Basis einer Davis VantagePro</p>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Table of Contents -->
|
||||
<nav class="toc">
|
||||
<h2>Inhalt</h2>
|
||||
<ol>
|
||||
<li><a href="#ueberblick">Überblick</a></li>
|
||||
<li><a href="#zeitraum">Zeitraum-Auswahl</a></li>
|
||||
<li><a href="#aktuell">Aktuelle Werte</a></li>
|
||||
<li><a href="#diagramme">Diagramme im Überblick</a></li>
|
||||
<li><a href="#charts-detail">Diagramme im Detail</a></li>
|
||||
<li><a href="#tabelle">Tabellenansicht</a></li>
|
||||
<li><a href="#benutzerdefiniert">Benutzerdefinierter Zeitbereich</a></li>
|
||||
<li><a href="#refresh">Automatische Aktualisierung</a></li>
|
||||
<li><a href="#drucken">Drucken</a></li>
|
||||
<li><a href="#hinweise">Hinweise & Grenzen</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 1 Überblick -->
|
||||
<section id="ueberblick">
|
||||
<h2>1. Überblick</h2>
|
||||
<p>
|
||||
Das Wetterstation-Dashboard zeigt die Messwerte einer <strong>Davis VantagePro</strong>
|
||||
in interaktiven Diagrammen an. Alle Daten werden aus einer PostgreSQL-Datenbank
|
||||
bezogen und im Browser aufbereitet.
|
||||
</p>
|
||||
<p>
|
||||
Die Oberfläche besteht aus drei Bereichen:
|
||||
</p>
|
||||
<ol style="padding-left:1.4rem;">
|
||||
<li><strong>Zeitraum-Navigation</strong> (oben) – wählt den dargestellten Zeitraum.</li>
|
||||
<li><strong>Diagramme / Tabelle</strong> (Mitte) – zeigt die Wetterdaten an.</li>
|
||||
<li><strong>Fußzeile</strong> (unten) – zeigt Version, Build-Datum und Kontakt.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- 2 Zeitraum-Auswahl -->
|
||||
<section id="zeitraum">
|
||||
<h2>2. Zeitraum-Auswahl</h2>
|
||||
<p>
|
||||
In der Navigationsleiste am oberen Rand können Sie den Darstellungszeitraum wählen:
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<span class="btn active">24 Stunden</span>
|
||||
<span class="btn">7 Tage</span>
|
||||
<span class="btn">30 Tage</span>
|
||||
<span class="btn">365 Tage</span>
|
||||
<span class="btn">Bereich</span>
|
||||
<span class="btn">Tabelle</span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Schaltfläche</th><th>Dargestellter Zeitraum</th><th>Auflösung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>24 Stunden</strong></td><td>Letzte 24 Stunden ab jetzt</td><td>Einzelmessungen (~5 min)</td></tr>
|
||||
<tr><td><strong>7 Tage</strong></td><td>Letzte 7 Tage ab heute</td><td>Tagesmittel / Tages-Min+Max</td></tr>
|
||||
<tr><td><strong>30 Tage</strong></td><td>Letzte 30 Tage ab heute</td><td>Tagesmittel / Tages-Min+Max</td></tr>
|
||||
<tr><td><strong>365 Tage</strong></td><td>Letzte 365 Tage ab heute</td><td>Tagesmittel, Regen pro Woche</td></tr>
|
||||
<tr><td><strong>Bereich</strong></td><td>Frei wählbar (max. 1 Jahr)</td><td>Stundenmittel (< 7 Tage) oder Tagesmittel (≥ 7 Tage)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-box">
|
||||
Der aktuell aktive Zeitraum wird unterhalb der Navigationsleiste als Text angezeigt,
|
||||
z. B. <em>„Die letzten 24 Stunden"</em> oder
|
||||
<em>„01.01.2026 00:00 – 31.01.2026 23:59"</em>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 Aktuelle Werte -->
|
||||
<section id="aktuell">
|
||||
<h2>3. Aktuelle Werte</h2>
|
||||
<p>
|
||||
Über jedem Diagramm wird der <strong>aktuellste Messwert</strong> angezeigt
|
||||
(grauer Hinweistext, z. B. <em>„Aktuell: 18,4 °C"</em>).
|
||||
Dieser Wert stammt immer aus den letzten 24 Stunden – unabhängig vom gewählten
|
||||
Zeitraum.
|
||||
</p>
|
||||
<p>
|
||||
Unterhalb jedes Diagramms erscheinen die <strong>Minimum- und Maximumwerte</strong>
|
||||
des gewählten Zeitraums mit dem zugehörigen Zeitpunkt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 4 Diagramme im Überblick -->
|
||||
<section id="diagramme">
|
||||
<h2>4. Diagramme im Überblick</h2>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌡️</div>
|
||||
<strong>Temperatur</strong>
|
||||
<span>°C</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌐</div>
|
||||
<strong>Luftdruck</strong>
|
||||
<span>hPa</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">💧</div>
|
||||
<strong>Luftfeuchtigkeit</strong>
|
||||
<span>%</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌧️</div>
|
||||
<strong>Regen</strong>
|
||||
<span>mm / mm/h</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🧭</div>
|
||||
<strong>Windrichtung</strong>
|
||||
<span>°</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">💨</div>
|
||||
<strong>Wind & Böen</strong>
|
||||
<span>km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Alle Diagramme sind interaktiv: Fahren Sie mit der Maus über eine Kurve,
|
||||
um den genauen Messwert und Zeitpunkt als Tooltip anzuzeigen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 5 Diagramme im Detail -->
|
||||
<section id="charts-detail">
|
||||
<h2>5. Diagramme im Detail</h2>
|
||||
|
||||
<h3>🌡️ Temperatur</h3>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> wird der Temperaturverlauf als Flächendiagramm dargestellt.
|
||||
Bei längeren Zeiträumen (7 d, 30 d, 365 d, benutzerdefiniert ≥ 7 Tage) werden
|
||||
<strong>Tages-Minimum und -Maximum</strong> als Band angezeigt, sodass die
|
||||
tägliche Schwankungsbreite sofort erkennbar ist.
|
||||
</p>
|
||||
|
||||
<h3>🌐 Luftdruck</h3>
|
||||
<p>
|
||||
Zeigt den barometrischen Druck in hPa. Im Diagrammtitel erscheint ein
|
||||
<strong>Trendpfeil</strong>, der die Druckentwicklung der letzten Stunden anzeigt:
|
||||
</p>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Pfeil</th><th>Bedeutung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>⬇⬇</td><td>Fällt schnell (–60)</td></tr>
|
||||
<tr><td>⬇</td><td>Fällt langsam (–20)</td></tr>
|
||||
<tr><td>→</td><td>Stabil (0)</td></tr>
|
||||
<tr><td>⬆</td><td>Steigt langsam (+20)</td></tr>
|
||||
<tr><td>⬆⬆</td><td>Steigt schnell (+60)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>💧 Luftfeuchtigkeit</h3>
|
||||
<p>Relative Luftfeuchtigkeit in Prozent als Flächendiagramm.</p>
|
||||
|
||||
<h3>🌧️ Regen</h3>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> werden zwei Kurven überlagert:
|
||||
</p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li><strong>Regen (mm)</strong> – kumulierter Regenfall als Fläche.</li>
|
||||
<li><strong>Regenrate (mm/h)</strong> – aktuelle Intensität als gestrichelte Linie.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong>
|
||||
als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp:</strong> Im 24h-Modus ist eine Legende eingeblendet,
|
||||
die Regen und Regenrate unterscheidet.
|
||||
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
|
||||
</div>
|
||||
|
||||
<h3>🧭 Windrichtung</h3>
|
||||
<p>
|
||||
Die Windrichtung wird als <strong>Punktewolke</strong> (Streudiagramm) angezeigt.
|
||||
Die Y-Achse zeigt die Himmelsrichtungen von 0° bis 360°:
|
||||
N = 0°/360°, NO = 45°, O = 90°, SO = 135°, S = 180°, SW = 225°, W = 270°, NW = 315°.
|
||||
</p>
|
||||
|
||||
<h3>💨 Wind & Böen</h3>
|
||||
<p>
|
||||
Zeigt <strong>Windgeschwindigkeit</strong> (lila) und <strong>Böen</strong> (orange)
|
||||
in km/h. Bei 365 Tagen und benutzerdefinierten Zeiträumen über einem Jahr
|
||||
werden Böen ausgeblendet, da keine aussagekräftigen Tagesmaxima vorliegen.
|
||||
Unterhalb des Diagramms erscheint die maximale Böe des gewählten Zeitraums
|
||||
mit Zeitpunkt.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp:</strong> Im Wind-Diagramm ist eine Legende eingeblendet,
|
||||
die Windgeschwindigkeit und Böen unterscheidet.
|
||||
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 Tabellenansicht -->
|
||||
<section id="tabelle">
|
||||
<h2>6. Tabellenansicht</h2>
|
||||
<p>
|
||||
Klicken Sie auf die Schaltfläche <span class="btn">Tabelle</span>,
|
||||
um statt der Diagramme eine <strong>tabellarische Tagesübersicht</strong>
|
||||
anzuzeigen. Ein Klick auf <span class="btn active">Grafik</span>
|
||||
kehrt zur Diagrammansicht zurück.
|
||||
</p>
|
||||
<p>Die Tabelle enthält je Tag:</p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li>Temperatur-Minimum und -Maximum (°C)</li>
|
||||
<li>Luftfeuchtigkeit-Minimum und -Maximum (%)</li>
|
||||
<li>Luftdruck-Minimum und -Maximum (hPa)</li>
|
||||
<li>Regen gesamt (mm)</li>
|
||||
<li>Maximale Windböe (km/h)</li>
|
||||
</ul>
|
||||
<div class="info-box">
|
||||
Im 24h-Modus werden die vorliegenden Einzelmessungen automatisch
|
||||
pro Tag aggregiert, sodass immer eine Tageszeile erscheint.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 Benutzerdefinierter Zeitbereich -->
|
||||
<section id="benutzerdefiniert">
|
||||
<h2>7. Benutzerdefinierter Zeitbereich</h2>
|
||||
<p>
|
||||
Mit der Schaltfläche <span class="btn">Bereich</span> öffnet sich ein Dialog,
|
||||
in dem Sie ein <strong>Start- und Enddatum</strong> frei eingeben können.
|
||||
</p>
|
||||
<p><strong>Regeln:</strong></p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li>Das Enddatum muss nach dem Startdatum liegen.</li>
|
||||
<li>Der maximale Zeitraum beträgt <strong>365 Tage</strong>.</li>
|
||||
</ul>
|
||||
<p><strong>Darstellungsauflösung:</strong></p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li><strong>< 7 Tage:</strong> Stundenmittelwerte, X-Achse mit Datum und Uhrzeit.</li>
|
||||
<li><strong>≥ 7 Tage:</strong> Tagesmittel- bzw. Min/Max-Werte, X-Achse mit Datum.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Der zuletzt gewählte benutzerdefinierte Zeitbereich wird im Browser
|
||||
(<code>localStorage</code>) gespeichert und beim nächsten Öffnen des
|
||||
Dialogs vorausgefüllt.
|
||||
</p>
|
||||
<div class="warn-box">
|
||||
<strong>Hinweis:</strong> Schließen Sie den Dialog mit <em>Abbrechen</em>,
|
||||
um ohne Änderung zurückzukehren. Ein Klick auf den dunklen Hintergrund
|
||||
schließt den Dialog ebenfalls.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 Automatische Aktualisierung -->
|
||||
<section id="refresh">
|
||||
<h2>8. Automatische Aktualisierung</h2>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> werden die Daten automatisch alle
|
||||
<strong>5 Minuten</strong> neu geladen. Dabei bleibt die aktuelle
|
||||
Ansicht sichtbar – die Seite flackert nicht.
|
||||
</p>
|
||||
<p>
|
||||
Bei allen anderen Zeiträumen (7 d, 30 d, 365 d, benutzerdefiniert)
|
||||
findet <strong>kein automatisches Neuladen</strong> statt.
|
||||
Laden Sie die Seite manuell neu (F5 / ⌘R), um aktuelle Daten
|
||||
für diese Zeiträume zu erhalten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 9 Drucken -->
|
||||
<section id="drucken">
|
||||
<h2>9. Drucken</h2>
|
||||
<p>
|
||||
In der <strong>Tabellenansicht</strong> erscheint oben links die Schaltfläche
|
||||
<em>🖨️ Drucken</em>. Ein Klick darauf öffnet den Druckdialog des Browsers.
|
||||
</p>
|
||||
<p>
|
||||
Die Schaltflächen-Leiste und der Drucken-Button werden beim Ausdruck
|
||||
automatisch ausgeblendet, sodass nur die Tabelle erscheint.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp für PDF-Export:</strong> Wählen Sie im Druckdialog
|
||||
„Als PDF speichern", um die Tabelle als PDF-Datei zu exportieren.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 Hinweise & Grenzen -->
|
||||
<section id="hinweise">
|
||||
<h2>10. Hinweise & Grenzen</h2>
|
||||
<table>
|
||||
<thead><tr><th>Thema</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Messsystem</strong></td>
|
||||
<td>Davis VantagePro Wetterstation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Datenbank</strong></td>
|
||||
<td>PostgreSQL, Tabelle <code>weather_data</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Messintervall</strong></td>
|
||||
<td>ca. 5 Minuten (Rohwerte); längere Zeiträume werden serverseitig aggregiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Max. Zeitraum</strong></td>
|
||||
<td>365 Tage bei benutzerdefiniertem Bereich</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Datenlücken</strong></td>
|
||||
<td>Ausfälle der Station oder des Collectors erscheinen als Unterbrechung in den Kurven</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Browserkompatibilität</strong></td>
|
||||
<td>Alle modernen Browser (Chrome, Firefox, Safari, Edge) in aktueller Version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Mobil</strong></td>
|
||||
<td>Die Oberfläche ist für Mobilgeräte optimiert; Zeitraum-Buttons zeigen Kurzbezeichnungen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Zeitzone</strong></td>
|
||||
<td>Alle Zeitangaben in lokaler Browserzeit (keine UTC-Umrechnung)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Wetterstation Dashboard · Stand: Mai 2026</p>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -289,6 +289,7 @@ Die aggregierten Endpunkte sind optimiert für Langzeit-Visualisierungen und red
|
||||
---
|
||||
|
||||
#### `GET /weather/rain-weekly`
|
||||
|
||||
**Gibt wöchentliche Regensummen zurück (Woche = Mo-So)**
|
||||
|
||||
**Query Parameter:**
|
||||
|
||||
+60
-25
@@ -270,11 +270,17 @@ async def get_weather_statistics(
|
||||
AVG(pressure) as avg_pressure,
|
||||
AVG(wind_speed * 1.60934) as avg_wind_speed,
|
||||
MAX(wind_gust * 1.60934) as max_wind_gust,
|
||||
SUM(rain) as total_rain,
|
||||
(SELECT COALESCE(SUM(daily_max), 0)
|
||||
FROM (
|
||||
SELECT MAX(rain) as daily_max
|
||||
FROM weather_data d2
|
||||
WHERE d2.datetime >= NOW() - make_interval(hours => %s)
|
||||
GROUP BY DATE(d2.datetime)
|
||||
) sub) as total_rain,
|
||||
COUNT(*) as data_points
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(hours => %s)
|
||||
""", (hours,))
|
||||
""", (hours, hours))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if not result or result['data_points'] == 0:
|
||||
@@ -300,7 +306,7 @@ async def get_daily_statistics(
|
||||
AVG(pressure) as avg_pressure,
|
||||
AVG(wind_speed * 1.60934) as avg_wind_speed,
|
||||
MAX(wind_gust * 1.60934) as max_wind_gust,
|
||||
SUM(rain) as total_rain,
|
||||
MAX(rain) as total_rain,
|
||||
COUNT(*) as data_points
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
@@ -387,7 +393,7 @@ async def get_hourly_aggregated_data(
|
||||
AVG(wind_speed * 1.60934) as wind_speed,
|
||||
MAX(wind_gust * 1.60934) as wind_gust,
|
||||
AVG(wind_dir) as wind_dir,
|
||||
AVG(rain) as rain,
|
||||
MAX(rain) as rain,
|
||||
AVG(rain_rate) as rain_rate,
|
||||
MAX(received_at) as received_at
|
||||
FROM weather_data
|
||||
@@ -408,28 +414,35 @@ async def get_daily_aggregated_data(
|
||||
"""Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel mit Min/Max-Temperaturen)"""
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SELECT
|
||||
date_trunc('day', datetime) as datetime,
|
||||
AVG(temperature)::float as temperature,
|
||||
MIN(temperature)::float as min_temperature,
|
||||
MAX(temperature)::float as max_temperature,
|
||||
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
|
||||
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
|
||||
ROUND(AVG(humidity))::int as humidity,
|
||||
MIN(humidity)::int as min_humidity,
|
||||
MAX(humidity)::int as max_humidity,
|
||||
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
|
||||
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
|
||||
AVG(pressure)::float as pressure,
|
||||
MIN(pressure)::float as min_pressure,
|
||||
MAX(pressure)::float as max_pressure,
|
||||
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
|
||||
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
|
||||
AVG(wind_speed * 1.60934)::float as wind_speed,
|
||||
MAX(wind_gust * 1.60934)::float as wind_gust,
|
||||
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
|
||||
AVG(wind_dir)::float as wind_dir,
|
||||
SUM(rain)::float as total_rain
|
||||
MAX(rain)::float as total_rain
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
GROUP BY date_trunc('day', datetime)
|
||||
ORDER BY datetime ASC
|
||||
""", (days,))
|
||||
results = cursor.fetchall()
|
||||
|
||||
|
||||
return [dict(row) for row in results]
|
||||
|
||||
|
||||
@@ -441,28 +454,35 @@ async def get_daily_with_minmax_data(
|
||||
"""Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen zurück"""
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SELECT
|
||||
date_trunc('day', datetime) as datetime,
|
||||
AVG(temperature)::float as temperature,
|
||||
MIN(temperature)::float as min_temperature,
|
||||
MAX(temperature)::float as max_temperature,
|
||||
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
|
||||
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
|
||||
ROUND(AVG(humidity))::int as humidity,
|
||||
MIN(humidity)::int as min_humidity,
|
||||
MAX(humidity)::int as max_humidity,
|
||||
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
|
||||
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
|
||||
AVG(pressure)::float as pressure,
|
||||
MIN(pressure)::float as min_pressure,
|
||||
MAX(pressure)::float as max_pressure,
|
||||
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
|
||||
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
|
||||
AVG(wind_speed * 1.60934)::float as wind_speed,
|
||||
MAX(wind_gust * 1.60934)::float as wind_gust,
|
||||
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
|
||||
AVG(wind_dir)::float as wind_dir,
|
||||
SUM(rain)::float as total_rain
|
||||
MAX(rain)::float as total_rain
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
GROUP BY date_trunc('day', datetime)
|
||||
ORDER BY datetime ASC
|
||||
""", (days,))
|
||||
results = cursor.fetchall()
|
||||
|
||||
|
||||
return [dict(row) for row in results]
|
||||
|
||||
|
||||
@@ -474,9 +494,9 @@ async def get_daily_rain_data(
|
||||
"""Gibt tägliche Regensummen zurück"""
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SELECT
|
||||
date_trunc('day', datetime) as date,
|
||||
SUM(rain) as total_rain
|
||||
MAX(rain) as total_rain
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
GROUP BY date_trunc('day', datetime)
|
||||
@@ -497,21 +517,29 @@ async def get_weekly_rain_data(
|
||||
# Bei 365 Tagen: alle verfügbaren Daten zurückgeben
|
||||
if days >= 365:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
date_trunc('week', datetime) as week_start,
|
||||
SUM(rain) as total_rain
|
||||
FROM weather_data
|
||||
GROUP BY date_trunc('week', datetime)
|
||||
SELECT
|
||||
date_trunc('week', day) as week_start,
|
||||
SUM(daily_rain) as total_rain
|
||||
FROM (
|
||||
SELECT DATE(datetime) as day, MAX(rain) as daily_rain
|
||||
FROM weather_data
|
||||
GROUP BY DATE(datetime)
|
||||
) sub
|
||||
GROUP BY date_trunc('week', day)
|
||||
ORDER BY week_start ASC
|
||||
""")
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
date_trunc('week', datetime) as week_start,
|
||||
SUM(rain) as total_rain
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
GROUP BY date_trunc('week', datetime)
|
||||
SELECT
|
||||
date_trunc('week', day) as week_start,
|
||||
SUM(daily_rain) as total_rain
|
||||
FROM (
|
||||
SELECT DATE(datetime) as day, MAX(rain) as daily_rain
|
||||
FROM weather_data
|
||||
WHERE datetime >= NOW() - make_interval(days => %s)
|
||||
GROUP BY DATE(datetime)
|
||||
) sub
|
||||
GROUP BY date_trunc('week', day)
|
||||
ORDER BY week_start ASC
|
||||
""", (days,))
|
||||
results = cursor.fetchall()
|
||||
@@ -561,21 +589,28 @@ async def get_daily_aggregated_range(
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SELECT
|
||||
date_trunc('day', datetime) as datetime,
|
||||
AVG(temperature)::float as temperature,
|
||||
MIN(temperature)::float as min_temperature,
|
||||
MAX(temperature)::float as max_temperature,
|
||||
(array_agg(datetime ORDER BY temperature ASC NULLS LAST))[1] as min_temperature_time,
|
||||
(array_agg(datetime ORDER BY temperature DESC NULLS LAST))[1] as max_temperature_time,
|
||||
ROUND(AVG(humidity))::int as humidity,
|
||||
MIN(humidity)::int as min_humidity,
|
||||
MAX(humidity)::int as max_humidity,
|
||||
(array_agg(datetime ORDER BY humidity ASC NULLS LAST))[1] as min_humidity_time,
|
||||
(array_agg(datetime ORDER BY humidity DESC NULLS LAST))[1] as max_humidity_time,
|
||||
AVG(pressure)::float as pressure,
|
||||
MIN(pressure)::float as min_pressure,
|
||||
MAX(pressure)::float as max_pressure,
|
||||
(array_agg(datetime ORDER BY pressure ASC NULLS LAST))[1] as min_pressure_time,
|
||||
(array_agg(datetime ORDER BY pressure DESC NULLS LAST))[1] as max_pressure_time,
|
||||
AVG(wind_speed * 1.60934)::float as wind_speed,
|
||||
MAX(wind_gust * 1.60934)::float as wind_gust,
|
||||
(array_agg(datetime ORDER BY wind_gust DESC NULLS LAST))[1] as max_wind_gust_time,
|
||||
AVG(wind_dir)::float as wind_dir,
|
||||
SUM(rain)::float as total_rain
|
||||
MAX(rain)::float as total_rain
|
||||
FROM weather_data
|
||||
WHERE datetime BETWEEN %s AND %s
|
||||
GROUP BY date_trunc('day', datetime)
|
||||
|
||||
Binary file not shown.
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wetterserver-Monitor: Prüft alle 5 Minuten ob Wetterdaten ankommen.
|
||||
- Alert-E-Mail wenn >15 Minuten keine Daten (Wiederholung nach 2h)
|
||||
- Tägliche Status-E-Mail zur Bestätigung dass alles läuft
|
||||
|
||||
Cron-Empfehlung: alle 5 Minuten
|
||||
*/5 * * * * /pfad/zu/.venv/bin/python /pfad/zu/check_wetterserver.py >> /pfad/zu/monitor.log 2>&1
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ENV_FILE = SCRIPT_DIR / ".env"
|
||||
STATE_FILE = SCRIPT_DIR / ".monitoring_state.json"
|
||||
|
||||
API_URL = "https://stwwetter.fuerst-stuttgart.de/api/weather/latest"
|
||||
TIMEOUT_MINUTES = 15
|
||||
RESEND_AFTER_HOURS = 2
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
try:
|
||||
import certifi
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
if not ENV_FILE.exists():
|
||||
return
|
||||
for line in ENV_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
val = val.strip().strip("'\"")
|
||||
if key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"alert_active": False, "last_alert_sent": None, "last_daily_report": None}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def get_latest_weather() -> Optional[dict]:
|
||||
try:
|
||||
with urllib.request.urlopen(API_URL, timeout=10, context=_ssl_context()) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f"API-Anfrage fehlgeschlagen: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(dt_str: str) -> Optional[datetime]:
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(dt_str).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _send_email(subject: str, body: str) -> bool:
|
||||
smtp_host = os.getenv("MONITOR_SMTP_HOST", "smtp.gmx.de")
|
||||
smtp_port = int(os.getenv("MONITOR_SMTP_PORT", "587"))
|
||||
smtp_user = os.getenv("MONITOR_SMTP_USER", "")
|
||||
smtp_pass = os.getenv("MONITOR_SMTP_PASSWORD", "")
|
||||
to_email = os.getenv("MONITOR_TO_EMAIL", "rxf@gmx.de")
|
||||
from_email = os.getenv("MONITOR_FROM_EMAIL", smtp_user)
|
||||
|
||||
if not smtp_user or not smtp_pass:
|
||||
print("FEHLER: MONITOR_SMTP_USER / MONITOR_SMTP_PASSWORD nicht in .env gesetzt")
|
||||
return False
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_email
|
||||
msg["To"] = to_email
|
||||
msg.set_content(body)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as smtp:
|
||||
smtp.ehlo()
|
||||
smtp.starttls()
|
||||
smtp.login(smtp_user, smtp_pass)
|
||||
smtp.send_message(msg)
|
||||
print(f"E-Mail gesendet: {subject!r} -> {to_email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"FEHLER beim E-Mail-Versand: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_alert(latest_dt: Optional[datetime], state: dict, now: datetime) -> None:
|
||||
if latest_dt is not None:
|
||||
age_min = int((now - latest_dt).total_seconds() / 60)
|
||||
body = (
|
||||
f"Achtung: Die Wetterstation sendet seit {age_min} Minuten keine Daten mehr.\n\n"
|
||||
f"Letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')}\n"
|
||||
f"Aktueller Zeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}\n\n"
|
||||
f"Bitte überprüfen Sie den Wetterserver.\n"
|
||||
f"API: {API_URL}"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"Achtung: Die Wetterstation-API ist nicht erreichbar oder liefert keine Daten.\n\n"
|
||||
f"Zeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}\n\n"
|
||||
f"Bitte überprüfen Sie den Wetterserver.\n"
|
||||
f"API: {API_URL}"
|
||||
)
|
||||
if _send_email("⚠ Wetterstation: Keine Daten seit >15 Minuten", body):
|
||||
state["last_alert_sent"] = now.isoformat()
|
||||
|
||||
|
||||
def send_daily_report(data: dict, latest_dt: datetime, now: datetime, state: dict) -> None:
|
||||
temp = data.get("temperature")
|
||||
humidity = data.get("humidity")
|
||||
pressure = data.get("pressure")
|
||||
age_min = int((now - latest_dt).total_seconds() / 60)
|
||||
|
||||
lines = [
|
||||
"✓ Die Wetterstation läuft einwandfrei.",
|
||||
"",
|
||||
f"Letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')} (vor {age_min} Min.)",
|
||||
f"Prüfzeitpunkt: {now.strftime('%d.%m.%Y %H:%M:%S UTC')}",
|
||||
"",
|
||||
"Aktuelle Messwerte:",
|
||||
]
|
||||
if temp is not None:
|
||||
lines.append(f" Temperatur: {temp:.1f} °C")
|
||||
if humidity is not None:
|
||||
lines.append(f" Luftfeuchte: {humidity} %")
|
||||
if pressure is not None:
|
||||
lines.append(f" Luftdruck: {pressure:.1f} hPa")
|
||||
lines += ["", f"API: {API_URL}"]
|
||||
|
||||
if _send_email("✓ Wetterstation: Täglicher Status – alles in Ordnung", "\n".join(lines)):
|
||||
state["last_daily_report"] = date.today().isoformat()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_env()
|
||||
state = load_state()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
weather = get_latest_weather()
|
||||
latest_dt: Optional[datetime] = None
|
||||
if weather:
|
||||
dt_str = weather.get("datetime") or weather.get("received_at")
|
||||
if dt_str:
|
||||
latest_dt = parse_datetime(dt_str)
|
||||
|
||||
outage = latest_dt is None or (now - latest_dt) > timedelta(minutes=TIMEOUT_MINUTES)
|
||||
|
||||
if outage:
|
||||
should_send = not state["alert_active"]
|
||||
if not should_send and state.get("last_alert_sent"):
|
||||
last_sent = datetime.fromisoformat(state["last_alert_sent"])
|
||||
should_send = (now - last_sent) > timedelta(hours=RESEND_AFTER_HOURS)
|
||||
|
||||
state["alert_active"] = True
|
||||
if should_send:
|
||||
send_alert(latest_dt, state, now)
|
||||
else:
|
||||
print(
|
||||
f"{now.strftime('%Y-%m-%d %H:%M:%S')} Ausfall aktiv, "
|
||||
f"nächste E-Mail frühestens um "
|
||||
f"{(datetime.fromisoformat(state['last_alert_sent']) + timedelta(hours=RESEND_AFTER_HOURS)).strftime('%H:%M')} UTC"
|
||||
)
|
||||
else:
|
||||
if state["alert_active"]:
|
||||
print(
|
||||
f"{now.strftime('%Y-%m-%d %H:%M:%S')} Wetterstation liefert wieder Daten "
|
||||
f"(letzter Datenpunkt: {latest_dt.strftime('%d.%m.%Y %H:%M:%S UTC')})"
|
||||
)
|
||||
state["alert_active"] = False
|
||||
|
||||
today = date.today().isoformat()
|
||||
if state.get("last_daily_report") != today:
|
||||
send_daily_report(weather, latest_dt, now, state)
|
||||
else:
|
||||
print(
|
||||
f"{now.strftime('%Y-%m-%d %H:%M:%S')} OK – "
|
||||
f"letzter Datenpunkt: {latest_dt.strftime('%H:%M:%S UTC')}"
|
||||
)
|
||||
|
||||
save_state(state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+18
-3
@@ -164,6 +164,9 @@ class WeatherDataInput(BaseModel):
|
||||
# Vorhersage
|
||||
forecast: Optional[int] = None
|
||||
|
||||
# Datenquelle
|
||||
source: Optional[str] = None
|
||||
|
||||
# ---- Validatoren -----------------------------------------------------
|
||||
|
||||
@field_validator("tempOut", "temperature", "tempIn")
|
||||
@@ -229,6 +232,13 @@ class WeatherDataInput(BaseModel):
|
||||
raise ValueError("rain value out of plausible range")
|
||||
return v
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def _source_valid(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and v not in ("loop", "archive"):
|
||||
raise ValueError("source must be 'loop' or 'archive'")
|
||||
return v
|
||||
|
||||
# ---- Konvertierungen -------------------------------------------------
|
||||
|
||||
def get_datetime_string(self) -> str:
|
||||
@@ -330,6 +340,9 @@ def setup_database() -> None:
|
||||
cursor.execute(
|
||||
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER"
|
||||
)
|
||||
cursor.execute(
|
||||
"ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS source VARCHAR"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
|
||||
"ON weather_data (datetime DESC)"
|
||||
@@ -500,6 +513,7 @@ def _store_weather(data: WeatherDataInput) -> dict:
|
||||
data.rain,
|
||||
data.get_rain_rate(),
|
||||
data.forecast,
|
||||
data.source,
|
||||
)
|
||||
|
||||
with pool.connection() as conn:
|
||||
@@ -509,8 +523,8 @@ def _store_weather(data: WeatherDataInput) -> dict:
|
||||
INSERT INTO weather_data
|
||||
(datetime, temperature, temp_in, humidity, humidity_in,
|
||||
pressure, bar_trend, wind_speed, wind_gust, wind_dir,
|
||||
rain, rain_rate, forecast)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
rain, rain_rate, forecast, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (datetime) DO UPDATE SET
|
||||
temperature = EXCLUDED.temperature,
|
||||
temp_in = EXCLUDED.temp_in,
|
||||
@@ -523,7 +537,8 @@ def _store_weather(data: WeatherDataInput) -> dict:
|
||||
wind_dir = EXCLUDED.wind_dir,
|
||||
rain = EXCLUDED.rain,
|
||||
rain_rate = EXCLUDED.rain_rate,
|
||||
forecast = EXCLUDED.forecast
|
||||
forecast = EXCLUDED.forecast,
|
||||
source = EXCLUDED.source
|
||||
""",
|
||||
values,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy Script für laufschrift
|
||||
# Deploy Script für wetterstation
|
||||
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
|
||||
|
||||
set -e
|
||||
@@ -9,11 +9,12 @@ set -e
|
||||
# Konfiguration
|
||||
REGISTRY="docker.citysensor.de"
|
||||
PROJEKT="wetterstation"
|
||||
IMAGE_NAME=("${PROJEKT}-frontend" "${PROJEKT}-collector" "${PROJEKT}-api")
|
||||
IMAGE_NAME=("${PROJEKT}-frontend" "${PROJEKT}-collector" "${PROJEKT}-api" "${PROJEKT}-monitor")
|
||||
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
|
||||
|
||||
# Build-Datum
|
||||
# Build-Datum und Version
|
||||
BUILD_DATE=$(date +%d.%m.%Y)
|
||||
VERSION=$(grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||
|
||||
echo "=========================================="
|
||||
echo " Deploy Script"
|
||||
@@ -49,23 +50,30 @@ for image in "${IMAGE_NAME[@]}"; do
|
||||
echo "=========================================="
|
||||
|
||||
# Build-Args vorbereiten (für Frontend Version und Build-Date)
|
||||
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
|
||||
if [ "${IMAGE_DIR}" = "frontend" ]; then
|
||||
VERSION=$(grep '"version"' "${IMAGE_DIR}/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
|
||||
fi
|
||||
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE} --build-arg VERSION=${VERSION}"
|
||||
|
||||
# 3. Docker Image bauen und pushen (Multiplatform)
|
||||
# monitor: Build-Kontext ist Projekt-Root (check_wetterserver.py liegt dort)
|
||||
if [[ "${image}" == "${PROJEKT}-monitor" ]]; then
|
||||
DOCKERFILE_ARG="-f monitor/Dockerfile"
|
||||
BUILD_CONTEXT="."
|
||||
else
|
||||
DOCKERFILE_ARG=""
|
||||
BUILD_CONTEXT="./${IMAGE_DIR}"
|
||||
fi
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
${BUILD_ARGS} \
|
||||
${DOCKERFILE_ARG} \
|
||||
-t "${FULL_IMAGE}" \
|
||||
--push \
|
||||
"./${IMAGE_DIR}"
|
||||
"${BUILD_CONTEXT}"
|
||||
|
||||
# 4. Tagge auch als :latest
|
||||
echo ">>> Tagge ${image} als :latest..."
|
||||
# 4. Tagge auch als :${VERSION} und :latest
|
||||
echo ">>> Tagge ${image} als :${VERSION} und :latest..."
|
||||
docker buildx imagetools create \
|
||||
-t "${REGISTRY}/${image}:${VERSION}" \
|
||||
-t "${REGISTRY}/${image}:latest" \
|
||||
"${FULL_IMAGE}"
|
||||
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# backup-db.sh
|
||||
set -euo pipefail
|
||||
source /Users/rxf/Projekte/wetterstation/.env
|
||||
|
||||
BACKUP_DIR="/Users/rxf/Projekte/wetterstation/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
FILENAME="wetterstation_$(date +%Y%m%d_%H%M).dump"
|
||||
|
||||
docker exec wetterstation_db pg_dump -U "$DB_USER" -d "$DB_NAME" -F c -f /tmp/backup.dump
|
||||
docker cp wetterstation_db:/tmp/backup.dump "$BACKUP_DIR/$FILENAME"
|
||||
docker exec wetterstation_db rm /tmp/backup.dump
|
||||
|
||||
# Alte Backups löschen (älter als 30 Tage)
|
||||
find "$BACKUP_DIR" -name "*.dump" -mtime +30 -delete
|
||||
|
||||
echo "Backup gespeichert: $BACKUP_DIR/$FILENAME"
|
||||
@@ -92,9 +92,35 @@ services:
|
||||
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.wetterstation.loadbalancer.server.port=80"
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: wetterstation_pgadmin_prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'True'
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
monitor:
|
||||
image: docker.citysensor.de/wetterstation-monitor:latest
|
||||
container_name: wetterstation_monitor_prod
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: wetterstation_postgres_data_prod
|
||||
pgadmin_data:
|
||||
name: wetterstation_pgadmin_data_prod
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wetterstation-frontend",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,16 +9,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^3.3.1",
|
||||
"highcharts": "^11.4.0",
|
||||
"highcharts-react-official": "^3.2.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"highcharts": "^12.6.0",
|
||||
"highcharts-react-official": "^3.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wetterstation – Bedienungsanleitung</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a2e;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a3a5c 0%, #2d6a9f 100%);
|
||||
color: #fff;
|
||||
padding: 3rem 2rem 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
header h1 { font-size: 2.2rem; font-weight: 700; letter-spacing: -0.5px; }
|
||||
header p { margin-top: .5rem; font-size: 1.05rem; opacity: .85; }
|
||||
|
||||
/* ── Layout ── */
|
||||
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||
|
||||
/* ── Table of Contents ── */
|
||||
.toc {
|
||||
background: #fff;
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 10px;
|
||||
padding: 1.4rem 1.8rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.toc h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: .08em; color: #5a6a7a; margin-bottom: .7rem; }
|
||||
.toc ol { padding-left: 1.4rem; }
|
||||
.toc li { margin: .25rem 0; }
|
||||
.toc a { color: #2d6a9f; text-decoration: none; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Sections ── */
|
||||
section {
|
||||
background: #fff;
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 10px;
|
||||
padding: 1.8rem 2rem;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
section h2 {
|
||||
font-size: 1.35rem;
|
||||
color: #1a3a5c;
|
||||
border-bottom: 2px solid #e4eaf2;
|
||||
padding-bottom: .5rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
section h3 {
|
||||
font-size: 1.05rem;
|
||||
color: #2d6a9f;
|
||||
margin: 1.3rem 0 .5rem;
|
||||
}
|
||||
p { margin-bottom: .8rem; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ── Buttons – visual replicas ── */
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: .35rem .85rem;
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid #2d6a9f;
|
||||
color: #2d6a9f;
|
||||
background: #fff;
|
||||
}
|
||||
.btn.active { background: #2d6a9f; color: #fff; }
|
||||
|
||||
/* ── Charts grid preview ── */
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: .8rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.chart-card {
|
||||
border: 1px solid #dce3ec;
|
||||
border-radius: 8px;
|
||||
padding: .8rem 1rem;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
.chart-card .icon { font-size: 1.4rem; }
|
||||
.chart-card strong { display: block; margin-top: .3rem; font-size: .92rem; color: #1a3a5c; }
|
||||
.chart-card span { font-size: .82rem; color: #5a6a7a; }
|
||||
|
||||
/* ── Info boxes ── */
|
||||
.info-box {
|
||||
background: #eef5ff;
|
||||
border-left: 4px solid #2d6a9f;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
.tip-box {
|
||||
background: #f0faf3;
|
||||
border-left: 4px solid #27ae60;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
.warn-box {
|
||||
background: #fffbea;
|
||||
border-left: 4px solid #f0a500;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: .8rem 1.1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .94rem;
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
table { width: 100%; border-collapse: collapse; font-size: .93rem; margin: 1rem 0; }
|
||||
th, td { padding: .55rem .8rem; border: 1px solid #dce3ec; text-align: left; }
|
||||
th { background: #eef2f8; font-weight: 600; color: #1a3a5c; }
|
||||
tr:nth-child(even) td { background: #f9fbfd; }
|
||||
|
||||
/* ── Trend arrows ── */
|
||||
.trend-table td:first-child { font-size: 1.1rem; text-align: center; }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
text-align: center;
|
||||
color: #8a9ab0;
|
||||
font-size: .85rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
header h1 { font-size: 1.6rem; }
|
||||
.page { padding: 1.2rem 1rem 3rem; }
|
||||
section { padding: 1.2rem 1.1rem; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; font-size: 12pt; }
|
||||
header { background: #1a3a5c !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.btn.active { background: #2d6a9f !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>🌤️ Wetterstation – Bedienungsanleitung</h1>
|
||||
<p>Dashboard auf Basis einer Davis VantagePro</p>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- Table of Contents -->
|
||||
<nav class="toc">
|
||||
<h2>Inhalt</h2>
|
||||
<ol>
|
||||
<li><a href="#ueberblick">Überblick</a></li>
|
||||
<li><a href="#zeitraum">Zeitraum-Auswahl</a></li>
|
||||
<li><a href="#aktuell">Aktuelle Werte</a></li>
|
||||
<li><a href="#diagramme">Diagramme im Überblick</a></li>
|
||||
<li><a href="#charts-detail">Diagramme im Detail</a></li>
|
||||
<li><a href="#tabelle">Tabellenansicht</a></li>
|
||||
<li><a href="#benutzerdefiniert">Benutzerdefinierter Zeitbereich</a></li>
|
||||
<li><a href="#refresh">Automatische Aktualisierung</a></li>
|
||||
<li><a href="#drucken">Drucken</a></li>
|
||||
<li><a href="#hinweise">Hinweise & Grenzen</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 1 Überblick -->
|
||||
<section id="ueberblick">
|
||||
<h2>1. Überblick</h2>
|
||||
<p>
|
||||
Das Wetterstation-Dashboard zeigt die Messwerte einer <strong>Davis VantagePro</strong>
|
||||
in interaktiven Diagrammen an. Alle Daten werden aus einer PostgreSQL-Datenbank
|
||||
bezogen und im Browser aufbereitet.
|
||||
</p>
|
||||
<p>
|
||||
Die Oberfläche besteht aus drei Bereichen:
|
||||
</p>
|
||||
<ol style="padding-left:1.4rem;">
|
||||
<li><strong>Zeitraum-Navigation</strong> (oben) – wählt den dargestellten Zeitraum.</li>
|
||||
<li><strong>Diagramme / Tabelle</strong> (Mitte) – zeigt die Wetterdaten an.</li>
|
||||
<li><strong>Fußzeile</strong> (unten) – zeigt Version, Build-Datum und Kontakt.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- 2 Zeitraum-Auswahl -->
|
||||
<section id="zeitraum">
|
||||
<h2>2. Zeitraum-Auswahl</h2>
|
||||
<p>
|
||||
In der Navigationsleiste am oberen Rand können Sie den Darstellungszeitraum wählen:
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<span class="btn active">24 Stunden</span>
|
||||
<span class="btn">7 Tage</span>
|
||||
<span class="btn">30 Tage</span>
|
||||
<span class="btn">365 Tage</span>
|
||||
<span class="btn">Bereich</span>
|
||||
<span class="btn">Tabelle</span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Schaltfläche</th><th>Dargestellter Zeitraum</th><th>Auflösung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>24 Stunden</strong></td><td>Letzte 24 Stunden ab jetzt</td><td>Einzelmessungen (~5 min)</td></tr>
|
||||
<tr><td><strong>7 Tage</strong></td><td>Letzte 7 Tage ab heute</td><td>Tagesmittel / Tages-Min+Max</td></tr>
|
||||
<tr><td><strong>30 Tage</strong></td><td>Letzte 30 Tage ab heute</td><td>Tagesmittel / Tages-Min+Max</td></tr>
|
||||
<tr><td><strong>365 Tage</strong></td><td>Letzte 365 Tage ab heute</td><td>Tagesmittel, Regen pro Woche</td></tr>
|
||||
<tr><td><strong>Bereich</strong></td><td>Frei wählbar (max. 1 Jahr)</td><td>Stundenmittel (< 7 Tage) oder Tagesmittel (≥ 7 Tage)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="info-box">
|
||||
Der aktuell aktive Zeitraum wird unterhalb der Navigationsleiste als Text angezeigt,
|
||||
z. B. <em>„Die letzten 24 Stunden"</em> oder
|
||||
<em>„01.01.2026 00:00 – 31.01.2026 23:59"</em>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 Aktuelle Werte -->
|
||||
<section id="aktuell">
|
||||
<h2>3. Aktuelle Werte</h2>
|
||||
<p>
|
||||
Über jedem Diagramm wird der <strong>aktuellste Messwert</strong> angezeigt
|
||||
(grauer Hinweistext, z. B. <em>„Aktuell: 18,4 °C"</em>).
|
||||
Dieser Wert stammt immer aus den letzten 24 Stunden – unabhängig vom gewählten
|
||||
Zeitraum.
|
||||
</p>
|
||||
<p>
|
||||
Unterhalb jedes Diagramms erscheinen die <strong>Minimum- und Maximumwerte</strong>
|
||||
des gewählten Zeitraums mit dem zugehörigen Zeitpunkt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 4 Diagramme im Überblick -->
|
||||
<section id="diagramme">
|
||||
<h2>4. Diagramme im Überblick</h2>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌡️</div>
|
||||
<strong>Temperatur</strong>
|
||||
<span>°C</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌐</div>
|
||||
<strong>Luftdruck</strong>
|
||||
<span>hPa</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">💧</div>
|
||||
<strong>Luftfeuchtigkeit</strong>
|
||||
<span>%</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🌧️</div>
|
||||
<strong>Regen</strong>
|
||||
<span>mm / mm/h</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">🧭</div>
|
||||
<strong>Windrichtung</strong>
|
||||
<span>°</span>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="icon">💨</div>
|
||||
<strong>Wind & Böen</strong>
|
||||
<span>km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Alle Diagramme sind interaktiv: Fahren Sie mit der Maus über eine Kurve,
|
||||
um den genauen Messwert und Zeitpunkt als Tooltip anzuzeigen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 5 Diagramme im Detail -->
|
||||
<section id="charts-detail">
|
||||
<h2>5. Diagramme im Detail</h2>
|
||||
|
||||
<h3>🌡️ Temperatur</h3>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> wird der Temperaturverlauf als Flächendiagramm dargestellt.
|
||||
Bei längeren Zeiträumen (7 d, 30 d, 365 d, benutzerdefiniert ≥ 7 Tage) werden
|
||||
<strong>Tages-Minimum und -Maximum</strong> als Band angezeigt, sodass die
|
||||
tägliche Schwankungsbreite sofort erkennbar ist.
|
||||
</p>
|
||||
|
||||
<h3>🌐 Luftdruck</h3>
|
||||
<p>
|
||||
Zeigt den barometrischen Druck in hPa. Im Diagrammtitel erscheint ein
|
||||
<strong>Trendpfeil</strong>, der die Druckentwicklung der letzten Stunden anzeigt:
|
||||
</p>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Pfeil</th><th>Bedeutung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>⬇⬇</td><td>Fällt schnell (–60)</td></tr>
|
||||
<tr><td>⬇</td><td>Fällt langsam (–20)</td></tr>
|
||||
<tr><td>→</td><td>Stabil (0)</td></tr>
|
||||
<tr><td>⬆</td><td>Steigt langsam (+20)</td></tr>
|
||||
<tr><td>⬆⬆</td><td>Steigt schnell (+60)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>💧 Luftfeuchtigkeit</h3>
|
||||
<p>Relative Luftfeuchtigkeit in Prozent als Flächendiagramm.</p>
|
||||
|
||||
<h3>🌧️ Regen</h3>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> werden zwei Kurven überlagert:
|
||||
</p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li><strong>Regen (mm)</strong> – kumulierter Regenfall als Fläche.</li>
|
||||
<li><strong>Regenrate (mm/h)</strong> – aktuelle Intensität als gestrichelte Linie.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Bei längeren Zeiträumen (7 d, 30 d) wird der <strong>Tagesregen</strong>
|
||||
als Balkendiagramm dargestellt; bei 365 Tagen der <strong>Wochenregen</strong>.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp:</strong> Im 24h-Modus ist eine Legende eingeblendet,
|
||||
die Regen und Regenrate unterscheidet.
|
||||
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
|
||||
</div>
|
||||
|
||||
<h3>🧭 Windrichtung</h3>
|
||||
<p>
|
||||
Die Windrichtung wird als <strong>Punktewolke</strong> (Streudiagramm) angezeigt.
|
||||
Die Y-Achse zeigt die Himmelsrichtungen von 0° bis 360°:
|
||||
N = 0°/360°, NO = 45°, O = 90°, SO = 135°, S = 180°, SW = 225°, W = 270°, NW = 315°.
|
||||
</p>
|
||||
|
||||
<h3>💨 Wind & Böen</h3>
|
||||
<p>
|
||||
Zeigt <strong>Windgeschwindigkeit</strong> (lila) und <strong>Böen</strong> (orange)
|
||||
in km/h. Bei 365 Tagen und benutzerdefinierten Zeiträumen über einem Jahr
|
||||
werden Böen ausgeblendet, da keine aussagekräftigen Tagesmaxima vorliegen.
|
||||
Unterhalb des Diagramms erscheint die maximale Böe des gewählten Zeitraums
|
||||
mit Zeitpunkt.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp:</strong> Im Wind-Diagramm ist eine Legende eingeblendet,
|
||||
die Windgeschwindigkeit und Böen unterscheidet.
|
||||
Klicken Sie auf einen Legendeneintrag, um eine Serie ein- oder auszublenden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 Tabellenansicht -->
|
||||
<section id="tabelle">
|
||||
<h2>6. Tabellenansicht</h2>
|
||||
<p>
|
||||
Klicken Sie auf die Schaltfläche <span class="btn">Tabelle</span>,
|
||||
um statt der Diagramme eine <strong>tabellarische Tagesübersicht</strong>
|
||||
anzuzeigen. Ein Klick auf <span class="btn active">Grafik</span>
|
||||
kehrt zur Diagrammansicht zurück.
|
||||
</p>
|
||||
<p>Die Tabelle enthält je Tag:</p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li>Temperatur-Minimum und -Maximum (°C)</li>
|
||||
<li>Luftfeuchtigkeit-Minimum und -Maximum (%)</li>
|
||||
<li>Luftdruck-Minimum und -Maximum (hPa)</li>
|
||||
<li>Regen gesamt (mm)</li>
|
||||
<li>Maximale Windböe (km/h)</li>
|
||||
</ul>
|
||||
<div class="info-box">
|
||||
Im 24h-Modus werden die vorliegenden Einzelmessungen automatisch
|
||||
pro Tag aggregiert, sodass immer eine Tageszeile erscheint.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 Benutzerdefinierter Zeitbereich -->
|
||||
<section id="benutzerdefiniert">
|
||||
<h2>7. Benutzerdefinierter Zeitbereich</h2>
|
||||
<p>
|
||||
Mit der Schaltfläche <span class="btn">Bereich</span> öffnet sich ein Dialog,
|
||||
in dem Sie ein <strong>Start- und Enddatum</strong> frei eingeben können.
|
||||
</p>
|
||||
<p><strong>Regeln:</strong></p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li>Das Enddatum muss nach dem Startdatum liegen.</li>
|
||||
<li>Der maximale Zeitraum beträgt <strong>365 Tage</strong>.</li>
|
||||
</ul>
|
||||
<p><strong>Darstellungsauflösung:</strong></p>
|
||||
<ul style="padding-left:1.4rem; margin-bottom:.8rem;">
|
||||
<li><strong>< 7 Tage:</strong> Stundenmittelwerte, X-Achse mit Datum und Uhrzeit.</li>
|
||||
<li><strong>≥ 7 Tage:</strong> Tagesmittel- bzw. Min/Max-Werte, X-Achse mit Datum.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Der zuletzt gewählte benutzerdefinierte Zeitbereich wird im Browser
|
||||
(<code>localStorage</code>) gespeichert und beim nächsten Öffnen des
|
||||
Dialogs vorausgefüllt.
|
||||
</p>
|
||||
<div class="warn-box">
|
||||
<strong>Hinweis:</strong> Schließen Sie den Dialog mit <em>Abbrechen</em>,
|
||||
um ohne Änderung zurückzukehren. Ein Klick auf den dunklen Hintergrund
|
||||
schließt den Dialog ebenfalls.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 Automatische Aktualisierung -->
|
||||
<section id="refresh">
|
||||
<h2>8. Automatische Aktualisierung</h2>
|
||||
<p>
|
||||
Im <strong>24h-Modus</strong> werden die Daten automatisch alle
|
||||
<strong>5 Minuten</strong> neu geladen. Dabei bleibt die aktuelle
|
||||
Ansicht sichtbar – die Seite flackert nicht.
|
||||
</p>
|
||||
<p>
|
||||
Bei allen anderen Zeiträumen (7 d, 30 d, 365 d, benutzerdefiniert)
|
||||
findet <strong>kein automatisches Neuladen</strong> statt.
|
||||
Laden Sie die Seite manuell neu (F5 / ⌘R), um aktuelle Daten
|
||||
für diese Zeiträume zu erhalten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 9 Drucken -->
|
||||
<section id="drucken">
|
||||
<h2>9. Drucken</h2>
|
||||
<p>
|
||||
In der <strong>Tabellenansicht</strong> erscheint oben links die Schaltfläche
|
||||
<em>🖨️ Drucken</em>. Ein Klick darauf öffnet den Druckdialog des Browsers.
|
||||
</p>
|
||||
<p>
|
||||
Die Schaltflächen-Leiste und der Drucken-Button werden beim Ausdruck
|
||||
automatisch ausgeblendet, sodass nur die Tabelle erscheint.
|
||||
</p>
|
||||
<div class="tip-box">
|
||||
<strong>Tipp für PDF-Export:</strong> Wählen Sie im Druckdialog
|
||||
„Als PDF speichern", um die Tabelle als PDF-Datei zu exportieren.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 Hinweise & Grenzen -->
|
||||
<section id="hinweise">
|
||||
<h2>10. Hinweise & Grenzen</h2>
|
||||
<table>
|
||||
<thead><tr><th>Thema</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Messsystem</strong></td>
|
||||
<td>Davis VantagePro Wetterstation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Datenbank</strong></td>
|
||||
<td>PostgreSQL, Tabelle <code>weather_data</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Messintervall</strong></td>
|
||||
<td>ca. 5 Minuten (Rohwerte); längere Zeiträume werden serverseitig aggregiert</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Max. Zeitraum</strong></td>
|
||||
<td>365 Tage bei benutzerdefiniertem Bereich</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Datenlücken</strong></td>
|
||||
<td>Ausfälle der Station oder des Collectors erscheinen als Unterbrechung in den Kurven</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Browserkompatibilität</strong></td>
|
||||
<td>Alle modernen Browser (Chrome, Firefox, Safari, Edge) in aktueller Version</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Mobil</strong></td>
|
||||
<td>Die Oberfläche ist für Mobilgeräte optimiert; Zeitraum-Buttons zeigen Kurzbezeichnungen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Zeitzone</strong></td>
|
||||
<td>Alle Zeitangaben in lokaler Browserzeit (keine UTC-Umrechnung)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Wetterstation Dashboard · Stand: Mai 2026</p>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,8 +26,8 @@ function buildUrls(timeRange) {
|
||||
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
|
||||
weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`,
|
||||
rainUrl: days < 7 ? `${API_BASE}/weather/daily-aggregated-range?start=${start}&end=${end}` : null,
|
||||
needsCurrent: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +190,27 @@
|
||||
.version-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.anleitung-btn {
|
||||
background: none;
|
||||
border: 1px solid #0066cc;
|
||||
border-radius: 6px;
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.2rem 0.75rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.anleitung-btn:hover {
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.version-short {
|
||||
display: none;
|
||||
}
|
||||
@@ -252,6 +269,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Anleitung Modal */
|
||||
.anleitung-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
height: 85vh;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anleitung-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.9rem;
|
||||
background: #1a3a5c;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border-radius: 12px 12px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anleitung-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.anleitung-modal-close:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.anleitung-frame {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import Highcharts from 'highcharts'
|
||||
import HighchartsReact from 'highcharts-react-official'
|
||||
import { HighchartsReact } from 'highcharts-react-official'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import './WeatherDashboard.css'
|
||||
@@ -21,6 +21,27 @@ Highcharts.setOptions({
|
||||
})
|
||||
|
||||
const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '24h', onTimeRangeChange, showTable = false, onToggleTable }) => {
|
||||
// State für Anleitung
|
||||
const [showAnleitung, setShowAnleitung] = useState(false)
|
||||
|
||||
// Schwellwert für Datenlücken (abhängig vom Zeitraum)
|
||||
const gapThresholdMs = useMemo(() => {
|
||||
if (timeRange === '24h') return 2 * 60 * 60 * 1000 // 2 Stunden
|
||||
return 1.5 * 24 * 3600 * 1000 // 1,5 Tage
|
||||
}, [timeRange])
|
||||
|
||||
// Fügt null-Einträge in Lücken ein, damit Highcharts die Linie unterbricht
|
||||
const withGaps = useCallback((pairs) => {
|
||||
const result = []
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
result.push(pairs[i])
|
||||
if (i < pairs.length - 1 && pairs[i + 1][0] - pairs[i][0] > gapThresholdMs) {
|
||||
result.push([(pairs[i][0] + pairs[i + 1][0]) / 2, null])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [gapThresholdMs])
|
||||
|
||||
// State für benutzerdefinierten Zeitbereich
|
||||
const [showCustomRangeModal, setShowCustomRangeModal] = useState(false)
|
||||
const [customStartDate, setCustomStartDate] = useState('')
|
||||
@@ -150,14 +171,14 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
// Spezieller Suffix für Regen
|
||||
const rainSuffix = useMemo(() => {
|
||||
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
|
||||
const days = timeRange.days || 1
|
||||
return days >= 7 ? ' (pro Tag)' : ''
|
||||
return ' (pro Tag)'
|
||||
}
|
||||
switch (timeRange) {
|
||||
case '7d':
|
||||
case '30d':
|
||||
case '365d':
|
||||
return ' (pro Tag)'
|
||||
case '365d':
|
||||
return ' (pro Woche)'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -270,30 +291,93 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
} else {
|
||||
// Vordefinierte Bereiche
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
const fmtDate = d => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`
|
||||
const fmtTime = d => `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden
|
||||
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
|
||||
xAxisConfig.tickPositioner = function() {
|
||||
const positions = []
|
||||
const d = new Date(this.min)
|
||||
d.setMinutes(0, 0, 0)
|
||||
const h = d.getHours()
|
||||
const nextH = Math.ceil(h / 4) * 4
|
||||
if (nextH >= 24) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0) }
|
||||
else d.setHours(nextH, 0, 0, 0)
|
||||
while (d.getTime() <= this.max) {
|
||||
positions.push(d.getTime())
|
||||
d.setHours(d.getHours() + 4, 0, 0, 0)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
xAxisConfig.labels = {
|
||||
rotation: 0, align: 'center', useHTML: true,
|
||||
formatter: function() {
|
||||
const d = new Date(this.value)
|
||||
return d.getHours() === 0 && d.getMinutes() === 0
|
||||
? `<span style="color:#3b82f6;font-weight:bold">${fmtDate(d)}</span>`
|
||||
: fmtTime(d)
|
||||
}
|
||||
}
|
||||
xAxisMin = now - 24 * 3600 * 1000
|
||||
xAxisMax = now
|
||||
tooltipDateFormat = '%d.%m.%Y %H:%M'
|
||||
break
|
||||
case '7d':
|
||||
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
|
||||
xAxisConfig.tickPositioner = function() {
|
||||
const positions = []
|
||||
const d = new Date(this.min)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
while (d.getTime() <= this.max) {
|
||||
positions.push(d.getTime())
|
||||
d.setDate(d.getDate() + 1)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
xAxisConfig.labels = {
|
||||
rotation: 0, align: 'center',
|
||||
formatter: function() { return fmtDate(new Date(this.value)) }
|
||||
}
|
||||
xAxisMin = now - 7 * 24 * 3600 * 1000
|
||||
xAxisMax = now
|
||||
tooltipDateFormat = '%d.%m.%Y - %Hh'
|
||||
tooltipDateFormat = '%d.%m.%Y'
|
||||
break
|
||||
case '30d':
|
||||
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
|
||||
xAxisConfig.tickPositioner = function() {
|
||||
const positions = []
|
||||
const d = new Date(this.min)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
while (d.getTime() <= this.max) {
|
||||
positions.push(d.getTime())
|
||||
d.setDate(d.getDate() + 5)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
xAxisConfig.labels = {
|
||||
rotation: 0, align: 'center',
|
||||
formatter: function() { return fmtDate(new Date(this.value)) }
|
||||
}
|
||||
xAxisMin = now - 30 * 24 * 3600 * 1000
|
||||
xAxisMax = now
|
||||
tooltipDateFormat = '%d.%m.%Y'
|
||||
break
|
||||
case '365d':
|
||||
xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' }
|
||||
tooltipDateFormat = '%b %Y'
|
||||
// Bei 365d: Min/Max aus vorhandenen Daten berechnen
|
||||
xAxisConfig.tickPositioner = function() {
|
||||
const positions = []
|
||||
const d = new Date(this.min)
|
||||
d.setDate(1); d.setHours(0, 0, 0, 0)
|
||||
while (d.getTime() <= this.max) {
|
||||
positions.push(d.getTime())
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
xAxisConfig.labels = {
|
||||
rotation: 0, align: 'center',
|
||||
formatter: function() { return pad(new Date(this.value).getMonth() + 1) }
|
||||
}
|
||||
tooltipDateFormat = '%d.%m.%Y'
|
||||
if (sortedData.length > 0) {
|
||||
xAxisMin = new Date(sortedData[0].datetime).getTime()
|
||||
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
|
||||
@@ -303,8 +387,27 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
break
|
||||
default:
|
||||
xAxisConfig.tickInterval = 4 * 3600 * 1000
|
||||
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
|
||||
xAxisConfig.tickPositioner = function() {
|
||||
const positions = []
|
||||
const d = new Date(this.min)
|
||||
d.setMinutes(0, 0, 0)
|
||||
const h = d.getHours()
|
||||
const nextH = Math.ceil(h / 4) * 4
|
||||
if (nextH >= 24) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0) }
|
||||
else d.setHours(nextH, 0, 0, 0)
|
||||
while (d.getTime() <= this.max) {
|
||||
positions.push(d.getTime())
|
||||
d.setHours(d.getHours() + 4, 0, 0, 0)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
xAxisConfig.labels = {
|
||||
rotation: 0, align: 'center',
|
||||
formatter: function() {
|
||||
const d = new Date(this.value)
|
||||
return d.getHours() === 0 && d.getMinutes() === 0 ? fmtDate(d) : fmtTime(d)
|
||||
}
|
||||
}
|
||||
xAxisMin = now - 24 * 3600 * 1000
|
||||
xAxisMax = now
|
||||
tooltipDateFormat = '%d.%m.%Y %H:%M'
|
||||
@@ -402,7 +505,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
series: [
|
||||
{
|
||||
name: 'Maximaltemperatur',
|
||||
data: sortedData.filter(item => item.max_temperature != null).map(item => [new Date(item.datetime).getTime(), item.max_temperature]),
|
||||
data: withGaps(sortedData.filter(item => item.max_temperature != null).map(item => [new Date(item.datetime).getTime(), item.max_temperature])),
|
||||
color: 'rgb(255, 99, 132)',
|
||||
type: 'line',
|
||||
lineWidth: 2,
|
||||
@@ -416,7 +519,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
},
|
||||
{
|
||||
name: 'Minimaltemperatur',
|
||||
data: sortedData.filter(item => item.min_temperature != null).map(item => [new Date(item.datetime).getTime(), item.min_temperature]),
|
||||
data: withGaps(sortedData.filter(item => item.min_temperature != null).map(item => [new Date(item.datetime).getTime(), item.min_temperature])),
|
||||
color: 'rgb(54, 162, 235)',
|
||||
type: 'line',
|
||||
lineWidth: 2,
|
||||
@@ -458,7 +561,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
},
|
||||
series: [{
|
||||
name: 'Temperatur',
|
||||
data: sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature]),
|
||||
data: withGaps(sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature])),
|
||||
color: 'rgb(255, 99, 132)',
|
||||
fillColor: {
|
||||
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
|
||||
@@ -478,7 +581,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}]
|
||||
}
|
||||
}, [sortedData, temperatureSuffix, timeRange])
|
||||
}, [sortedData, temperatureSuffix, timeRange, withGaps])
|
||||
|
||||
// Luftfeuchtigkeit Chart
|
||||
const humidityOptions = useMemo(() => {
|
||||
@@ -494,7 +597,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
},
|
||||
series: [{
|
||||
name: 'Feuchte',
|
||||
data: sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity]),
|
||||
data: withGaps(sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity])),
|
||||
color: 'rgb(54, 162, 235)',
|
||||
fillColor: {
|
||||
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
|
||||
@@ -513,7 +616,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}]
|
||||
}
|
||||
}, [sortedData, timeRange])
|
||||
}, [sortedData, timeRange, withGaps])
|
||||
|
||||
// Luftdruck Chart
|
||||
const pressureOptions = useMemo(() => {
|
||||
@@ -542,7 +645,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
},
|
||||
series: [{
|
||||
name: 'Luftdruck',
|
||||
data: sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure]),
|
||||
data: withGaps(sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure])),
|
||||
color: 'rgb(75, 192, 192)',
|
||||
fillColor: {
|
||||
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
|
||||
@@ -561,7 +664,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}]
|
||||
}
|
||||
}, [sortedData, timeRange])
|
||||
}, [sortedData, timeRange, withGaps])
|
||||
|
||||
// Regen Chart (angepasst an Zeitraum)
|
||||
const rainOptions = useMemo(() => {
|
||||
@@ -618,11 +721,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}]
|
||||
} else if (typeof timeRange === 'object' && timeRange.type === 'custom') {
|
||||
// Custom range: tägliche Summen aus sortedData (total_rain ist im daily-aggregated-range enthalten)
|
||||
// Custom range: tägliche Summen — bei kurzen Ranges (<7d) aus rainData (extra Fetch),
|
||||
// bei langen Ranges aus sortedData (daily-aggregated-range enthält total_rain)
|
||||
yAxisTitle = 'Regen (mm pro Tag)'
|
||||
const rainSource = rainData.length > 0 ? rainData : sortedData
|
||||
series = [{
|
||||
name: 'Regen',
|
||||
data: sortedData
|
||||
data: rainSource
|
||||
.filter(item => item.total_rain != null && item.total_rain > 0)
|
||||
.map(item => [new Date(item.datetime).getTime(), item.total_rain]),
|
||||
color: 'rgb(54, 162, 235)',
|
||||
@@ -636,13 +741,20 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
|
||||
return {
|
||||
...getCommonOptions(),
|
||||
legend: {
|
||||
enabled: series.length > 1,
|
||||
align: 'right',
|
||||
verticalAlign: 'top',
|
||||
floating: true,
|
||||
itemStyle: { fontSize: '11px', fontWeight: 'normal' }
|
||||
},
|
||||
yAxis: {
|
||||
...getCommonOptions().yAxis,
|
||||
title: { text: null }
|
||||
},
|
||||
series
|
||||
}
|
||||
}, [sortedData, rainData, timeRange])
|
||||
}, [sortedData, rainData, timeRange, withGaps])
|
||||
|
||||
// Windgeschwindigkeit Chart
|
||||
const windSpeedOptions = useMemo(() => {
|
||||
@@ -652,9 +764,9 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365)
|
||||
const windSpeedSeries = {
|
||||
name: 'Windgeschwindigkeit',
|
||||
data: sortedData
|
||||
data: withGaps(sortedData
|
||||
.filter(item => item.wind_speed != null)
|
||||
.map(item => [new Date(item.datetime).getTime(), item.wind_speed]),
|
||||
.map(item => [new Date(item.datetime).getTime(), item.wind_speed])),
|
||||
color: 'rgb(153, 102, 255)',
|
||||
fillColor: 'rgba(153, 102, 255, 0.1)',
|
||||
type: 'area',
|
||||
@@ -667,15 +779,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}
|
||||
|
||||
const series = hideGusts
|
||||
? [windSpeedSeries]
|
||||
: [windSpeedSeries, {
|
||||
const gustSeries = {
|
||||
name: 'Böe' + windGustSuffix,
|
||||
data: sortedData
|
||||
data: withGaps(sortedData
|
||||
.filter(item => item.wind_gust != null)
|
||||
.map(item => [new Date(item.datetime).getTime(), item.wind_gust]),
|
||||
color: 'rgb(255, 100, 0)',
|
||||
fillColor: 'rgba(255, 100, 0, 0.15)',
|
||||
.map(item => [new Date(item.datetime).getTime(), item.wind_gust])),
|
||||
color: 'rgba(255, 160, 80, 0.6)',
|
||||
fillColor: 'rgba(255, 160, 80, 0.08)',
|
||||
type: 'area',
|
||||
lineWidth: 1.5,
|
||||
connectNulls: false,
|
||||
@@ -685,7 +795,10 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
valueDecimals: 1,
|
||||
valueSuffix: ' km/h'
|
||||
}
|
||||
}]
|
||||
}
|
||||
const series = hideGusts
|
||||
? [windSpeedSeries]
|
||||
: [gustSeries, windSpeedSeries]
|
||||
return {
|
||||
...getCommonOptions(),
|
||||
legend: {
|
||||
@@ -713,14 +826,14 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
},
|
||||
series
|
||||
}
|
||||
}, [sortedData, timeRange, windGustSuffix])
|
||||
}, [sortedData, timeRange, windGustSuffix, withGaps])
|
||||
|
||||
// Windrichtung Chart
|
||||
const windDirOptions = useMemo(() => ({
|
||||
...getCommonOptions(),
|
||||
tooltip: {
|
||||
formatter: function() {
|
||||
const dateFormat = timeRange === '24h' ? '%d.%m.%Y %H:%M' : (timeRange === '7d' || timeRange === '30d' ? '%d.%m.%Y - %Hh' : '%d.%m.%Y')
|
||||
const dateFormat = timeRange === '24h' ? '%d.%m.%Y %H:%M' : '%d.%m.%Y'
|
||||
const dateStr = Highcharts.dateFormat(dateFormat, this.x)
|
||||
return `${dateStr}<br/><span style="color:${this.color}">\u25CF</span> ${this.series.name}: <b>${this.y.toFixed(0)}°</b>`
|
||||
}
|
||||
@@ -779,54 +892,66 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
}
|
||||
}
|
||||
|
||||
// Zeitformat basierend auf Zeitraum
|
||||
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
|
||||
const customDays = isCustomRange ? (timeRange.days || 1) : 0
|
||||
let timeFormat = 'dd.MM HH:mm'
|
||||
|
||||
if (isCustomRange) {
|
||||
timeFormat = customDays < 7 ? 'HH:mm' : 'dd.MM HH:mm'
|
||||
} else {
|
||||
timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm'
|
||||
const is24h = timeRange === '24h' || (isCustomRange && customDays < 7)
|
||||
const timeFormat = is24h ? 'HH:mm' : 'dd.MM HH:mm'
|
||||
|
||||
// Gibt die anzuzeigende Zeit zurück: bei aggregierten Daten das spezifische *_time-Feld,
|
||||
// bei Rohdaten (24h) das datetime des Datenpunkts selbst.
|
||||
const itemTime = (item, timeField) => {
|
||||
if (!item) return null
|
||||
const raw = item[timeField] ?? item.datetime
|
||||
return format(new Date(raw), timeFormat, { locale: de })
|
||||
}
|
||||
|
||||
// Bei aggregierten Daten (7d+) liegen echte Tages-Min/Max in eigenen Feldern;
|
||||
// bei 24h-Rohdaten sind diese Felder nicht vorhanden → Fallback auf den Messwert selbst.
|
||||
const hasAggregated = periodData[0]?.min_temperature != null
|
||||
|
||||
// Temperatur
|
||||
const minTempItem = periodData.reduce((min, item) =>
|
||||
item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null)
|
||||
const maxTempItem = periodData.reduce((max, item) =>
|
||||
item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null)
|
||||
const minTempField = hasAggregated ? 'min_temperature' : 'temperature'
|
||||
const maxTempField = hasAggregated ? 'max_temperature' : 'temperature'
|
||||
const minTempItem = periodData.reduce((min, item) =>
|
||||
item[minTempField] != null && (min === null || item[minTempField] < min[minTempField]) ? item : min, null)
|
||||
const maxTempItem = periodData.reduce((max, item) =>
|
||||
item[maxTempField] != null && (max === null || item[maxTempField] > max[maxTempField]) ? item : max, null)
|
||||
|
||||
// Luftfeuchtigkeit
|
||||
const minHumidityItem = periodData.reduce((min, item) =>
|
||||
item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null)
|
||||
const maxHumidityItem = periodData.reduce((max, item) =>
|
||||
item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null)
|
||||
const minHumidityField = hasAggregated ? 'min_humidity' : 'humidity'
|
||||
const maxHumidityField = hasAggregated ? 'max_humidity' : 'humidity'
|
||||
const minHumidityItem = periodData.reduce((min, item) =>
|
||||
item[minHumidityField] != null && (min === null || item[minHumidityField] < min[minHumidityField]) ? item : min, null)
|
||||
const maxHumidityItem = periodData.reduce((max, item) =>
|
||||
item[maxHumidityField] != null && (max === null || item[maxHumidityField] > max[maxHumidityField]) ? item : max, null)
|
||||
|
||||
// Luftdruck
|
||||
const minPressureItem = periodData.reduce((min, item) =>
|
||||
item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null)
|
||||
const maxPressureItem = periodData.reduce((max, item) =>
|
||||
item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null)
|
||||
const minPressureField = hasAggregated ? 'min_pressure' : 'pressure'
|
||||
const maxPressureField = hasAggregated ? 'max_pressure' : 'pressure'
|
||||
const minPressureItem = periodData.reduce((min, item) =>
|
||||
item[minPressureField] != null && (min === null || item[minPressureField] < min[minPressureField]) ? item : min, null)
|
||||
const maxPressureItem = periodData.reduce((max, item) =>
|
||||
item[maxPressureField] != null && (max === null || item[maxPressureField] > max[maxPressureField]) ? item : max, null)
|
||||
|
||||
// Windgeschwindigkeit
|
||||
const maxWindGustItem = periodData.reduce((max, item) =>
|
||||
// Wind
|
||||
const maxWindGustItem = periodData.reduce((max, item) =>
|
||||
item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null)
|
||||
|
||||
return {
|
||||
minTemp: minTempItem?.temperature ?? null,
|
||||
maxTemp: maxTempItem?.temperature ?? null,
|
||||
minTempTime: minTempItem ? format(new Date(minTempItem.datetime), timeFormat, { locale: de }) : null,
|
||||
maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), timeFormat, { locale: de }) : null,
|
||||
minHumidity: minHumidityItem?.humidity ?? null,
|
||||
maxHumidity: maxHumidityItem?.humidity ?? null,
|
||||
minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), timeFormat, { locale: de }) : null,
|
||||
maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), timeFormat, { locale: de }) : null,
|
||||
minPressure: minPressureItem?.pressure ?? null,
|
||||
maxPressure: maxPressureItem?.pressure ?? null,
|
||||
minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), timeFormat, { locale: de }) : null,
|
||||
maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), timeFormat, { locale: de }) : null,
|
||||
minTemp: minTempItem?.[minTempField] ?? null,
|
||||
maxTemp: maxTempItem?.[maxTempField] ?? null,
|
||||
minTempTime: itemTime(minTempItem, 'min_temperature_time'),
|
||||
maxTempTime: itemTime(maxTempItem, 'max_temperature_time'),
|
||||
minHumidity: minHumidityItem?.[minHumidityField] ?? null,
|
||||
maxHumidity: maxHumidityItem?.[maxHumidityField] ?? null,
|
||||
minHumidityTime: itemTime(minHumidityItem, 'min_humidity_time'),
|
||||
maxHumidityTime: itemTime(maxHumidityItem, 'max_humidity_time'),
|
||||
minPressure: minPressureItem?.[minPressureField] ?? null,
|
||||
maxPressure: maxPressureItem?.[maxPressureField] ?? null,
|
||||
minPressureTime: itemTime(minPressureItem, 'min_pressure_time'),
|
||||
maxPressureTime: itemTime(maxPressureItem, 'max_pressure_time'),
|
||||
maxWindGust: maxWindGustItem?.wind_gust ?? null,
|
||||
maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), timeFormat, { locale: de }) : null
|
||||
maxWindGustTime: itemTime(maxWindGustItem, 'max_wind_gust_time'),
|
||||
}
|
||||
}, [sortedData, timeRange])
|
||||
|
||||
@@ -1074,6 +1199,23 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
</div>
|
||||
)} {/* end showTable ternary */}
|
||||
|
||||
{/* Modal Anleitung */}
|
||||
{showAnleitung && (
|
||||
<div className="modal-overlay" onClick={() => setShowAnleitung(false)}>
|
||||
<div className="anleitung-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="anleitung-modal-header">
|
||||
<span>Bedienungsanleitung</span>
|
||||
<button className="anleitung-modal-close" onClick={() => setShowAnleitung(false)}>✕</button>
|
||||
</div>
|
||||
<iframe
|
||||
src="/ANLEITUNG.html"
|
||||
title="Bedienungsanleitung"
|
||||
className="anleitung-frame"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal für benutzerdefinierten Zeitbereich */}
|
||||
{showCustomRangeModal && (
|
||||
<div className="modal-overlay" onClick={handleCancelCustomRange}>
|
||||
@@ -1130,6 +1272,14 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '
|
||||
mailto:rxf@gmx.de
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="anleitung-btn"
|
||||
onClick={() => setShowAnleitung(true)}
|
||||
>
|
||||
Anleitung
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="version-full">Version</span>
|
||||
<span className="version-short">V</span>
|
||||
|
||||
@@ -19,7 +19,7 @@ load_dotenv(dotenv_path=env_path)
|
||||
# Konfiguration
|
||||
SQLITE_DB = "data/wview-archive.sdb"
|
||||
START_DATE = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
END_DATE = datetime(2026, 2, 8, 0, 0, 0, tzinfo=timezone.utc)
|
||||
END_DATE = datetime(2026, 3, 23, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# PostgreSQL-Konfiguration
|
||||
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||
@@ -96,6 +96,41 @@ def main():
|
||||
sqlite_conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Tabelle anlegen falls nicht vorhanden
|
||||
try:
|
||||
pg_cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS weather_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
datetime TIMESTAMPTZ NOT NULL,
|
||||
temperature FLOAT,
|
||||
humidity INTEGER,
|
||||
pressure FLOAT,
|
||||
wind_speed FLOAT,
|
||||
wind_gust FLOAT,
|
||||
wind_dir FLOAT,
|
||||
rain FLOAT,
|
||||
rain_rate FLOAT,
|
||||
temp_in FLOAT,
|
||||
humidity_in INTEGER,
|
||||
forecast INTEGER,
|
||||
bar_trend INTEGER,
|
||||
source VARCHAR,
|
||||
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(datetime)
|
||||
)
|
||||
""")
|
||||
pg_cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
|
||||
"ON weather_data (datetime DESC)"
|
||||
)
|
||||
pg_conn.commit()
|
||||
print("✓ Tabelle weather_data bereit")
|
||||
except Exception as e:
|
||||
print(f"✗ Fehler beim Anlegen der Tabelle: {e}")
|
||||
sqlite_conn.close()
|
||||
pg_conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Tabelle leeren falls gewünscht
|
||||
if TRUNCATE_TABLE:
|
||||
print("\nLeere PostgreSQL-Tabelle weather_data...")
|
||||
@@ -163,13 +198,13 @@ def main():
|
||||
|
||||
# In PostgreSQL einfügen
|
||||
pg_cursor.execute("""
|
||||
INSERT INTO weather_data
|
||||
(datetime, temperature, humidity, pressure,
|
||||
wind_speed, wind_gust, wind_dir, rain, rain_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
INSERT INTO weather_data
|
||||
(datetime, temperature, humidity, pressure,
|
||||
wind_speed, wind_gust, wind_dir, rain, rain_rate, source)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (datetime) DO NOTHING
|
||||
""", (dt, temp_c, humidity, pressure_hpa,
|
||||
wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm))
|
||||
""", (dt, temp_c, humidity, pressure_hpa,
|
||||
wind_speed_kmh, wind_gust_kmh, windDir, rain_mm, rain_rate_mm, 'wview'))
|
||||
|
||||
if pg_cursor.rowcount > 0:
|
||||
inserted += 1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir certifi
|
||||
|
||||
# Script vom Projekt-Root kopieren
|
||||
COPY check_wetterserver.py .
|
||||
|
||||
# Alle 5 Minuten ausführen (SIGTERM-sicher durch wait im Hintergrund)
|
||||
CMD ["sh", "-c", "while true; do python check_wetterserver.py; sleep 300 & wait $!; done"]
|
||||
@@ -30,6 +30,12 @@ docker buildx build --platform ${PLATFORMS} \
|
||||
--push \
|
||||
./frontend
|
||||
|
||||
docker buildx build --platform ${PLATFORMS} \
|
||||
-t ${REGISTRY}/${PROJECT}/monitor:latest \
|
||||
-f monitor/Dockerfile \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
|
||||
echo " Platforms: ${PLATFORMS}"
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# restore-db.sh – Wetterstation PostgreSQL-Datenbank wiederherstellen
|
||||
# Verwendung:
|
||||
# ./restore-db.sh # interaktiv – wählt aus vorhandenen Backups
|
||||
# ./restore-db.sh backups/wetterstation_20260502_0300.dump # direkt mit Datei
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/.env"
|
||||
BACKUP_DIR="$SCRIPT_DIR/backups"
|
||||
CONTAINER="wetterstation_db"
|
||||
|
||||
# ── .env laden ──────────────────────────────────────────────────────────────
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "FEHLER: .env nicht gefunden: $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
source "$ENV_FILE"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Wetterstation – DB-Restore"
|
||||
echo "=========================================="
|
||||
echo " Datenbank : $DB_NAME"
|
||||
echo " Benutzer : $DB_USER"
|
||||
echo " Container : $CONTAINER"
|
||||
echo ""
|
||||
|
||||
# ── Backup-Datei bestimmen ────────────────────────────────────────────────
|
||||
if [[ $# -ge 1 ]]; then
|
||||
DUMP_FILE="$1"
|
||||
else
|
||||
# Interaktiv: alle *.dump-Dateien im Backup-Verzeichnis auflisten
|
||||
if [[ ! -d "$BACKUP_DIR" ]]; then
|
||||
echo "FEHLER: Backup-Verzeichnis nicht gefunden: $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DUMPS=()
|
||||
while IFS= read -r line; do
|
||||
DUMPS+=("$line")
|
||||
done < <(find "$BACKUP_DIR" -name "*.dump" | sort -r)
|
||||
|
||||
if [[ ${#DUMPS[@]} -eq 0 ]]; then
|
||||
echo "FEHLER: Keine Backup-Dateien in $BACKUP_DIR gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verfügbare Backups (neuestes zuerst):"
|
||||
for i in "${!DUMPS[@]}"; do
|
||||
SIZE=$(du -sh "${DUMPS[$i]}" | cut -f1)
|
||||
echo " [$((i+1))] $(basename "${DUMPS[$i]}") ($SIZE)"
|
||||
done
|
||||
echo ""
|
||||
read -rp "Welches Backup wiederherstellen? [1-${#DUMPS[@]}]: " CHOICE
|
||||
|
||||
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#DUMPS[@]} )); then
|
||||
echo "Ungültige Auswahl. Abbruch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DUMP_FILE="${DUMPS[$((CHOICE-1))]}"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$DUMP_FILE" ]]; then
|
||||
echo "FEHLER: Datei nicht gefunden: $DUMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DUMP_SIZE=$(du -sh "$DUMP_FILE" | cut -f1)
|
||||
echo "Gewähltes Backup : $(basename "$DUMP_FILE") ($DUMP_SIZE)"
|
||||
echo ""
|
||||
|
||||
# ── Sicherheitsabfrage ────────────────────────────────────────────────────
|
||||
echo "WARNUNG: Der Restore überschreibt alle vorhandenen Daten in '$DB_NAME'!"
|
||||
read -rp "Wirklich fortfahren? (ja/n): " CONFIRM
|
||||
|
||||
if [[ "$CONFIRM" != "ja" ]]; then
|
||||
echo "Abgebrochen."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Container prüfen ─────────────────────────────────────────────────────
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
|
||||
echo "FEHLER: Container '$CONTAINER' läuft nicht."
|
||||
echo "Starte ihn mit: docker compose up -d postgres"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Restore durchführen ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Kopiere Dump in Container..."
|
||||
docker cp "$DUMP_FILE" "${CONTAINER}:/tmp/restore.dump"
|
||||
|
||||
echo "Stelle Datenbank wieder her..."
|
||||
docker exec "$CONTAINER" pg_restore \
|
||||
-U "$DB_USER" \
|
||||
-d "$DB_NAME" \
|
||||
--clean \
|
||||
--if-exists \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
-F c \
|
||||
/tmp/restore.dump
|
||||
|
||||
echo "Räume auf..."
|
||||
docker exec "$CONTAINER" rm /tmp/restore.dump
|
||||
|
||||
echo ""
|
||||
echo "✓ Restore abgeschlossen: $(basename "$DUMP_FILE")"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Setup Cronjob für Wetterserver-Monitoring (alle 5 Minuten)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON_SCRIPT="$SCRIPT_DIR/check_wetterserver.py"
|
||||
VENV_PYTHON="$SCRIPT_DIR/.venv/bin/python"
|
||||
|
||||
if [[ ! -f "$VENV_PYTHON" ]]; then
|
||||
echo "FEHLER: Python-venv nicht gefunden unter $VENV_PYTHON"
|
||||
echo "Bitte zuerst: python3 -m venv .venv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CRON_ENTRY="*/5 * * * * $VENV_PYTHON $PYTHON_SCRIPT >> $SCRIPT_DIR/monitor.log 2>&1"
|
||||
|
||||
echo "=== Wetterserver-Monitoring Setup ==="
|
||||
echo ""
|
||||
echo "Voraussetzung: MONITOR_SMTP_PASSWORD in .env gesetzt?"
|
||||
grep -q "MONITOR_SMTP_PASSWORD=" "$SCRIPT_DIR/.env" && echo " ✓ .env enthält MONITOR_SMTP_PASSWORD" || echo " ✗ MONITOR_SMTP_PASSWORD fehlt in .env - bitte zuerst eintragen!"
|
||||
echo ""
|
||||
echo "Dieser Cronjob prüft alle 5 Minuten ob Wetterdaten ankommen:"
|
||||
echo " $CRON_ENTRY"
|
||||
echo ""
|
||||
echo "Möchten Sie den Cronjob jetzt installieren? (j/n)"
|
||||
read -r response
|
||||
|
||||
if [[ "$response" =~ ^[Jj]$ ]]; then
|
||||
if crontab -l 2>/dev/null | grep -q "$PYTHON_SCRIPT"; then
|
||||
echo "Cronjob existiert bereits!"
|
||||
else
|
||||
(crontab -l 2>/dev/null; echo "$CRON_ENTRY") | crontab -
|
||||
echo "✓ Cronjob installiert"
|
||||
fi
|
||||
echo ""
|
||||
echo "Aktive Monitoring-Cronjobs:"
|
||||
crontab -l | grep check_wetterserver
|
||||
echo ""
|
||||
echo "Logs: $SCRIPT_DIR/monitor.log"
|
||||
echo ""
|
||||
echo "Testlauf:"
|
||||
"$VENV_PYTHON" "$PYTHON_SCRIPT"
|
||||
else
|
||||
echo "Abgebrochen"
|
||||
echo ""
|
||||
echo "Manuell installieren:"
|
||||
echo " crontab -e"
|
||||
echo " Zeile einfügen: $CRON_ENTRY"
|
||||
fi
|
||||
Reference in New Issue
Block a user