Files
tinyproject-monorepo/prg/uptime/app.py
T
2026-10-02 15:29:35 +02:00

1172 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()