diff --git a/.monitoring_state.json b/.monitoring_state.json new file mode 100644 index 0000000..461e221 --- /dev/null +++ b/.monitoring_state.json @@ -0,0 +1,5 @@ +{ + "alert_active": false, + "last_alert_sent": null, + "last_daily_report": "2026-05-03" +} \ No newline at end of file diff --git a/check_wetterserver.py b/check_wetterserver.py new file mode 100755 index 0000000..8b8ce8e --- /dev/null +++ b/check_wetterserver.py @@ -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() diff --git a/deploy.sh b/deploy.sh index bdeac83..fde783b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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..." diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8089ab4..ae266d6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/frontend/package.json b/frontend/package.json index d6d960d..357b78e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "wetterstation-frontend", "private": true, - "version": "1.5.1", + "version": "1.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/monitor/Dockerfile b/monitor/Dockerfile new file mode 100644 index 0000000..f71b7ee --- /dev/null +++ b/monitor/Dockerfile @@ -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"] diff --git a/push-images.sh b/push-images.sh index d0499ad..c354210 100755 --- a/push-images.sh +++ b/push-images.sh @@ -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}" diff --git a/setup-monitoring.sh b/setup-monitoring.sh new file mode 100755 index 0000000..dab555c --- /dev/null +++ b/setup-monitoring.sh @@ -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