#!/usr/bin/env python3 """kuma-export.py FROM TO [OUT] (episode 16) Reads Uptime Kuma's SQLite database read-only on the host and writes one JSON file per window for ops-readonly (/kuma/alerts.json). The agent never sees the database, only this export: per monitor its state now and since when, every status change in the window, heartbeat counts, and days left on the TLS certificate. Monitor names and messages are written by whoever controls the monitored page or the monitor; they are truncated here, not cleaned. """ import json, os, sqlite3, sys, time DB = os.popen('docker volume inspect selfhostedops-stack_kuma_data -f "{{.Mountpoint}}"').read().strip() + "/kuma.db" FROM, TO = sys.argv[1], sys.argv[2] OUT = sys.argv[3] if len(sys.argv) > 3 else "/var/lib/ops-readonly/kuma/alerts.json" S = {0: "DOWN", 1: "UP", 2: "PENDING", 3: "MAINTENANCE"} db = sqlite3.connect("file:%s?mode=ro" % DB, uri=True) q = lambda sql, *a: db.execute(sql, a).fetchall() groups = dict(q("select id, name from monitor where type = 'group'")) mons = [] for mid, name, typ, url, host, port, parent, active in q("select id, name, type, url, hostname, port, parent, active from monitor order by id"): last = q("select time, status, msg from heartbeat where monitor_id = ? and time < ? order by time desc limit 1", mid, TO) if not last: continue now_t, now_s, now_msg = last[0] # "since" = first beat after the last UP (for a monitor that is not UP), so a # PENDING beat after a Kuma restart does not make a 27 day outage look new if now_s == 1: other = q("select max(time) from heartbeat where monitor_id = ? and status != 1 and time < ?", mid, TO)[0][0] else: other = q("select max(time) from heartbeat where monitor_id = ? and status = 1 and time < ?", mid, TO)[0][0] since = q("select min(time) from heartbeat where monitor_id = ? and time > ? and time < ?", mid, other or "", TO)[0][0] hb = q("select time, status, msg, important from heartbeat where monitor_id = ? and time >= ? and time < ? order by time", mid, FROM, TO) prev = q("select status from heartbeat where monitor_id = ? and time < ? order by time desc limit 1", mid, FROM) prev = prev[0][0] if prev else None changes = [] for t, s, msg, imp in hb: if s != prev: changes.append({"t": t[:19], "status": S.get(s, s), "msg": (msg or "")[:160]}) prev = s tls = q("select info_json from monitor_tls_info where monitor_id = ?", mid) days = None if tls: try: days = json.loads(tls[0][0])["certInfo"]["daysRemaining"] except Exception: pass mons.append({ "id": mid, "name": name[:150], "type": typ, "target": url or ("%s:%s" % (host, port) if port else host) or "", "group": groups.get(parent), "active": bool(active), "now": {"status": S.get(now_s, now_s), "since": (since or now_t)[:19], "msg": (now_msg or "")[:160]}, "heartbeats": {S[k]: sum(1 for _, s, _, _ in hb if s == k) for k in S}, "changes": changes[-40:], "changes_total": len(changes), "tls_days_left": days, }) os.makedirs(os.path.dirname(OUT), exist_ok=True) data = {"window": {"from": FROM, "to": TO}, "exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "monitors": mons} tmp = OUT + ".tmp" json.dump(data, open(tmp, "w"), indent=1) os.replace(tmp, OUT) print("%s: %d monitors, %d changes, %d bytes" % (OUT, len(mons), sum(m["changes_total"] for m in mons), os.path.getsize(OUT)))