{% raw %}#!/usr/bin/env python3
# Managed by Ansible — do not edit manually
# Status dashboard for cloud.ladkau.de
# Reads host system stats from /host/proc (CPU, RAM) and /host (disk),
# checks service endpoints, and serves a status page on port 8080.

import os
import time
import threading
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler

PROC = "/host/proc"
HOST = "/host"
BG_IMAGE = "/app/bg.jpg"

{% endraw %}
SERVICES = [
    ("Gitea",     "https://{{ domain_gitea }}",             200, "https://{{ domain_gitea }}"),
    ("Nextcloud", "https://{{ domain_nextcloud }}",          200, "https://{{ domain_nextcloud }}"),
    ("Keycloak",  "https://{{ domain_sso }}/realms/master", 200, "https://{{ domain_sso }}"),
    ("Roundcube", "https://{{ domain_mail }}",               200, "https://{{ domain_mail }}"),
    ("Registry",  "https://{{ domain_registry }}/v2/",      401, "https://{{ domain_registry }}"),
    ("k8s",          "https://{{ domain_k8s }}",                200, "https://{{ domain_k8s }}"),
    ("Vaultwarden",  "https://{{ domain_vault }}",             200, "https://{{ domain_vault }}"),
    ("Downloads",    "https://{{ domain_dl }}",               200, "https://{{ domain_dl }}"),
]
{% raw %}

# ── Colour palette ───────────────────────────────────────────────────────────

BG    = "#0f172a"
CARD  = "#1e293b"
MUTED = "#64748b"
TEXT  = "#e2e8f0"
SUB   = "#94a3b8"
GREEN = "#22c55e"
AMBER = "#f59e0b"
RED   = "#ef4444"

# ── System stats ─────────────────────────────────────────────────────────────

_lock  = threading.Lock()
_cache = {"cpu": 0.0, "ram_pct": 0.0, "ram_used_gb": 0.0, "ram_total_gb": 0.0,
          "disk_pct": 0.0, "disk_used_gb": 0.0, "disk_total_gb": 0.0,
          "services": [], "updated": 0}


def _cpu_sample():
    with open(PROC + "/stat") as f:
        parts = list(map(int, f.readline().split()[1:8]))
    # user nice system idle iowait irq softirq
    idle  = parts[3] + parts[4]
    total = sum(parts)
    return idle, total


def read_cpu():
    a = _cpu_sample()
    time.sleep(1)
    b = _cpu_sample()
    dt = b[1] - a[1]
    di = b[0] - a[0]
    return round(100.0 * (1.0 - di / dt), 1) if dt else 0.0


def read_ram():
    mem = {}
    with open(PROC + "/meminfo") as f:
        for line in f:
            k, v = line.split(":")
            mem[k.strip()] = int(v.split()[0])  # kB
    total_kb = mem["MemTotal"]
    avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0))
    used_kb  = total_kb - avail_kb
    pct      = round(100.0 * used_kb / total_kb, 1) if total_kb else 0.0
    return pct, round(used_kb / 1048576, 1), round(total_kb / 1048576, 1)


def read_disk():
    st    = os.statvfs(HOST)
    total = st.f_blocks * st.f_frsize
    free  = st.f_bfree  * st.f_frsize
    used  = total - free
    pct   = round(100.0 * used / total, 1) if total else 0.0
    gb    = 1024 ** 3
    return pct, round(used / gb, 1), round(total / gb, 1)


def check_services():
    results = []
    for name, url, expected, link in SERVICES:
        ok = False
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "status-dashboard/1.0"})
            with urllib.request.urlopen(req, timeout=5) as r:
                ok = r.status == expected
        except urllib.error.HTTPError as e:
            ok = e.code == expected
        except Exception:
            ok = False
        results.append({"name": name, "ok": ok, "link": link})
    return results


def collect_loop():
    while True:
        try:
            cpu = read_cpu()          # includes 1 s sleep
            ram_pct, ram_used, ram_total   = read_ram()
            disk_pct, disk_used, disk_total = read_disk()
            services = check_services()
            with _lock:
                _cache.update({
                    "cpu": cpu,
                    "ram_pct": ram_pct, "ram_used_gb": ram_used, "ram_total_gb": ram_total,
                    "disk_pct": disk_pct, "disk_used_gb": disk_used, "disk_total_gb": disk_total,
                    "services": services,
                    "updated": time.time(),
                })
        except Exception as e:
            print("collect error:", e, flush=True)
        time.sleep(29)   # 29 s rest + 1 s CPU sample = ~30 s cycle


# ── HTML rendering ───────────────────────────────────────────────────────────

def _bar_color(pct):
    if pct < 60: return GREEN
    if pct < 85: return AMBER
    return RED


def _service_dot(svc):
    c = GREEN if svc["ok"] else RED
    return (
        '<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.75rem">'
        '<div style="width:10px;height:10px;border-radius:50%;flex-shrink:0;background:' + c + ';'
        'box-shadow:0 0 6px ' + c + '88"></div>'
        '<a href="' + svc["link"] + '" target="_blank" rel="noopener" '
        'style="color:' + TEXT + ';text-decoration:none;border-bottom:1px solid rgba(255,255,255,.2)">'
        + svc["name"] + '</a>'
        '</div>'
    )


def _stat_bar(label, pct, detail):
    bc = _bar_color(pct)
    pct_str = str(pct)
    detail_html = ('&ensp;<small style="color:' + MUTED + '">' + detail + '</small>') if detail else ''
    return (
        '<div style="margin-bottom:1.2rem">'
        '<div style="display:flex;justify-content:space-between;margin-bottom:.4rem;font-size:.85rem">'
        '<span style="color:' + SUB + '">' + label + '</span>'
        '<span>' + pct_str + '%' + detail_html + '</span>'
        '</div>'
        '<div style="background:' + BG + ';border-radius:999px;height:8px;overflow:hidden">'
        '<div style="width:' + pct_str + '%;height:100%;border-radius:999px;background:' + bc + '"></div>'
        '</div>'
        '</div>'
    )


CSS = (
    '*{box-sizing:border-box;margin:0;padding:0}'
    'body{font-family:system-ui,sans-serif;background:' + BG + ' url(/bg.jpg) center/cover no-repeat fixed;'
    'color:' + TEXT + ';min-height:100vh;padding:2rem}'
    'h1{font-size:1.1rem;font-weight:600;color:' + TEXT + ';margin-bottom:2rem;letter-spacing:.06em;'
    'text-transform:uppercase;text-shadow:0 1px 4px rgba(0,0,0,.6)}'
    '.grid{display:grid;gap:1.5rem;grid-template-columns:1fr 1fr;max-width:820px}'
    '@media(max-width:600px){.grid{grid-template-columns:1fr}}'
    '.card{background:rgba(15,23,42,.75);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);'
    'border-radius:.75rem;padding:1.5rem;border:1px solid rgba(255,255,255,.08)}'
    '.card h2{font-size:.7rem;font-weight:600;color:' + MUTED + ';text-transform:uppercase;letter-spacing:.1em;margin-bottom:1.25rem}'
    '.footer{margin-top:2rem;font-size:.75rem;color:rgba(148,163,184,.7);max-width:820px;text-align:right;'
    'text-shadow:0 1px 2px rgba(0,0,0,.5)}'
)


def render():
    with _lock:
        s = dict(_cache)

    svc_html = "".join(_service_dot(svc) for svc in s["services"]) \
               or '<span style="color:' + MUTED + '">Checking…</span>'

    stats_html = (
        _stat_bar("CPU",  s["cpu"],      "")
        + _stat_bar("RAM",  s["ram_pct"],
                    str(s["ram_used_gb"]) + " / " + str(s["ram_total_gb"]) + " GB")
        + _stat_bar("Disk", s["disk_pct"],
                    str(int(s["disk_used_gb"])) + " / " + str(int(s["disk_total_gb"])) + " GB")
    )

    updated = time.strftime("%H:%M:%S", time.localtime(s["updated"])) if s["updated"] else "—"

    return (
        '<!DOCTYPE html>'
        '<html lang="en">'
        '<head>'
        '<meta charset="utf-8">'
        '<meta name="viewport" content="width=device-width,initial-scale=1">'
        '<title>cloud.ladkau.de</title>'
        '<meta http-equiv="refresh" content="30">'
        '<style>' + CSS + '</style>'
        '</head>'
        '<body>'
        '<h1>cloud.ladkau.de</h1>'
        '<div class="grid">'
        '<div class="card"><h2>Services</h2>' + svc_html + '</div>'
        '<div class="card"><h2>System</h2>' + stats_html + '</div>'
        '</div>'
        '<div class="footer">Updated ' + updated + ' · refreshes every 30 s</div>'
        '</body>'
        '</html>'
    )


# ── HTTP server ───────────────────────────────────────────────────────────────

class _Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/bg.jpg":
            try:
                with open(BG_IMAGE, "rb") as f:
                    body = f.read()
                self.send_response(200)
                self.send_header("Content-Type", "image/jpeg")
                self.send_header("Content-Length", str(len(body)))
                self.send_header("Cache-Control", "max-age=3600")
                self.end_headers()
                self.wfile.write(body)
            except OSError:
                self.send_error(404)
            return

        body = render().encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *_args):
        pass  # suppress per-request log noise


if __name__ == "__main__":
    threading.Thread(target=collect_loop, daemon=True).start()
    print("Dashboard listening on :8080", flush=True)
    HTTPServer(("", 8080), _Handler).serve_forever()
{% endraw %}
