1172 lines
25 KiB
Python
1172 lines
25 KiB
Python
|
||
#!/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()
|