chore: bump patch version to 1.5.2, add monitor container

This commit is contained in:
2026-05-03 16:03:49 +02:00
parent 6d8ff752f5
commit 7deccea768
8 changed files with 302 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"alert_active": false,
"last_alert_sent": null,
"last_daily_report": "2026-05-03"
}
+210
View File
@@ -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()
+12 -2
View File
@@ -9,7 +9,7 @@ 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 und Version
@@ -53,12 +53,22 @@ for image in "${IMAGE_NAME[@]}"; do
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 :${VERSION} und :latest
echo ">>> Tagge ${image} als :${VERSION} und :latest..."
+7
View File
@@ -92,6 +92,13 @@ services:
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
- "traefik.http.services.wetterstation.loadbalancer.server.port=80"
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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wetterstation-frontend",
"private": true,
"version": "1.5.1",
"version": "1.5.2",
"type": "module",
"scripts": {
"dev": "vite",
+13
View File
@@ -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"]
+6
View File
@@ -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}"
+48
View File
@@ -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