chore: bump patch version to 1.5.2, add monitor container
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"alert_active": false,
|
||||||
|
"last_alert_sent": null,
|
||||||
|
"last_daily_report": "2026-05-03"
|
||||||
|
}
|
||||||
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()
|
||||||
@@ -9,7 +9,7 @@ set -e
|
|||||||
# Konfiguration
|
# Konfiguration
|
||||||
REGISTRY="docker.citysensor.de"
|
REGISTRY="docker.citysensor.de"
|
||||||
PROJEKT="wetterstation"
|
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
|
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
|
||||||
|
|
||||||
# Build-Datum und Version
|
# 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}"
|
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE} --build-arg VERSION=${VERSION}"
|
||||||
|
|
||||||
# 3. Docker Image bauen und pushen (Multiplatform)
|
# 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 \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
${BUILD_ARGS} \
|
${BUILD_ARGS} \
|
||||||
|
${DOCKERFILE_ARG} \
|
||||||
-t "${FULL_IMAGE}" \
|
-t "${FULL_IMAGE}" \
|
||||||
--push \
|
--push \
|
||||||
"./${IMAGE_DIR}"
|
"${BUILD_CONTEXT}"
|
||||||
|
|
||||||
# 4. Tagge auch als :${VERSION} und :latest
|
# 4. Tagge auch als :${VERSION} und :latest
|
||||||
echo ">>> Tagge ${image} als :${VERSION} und :latest..."
|
echo ">>> Tagge ${image} als :${VERSION} und :latest..."
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ services:
|
|||||||
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.wetterstation.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.wetterstation.loadbalancer.server.port=80"
|
- "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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: wetterstation_postgres_data_prod
|
name: wetterstation_postgres_data_prod
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wetterstation-frontend",
|
"name": "wetterstation-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 \
|
--push \
|
||||||
./frontend
|
./frontend
|
||||||
|
|
||||||
|
docker buildx build --platform ${PLATFORMS} \
|
||||||
|
-t ${REGISTRY}/${PROJECT}/monitor:latest \
|
||||||
|
-f monitor/Dockerfile \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
|
echo "✅ Done! Multi-platform images successfully pushed to ${REGISTRY}"
|
||||||
echo " Platforms: ${PLATFORMS}"
|
echo " Platforms: ${PLATFORMS}"
|
||||||
|
|||||||
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