#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ serverjonas Network – automatischer Wochenstatus Funktionen: - Sammelt öffentliche Uptime-Kuma-Heartbeats - Speichert die Daten dauerhaft in reports/history.sqlite - Erstellt sonntags zwischen 11:00 und 18:00 Uhr einen A4-PNG-Report - Erstellt pro Kalendertag maximal einen Report - Dokumentiert Datenlücken und bestätigte DOWN-Meldungen Die Zeiten der Uptime-Kuma-API werden, falls keine Zeitzone angegeben ist, als UTC interpretiert. """ from __future__ import annotations import io import json import logging import sqlite3 import time import urllib.request import urllib.error from collections import defaultdict from datetime import datetime, timedelta, timezone from pathlib import Path from PIL import Image, ImageDraw, ImageFont # ============================================================ # KONFIGURATION # ============================================================ API_URL = ( "https://health.mjlteam.de/" "api/status-page/heartbeat/serverjonas" ) MONITORS = { 16: "serverjonas Main", 13: "serverjonas DEV Server", } POLL_SECONDS = 60 # Sonntags zwischen diesen Uhrzeiten darf der Report entstehen. REPORT_START_HOUR = 11 REPORT_END_HOUR = 18 # Betrachteter Zeitraum: die letzten 7 * 24 Stunden. REPORT_DAYS = 7 BASE_DIR = Path(__file__).resolve().parent REPORT_DIR = BASE_DIR / "reports" DB_PATH = REPORT_DIR / "history.sqlite" # A4 bei 300 DPI WIDTH = 2480 HEIGHT = 3508 MARGIN = 150 # Farben BG = "#F4F6FA" WHITE = "#FFFFFF" DARK = "#172033" MUTED = "#657087" GREEN = "#168653" RED = "#C73545" ORANGE = "#D58A20" BLUE = "#3268B2" LIGHT_GREEN = "#E4F4EB" LIGHT_RED = "#FBE8EA" LIGHT_BLUE = "#E8EFFB" BORDER = "#DCE2EC" # Nach diesem Abstand wird eine Lücke im Heartbeat-Verlauf markiert. # Bei einem Minutenintervall sind 150 Sekunden ein sinnvoller Startwert. GAP_THRESHOLD_SECONDS = 150 # HTTP HTTP_TIMEOUT = 20 USER_AGENT = "serverjonas-network-status-collector/1.0" # ============================================================ # LOGGING UND VERZEICHNISSE # ============================================================ REPORT_DIR.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler( REPORT_DIR / "collector.log", encoding="utf-8", ), ], ) # ============================================================ # ZEIT UND DATENBANK # ============================================================ def now_local() -> datetime: """Aktuelle lokale Zeit inklusive Zeitzone.""" return datetime.now().astimezone() def parse_api_time(value: str) -> datetime: """ API-Zeitstempel parsen. Zeitstempel ohne Zeitzone werden als UTC behandelt. Speicherung erfolgt anschließend als Unix-Zeitstempel. """ value = value.strip().replace("Z", "+00:00") dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def db_connect() -> sqlite3.Connection: con = sqlite3.connect(DB_PATH, timeout=30) con.row_factory = sqlite3.Row return con def init_db() -> None: with db_connect() as con: con.execute(""" CREATE TABLE IF NOT EXISTS heartbeats ( monitor_id INTEGER NOT NULL, timestamp REAL NOT NULL, status INTEGER NOT NULL, msg TEXT, ping REAL, PRIMARY KEY (monitor_id, timestamp, status) ) """) con.execute(""" CREATE INDEX IF NOT EXISTS idx_heartbeat_time ON heartbeats(timestamp) """) con.execute(""" CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """) con.execute(""" CREATE TABLE IF NOT EXISTS collection_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, success INTEGER NOT NULL, detail TEXT ) """) con.execute(""" INSERT OR IGNORE INTO metadata(key, value) VALUES ('first_run', ?) """, (str(time.time()),)) def record_run(success: bool, detail: str = "") -> None: with db_connect() as con: con.execute( """ INSERT INTO collection_runs(timestamp, success, detail) VALUES (?, ?, ?) """, (time.time(), int(success), detail[:1000]), ) # ============================================================ # API-ABFRAGE # ============================================================ def fetch_api() -> dict: request = urllib.request.Request( API_URL, headers={"User-Agent": USER_AGENT}, ) with urllib.request.urlopen( request, timeout=HTTP_TIMEOUT, ) as response: if response.status != 200: raise RuntimeError( f"HTTP-Status {response.status}" ) raw = response.read() return json.loads(raw.decode("utf-8")) def collect_heartbeats() -> int: """ Holt die öffentlich verfügbaren Heartbeats und archiviert neue Einträge. Bereits vorhandene Datensätze werden ignoriert. """ data = fetch_api() heartbeat_list = data.get("heartbeatList", {}) inserted = 0 with db_connect() as con: for monitor_id, entries in heartbeat_list.items(): try: monitor_id = int(monitor_id) except (TypeError, ValueError): continue if monitor_id not in MONITORS: continue if not isinstance(entries, list): continue for item in entries: try: timestamp = parse_api_time( item["time"] ).timestamp() status = int(item["status"]) msg = str(item.get("msg") or "") ping = item.get("ping") if ping is not None: ping = float(ping) cursor = con.execute( """ INSERT OR IGNORE INTO heartbeats (monitor_id, timestamp, status, msg, ping) VALUES (?, ?, ?, ?, ?) """, ( monitor_id, timestamp, status, msg, ping, ), ) inserted += cursor.rowcount except ( KeyError, TypeError, ValueError, OverflowError, ) as exc: logging.warning( "Ungültiger Heartbeat: %s", exc ) record_run(True, f"{inserted} neue Heartbeats") logging.info( "Sammlung erfolgreich: %s neue Heartbeats", inserted, ) return inserted # ============================================================ # DATENABFRAGE UND ANALYSE # ============================================================ def get_heartbeats( monitor_id: int, start: float, end: float, ) -> list[dict]: with db_connect() as con: rows = con.execute( """ SELECT timestamp, status, msg, ping FROM heartbeats WHERE monitor_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC """, (monitor_id, start, end), ).fetchall() return [dict(row) for row in rows] def local_datetime(timestamp: float) -> datetime: return datetime.fromtimestamp(timestamp).astimezone() def fmt_time(timestamp: float) -> str: return local_datetime(timestamp).strftime( "%d.%m.%Y %H:%M:%S" ) def fmt_duration(seconds: float) -> str: seconds = max(0, int(seconds)) days, seconds = divmod(seconds, 86400) hours, seconds = divmod(seconds, 3600) minutes, seconds = divmod(seconds, 60) parts = [] if days: parts.append(f"{days} T") if hours: parts.append(f"{hours} h") if minutes: parts.append(f"{minutes} min") if not parts: parts.append(f"{seconds} s") return " ".join(parts) def analyse_monitor( monitor_id: int, start: float, end: float, ) -> dict: rows = get_heartbeats(monitor_id, start, end) result = { "id": monitor_id, "name": MONITORS[monitor_id], "rows": rows, "count": len(rows), "up": 0, "down": 0, "unknown": 0, "gaps": [], "incidents": [], "last_status": None, "last_time": None, "uptime_pct": None, "known_down_seconds": 0, } if not rows: return result for row in rows: status = row["status"] if status == 1: result["up"] += 1 elif status == 0: result["down"] += 1 else: result["unknown"] += 1 result["last_status"] = rows[-1]["status"] result["last_time"] = rows[-1]["timestamp"] # Datenlücken und Statuswechsel untersuchen. current_down = None previous = None up_seconds = 0.0 observed_seconds = 0.0 for row in rows: ts = row["timestamp"] status = row["status"] if previous is not None: delta = ts - previous["timestamp"] if delta > GAP_THRESHOLD_SECONDS: result["gaps"].append({ "start": previous["timestamp"], "end": ts, "duration": delta, }) # Nur Intervalle zwischen bekannten Statusmeldungen # in die beobachtete Zeit einrechnen. if 0 < delta <= GAP_THRESHOLD_SECONDS: observed_seconds += delta if previous["status"] == 1: up_seconds += delta if status == 0 and current_down is None: current_down = ts elif status == 1 and current_down is not None: duration = ts - current_down result["incidents"].append({ "start": current_down, "end": ts, "duration": duration, "confirmed": True, }) result["known_down_seconds"] += duration current_down = None previous = row # Ein bis zum Ende noch nicht behobener DOWN-Zustand. if current_down is not None: duration = max(0, end - current_down) result["incidents"].append({ "start": current_down, "end": None, "duration": duration, "confirmed": True, }) result["known_down_seconds"] += duration if observed_seconds > 0: result["uptime_pct"] = ( up_seconds / observed_seconds ) * 100 return result def daily_uptime( rows: list[dict], day_start: float, day_end: float, ) -> float | None: """ Berechnet eine beobachtungsbasierte Tages-Uptime. Größere Heartbeat-Lücken werden nicht als UP gewertet. """ relevant = [ row for row in rows if day_start <= row["timestamp"] <= day_end ] if len(relevant) < 2: return None up = 0.0 observed = 0.0 for a, b in zip(relevant, relevant[1:]): delta = b["timestamp"] - a["timestamp"] if 0 < delta <= GAP_THRESHOLD_SECONDS: observed += delta if a["status"] == 1: up += delta if observed <= 0: return None return 100.0 * up / observed # ============================================================ # BERICHTSGRAFIK # ============================================================ def load_font(size: int, bold: bool = False): candidates = [] if bold: candidates += [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", ] else: candidates += [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", ] for path in candidates: if Path(path).exists(): return ImageFont.truetype(path, size) return ImageFont.load_default() def draw_text( draw: ImageDraw.ImageDraw, xy, text: str, size: int = 30, fill=DARK, bold: bool = False, ): draw.text( xy, str(text), font=load_font(size, bold), fill=fill, ) def rounded_box( draw, xy, fill=WHITE, outline=BORDER, radius=28, ): draw.rounded_rectangle( xy, radius=radius, fill=fill, outline=outline, width=2, ) def draw_wrapped_text( draw, text: str, x: int, y: int, max_width: int, size: int = 25, fill=DARK, line_gap: int = 10, ): font = load_font(size) words = str(text).split() lines = [] current = "" for word in words: candidate = ( f"{current} {word}".strip() ) if draw.textlength(candidate, font=font) <= max_width: current = candidate else: if current: lines.append(current) current = word if current: lines.append(current) for line in lines: draw.text( (x, y), line, font=font, fill=fill, ) y += size + line_gap return y def draw_chart( draw, x: int, y: int, width: int, height: int, analyses: dict, start: float, end: float, ): # Hintergrund rounded_box( draw, (x, y, x + width, y + height), fill=WHITE, ) draw_text( draw, (x + 35, y + 25), "VERLAUF DER VERFÜGBARKEIT", 30, bold=True, ) chart_x = x + 100 chart_y = y + 100 chart_w = width - 150 chart_h = height - 180 # Raster und Achsenbeschriftungen for pct in (0, 25, 50, 75, 100): yy = chart_y + chart_h * (100 - pct) / 100 draw.line( (chart_x, yy, chart_x + chart_w, yy), fill=BORDER, width=2, ) draw_text( draw, (x + 25, int(yy) - 15), f"{pct}%", 20, fill=MUTED, ) colors = { 16: BLUE, 13: GREEN, } # Tageswerte verbinden for monitor_id, analysis in analyses.items(): points = [] for i in range(7): day_start = start + i * 86400 day_end = min(day_start + 86400, end) value = daily_uptime( analysis["rows"], day_start, day_end, ) if value is None: continue px = chart_x + chart_w * ( (i + 0.5) / 7 ) py = chart_y + chart_h * ( 1 - value / 100 ) points.append((int(px), int(py))) if len(points) >= 2: draw.line( points, fill=colors[monitor_id], width=7, joint="curve", ) for px, py in points: r = 10 draw.ellipse( (px-r, py-r, px+r, py+r), fill=colors[monitor_id], ) # Tagesnamen for i in range(7): day_start = start + i * 86400 day = local_datetime(day_start) px = chart_x + chart_w * ( (i + 0.5) / 7 ) draw.text( (int(px), chart_y + chart_h + 20), day.strftime("%a"), font=load_font(22), fill=MUTED, anchor="mt", ) def create_report() -> Path: now = now_local() end = now.timestamp() start = end - REPORT_DAYS * 86400 report_date = now.strftime("%Y-%m-%d") output = REPORT_DIR / f"{report_date}.png" if output.exists(): logging.info( "Report für %s existiert bereits.", report_date, ) return output analyses = { monitor_id: analyse_monitor( monitor_id, start, end, ) for monitor_id in MONITORS } image = Image.new( "RGB", (WIDTH, HEIGHT), BG, ) draw = ImageDraw.Draw(image) x = MARGIN content_w = WIDTH - 2 * MARGIN y = 120 # Kopfbereich draw_text( draw, (x, y), "SERVERJONAS NETWORK", 58, bold=True, ) y += 85 draw_text( draw, (x, y), "WÖCHENTLICHER SYSTEMSTATUS", 34, fill=MUTED, ) y += 55 draw_text( draw, (x, y), f"Erstellt: {now.strftime('%d.%m.%Y %H:%M')} Uhr", 25, fill=MUTED, ) y += 60 draw.line( (x, y, x + content_w, y), fill=BORDER, width=3, ) y += 40 # KPI-Karten card_gap = 25 card_w = (content_w - 2 * card_gap) // 3 card_h = 220 total_incidents = sum( len(a["incidents"]) for a in analyses.values() ) total_gaps = sum( len(a["gaps"]) for a in analyses.values() ) main = analyses[16] uptime = main["uptime_pct"] uptime_text = ( f"{uptime:.2f}%" if uptime is not None else "N/A" ) cards = [ ( "MAIN · UPTIME", uptime_text, LIGHT_BLUE, BLUE, ), ( "BESTÄTIGTE STÖRUNGEN", str(total_incidents), LIGHT_RED if total_incidents else LIGHT_GREEN, RED if total_incidents else GREEN, ), ( "DATENLÜCKEN", str(total_gaps), LIGHT_BLUE if total_gaps else LIGHT_GREEN, BLUE if total_gaps else GREEN, ), ] for i, (label, value, bg, color) in enumerate(cards): cx = x + i * (card_w + card_gap) rounded_box( draw, (cx, y, cx + card_w, y + card_h), fill=bg, outline=bg, ) draw_text( draw, (cx + 25, y + 30), label, 22, fill=color, bold=True, ) draw_text( draw, (cx + 25, y + 95), value, 48, fill=DARK, bold=True, ) y += card_h + 35 # Zeitraum draw_text( draw, (x, y), "AUSWERTUNGSZEITRAUM", 27, bold=True, ) y += 45 draw_text( draw, (x, y), f"{fmt_time(start)} bis {fmt_time(end)}", 24, fill=MUTED, ) y += 55 # Chart chart_h = 650 draw_chart( draw, x, y, content_w, chart_h, analyses, start, end, ) y += chart_h + 40 # Monitorübersicht draw_text( draw, (x, y), "MONITORÜBERSICHT", 30, bold=True, ) y += 55 for monitor_id, analysis in analyses.items(): box_h = 230 rounded_box( draw, (x, y, x + content_w, y + box_h), fill=WHITE, ) draw_text( draw, (x + 30, y + 25), analysis["name"], 30, bold=True, ) last_status = analysis["last_status"] if last_status == 1: status_text = "LETZTER STATUS: UP" status_color = GREEN elif last_status == 0: status_text = "LETZTER STATUS: DOWN" status_color = RED elif last_status is None: status_text = "KEINE DATEN" status_color = ORANGE else: status_text = ( f"LETZTER STATUS: {last_status}" ) status_color = ORANGE draw_text( draw, (x + 30, y + 80), status_text, 25, fill=status_color, bold=True, ) draw_text( draw, (x + 30, y + 130), ( f"Heartbeats: {analysis['count']} | " f"UP: {analysis['up']} | " f"DOWN: {analysis['down']} | " f"Sonstige: {analysis['unknown']}" ), 23, fill=MUTED, ) if analysis["last_time"] is not None: last_seen = fmt_time( analysis["last_time"] ) else: last_seen = "Keine Daten" draw_text( draw, (x + 30, y + 175), f"Letzter Heartbeat: {last_seen}", 21, fill=MUTED, ) y += box_h + 20 # Störungsprotokoll y += 10 draw_text( draw, (x, y), "STÖRUNGSPROTOKOLL", 30, bold=True, ) y += 55 all_incidents = [] for monitor_id, analysis in analyses.items(): for incident in analysis["incidents"]: all_incidents.append( (analysis["name"], incident) ) all_incidents.sort( key=lambda item: item[1]["start"] ) if not all_incidents: rounded_box( draw, (x, y, x + content_w, y + 110), fill=LIGHT_GREEN, outline=LIGHT_GREEN, ) draw_text( draw, (x + 25, y + 30), "Keine abgeschlossenen oder laufenden " "DOWN-Phasen in den gespeicherten Daten.", 23, fill=GREEN, ) y += 130 else: for name, incident in all_incidents[:8]: end_text = ( fmt_time(incident["end"]) if incident["end"] is not None else "Noch nicht behoben" ) text = ( f"{name}: {fmt_time(incident['start'])}" f" – {end_text} " f"({fmt_duration(incident['duration'])})" ) y = draw_wrapped_text( draw, text, x + 10, y, content_w - 20, size=23, ) + 15 # Datenqualität / Fußnote y = max(y + 35, HEIGHT - 430) draw.line( (x, y, x + content_w, y), fill=BORDER, width=3, ) y += 25 draw_text( draw, (x, y), "DATENQUALITÄT & HINWEISE", 25, bold=True, ) y += 45 notes = [ ( "Quelle: öffentliche Uptime-Kuma-Statusseiten-API." ), ( "Die Datenhistorie beginnt mit dem ersten " "Sammellauf dieses Skripts." ), ( "Die öffentliche API liefert nur begrenzte " "ältere Heartbeats." ), ( "Datenlücken sind keine bestätigten Ausfälle." ), ( "Die Uptime im Diagramm basiert auf den " "beobachteten Heartbeat-Intervallen." ), ( "Ein bestätigter DOWN-Status beweist nicht, " "dass der physische Server ausgeschaltet war." ), ] for note in notes: y = draw_wrapped_text( draw, "• " + note, x + 5, y, content_w - 10, size=19, fill=MUTED, line_gap=5, ) + 8 # Footer draw.text( (WIDTH // 2, HEIGHT - 80), "serverjonas Network | Automatischer Wochenreport", font=load_font(20), fill=MUTED, anchor="mm", ) # Speichern image.save( output, format="PNG", optimize=True, dpi=(300, 300), ) logging.info("Report erstellt: %s", output) return output # ============================================================ # ZEITPLAN # ============================================================ def should_create_report(now: datetime) -> bool: return ( now.weekday() == 6 and REPORT_START_HOUR <= now.hour < REPORT_END_HOUR ) def main() -> None: init_db() logging.info("serverjonas Report-Sammler gestartet.") logging.info("Datenbank: %s", DB_PATH) logging.info("Report-Ordner: %s", REPORT_DIR) while True: try: collect_heartbeats() except ( urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError, RuntimeError, ) as exc: logging.exception( "Fehler beim Abrufen der API" ) record_run(False, str(exc)) now = now_local() if should_create_report(now): report_path = ( REPORT_DIR / f"{now.strftime('%Y-%m-%d')}.png" ) if not report_path.exists(): try: create_report() except Exception as exc: logging.exception( "Report-Erstellung fehlgeschlagen" ) record_run(False, f"Report: {exc}") time.sleep(POLL_SECONDS) if __name__ == "__main__": main()