#!/usr/bin/env python3
"""hostrepo-statuspage: build the public backup status page.

Copies the status records (status/<HOST>/<KIND>/<UTC>.json, written by
hostrepo-status) from the bucket, then writes index.html, status.json and
age.js to OUT. Only hosts named in LABELS appear, and only under their
labels. Records carry numbers and results, never host text, and nothing
else from them is published.

Config: /etc/hostrepo/statuspage.conf (or HOSTREPO_STATUSPAGE_CONF), shell
style KEY=value lines:
  LABELS=web01=Server A (US);db01=Server B (EU)
  OUT=/var/www/backups.example.org
  TITLE=Backups
  LINK=https://example.org/posts/restore-drills/     (optional)
  CODE=https://git.example.org/you/hostrepo          (optional)
B2 access: the mirror key in /etc/hostrepo/mirror.env (read only is enough),
or HOSTREPO_STATUS_ROOT (any rclone path) instead of the bucket.
Exit: 0 page written, 2 failed.
"""
import html, json, os, re, shutil, subprocess, sys, tempfile, time
from datetime import datetime, timezone

CONF = os.environ.get("HOSTREPO_STATUSPAGE_CONF", "/etc/hostrepo/statuspage.conf")
CACHE = os.environ.get("HOSTREPO_STATUS_CACHE", "/var/lib/hostrepo-status")
# Freshness limits, in hours. Past these, the item turns amber or red.
FRESH = {"backup": 30, "mirror": 30, "litedrill": 30, "replica": 8 * 24, "drill": 100 * 24}
DOTS = 60          # backup dots shown per server


def read_kv(path):
    out = {}
    try:
        for line in open(path):
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                out[k.strip()] = v.strip().strip('"').strip("'")
    except FileNotFoundError:
        pass
    return out


def fetch(conf):
    """Copy new records from the bucket into CACHE. Copy only, never delete."""
    os.makedirs(CACHE, exist_ok=True)
    rclone = os.environ.get("RCLONE_BIN") or shutil.which("rclone")
    if not rclone:
        sys.exit("rclone missing")
    root = os.environ.get("HOSTREPO_STATUS_ROOT")
    env = dict(os.environ)
    if not root:
        m = read_kv(os.environ.get("HOSTREPO_MIRROR_ENV", "/etc/hostrepo/mirror.env"))
        env.update(RCLONE_CONFIG="/dev/null", RCLONE_CONFIG_SP_TYPE="b2",
                   RCLONE_CONFIG_SP_ACCOUNT=m.get("MIRROR_B2_ACCOUNT", ""),
                   RCLONE_CONFIG_SP_KEY=m.get("MIRROR_B2_KEY", ""))
        root = "sp:%s/status" % m.get("HOSTREPO_B2_BUCKET", "")
    r = subprocess.run([rclone, "copy", "-q", "--max-age", "400d", root, CACHE],
                       env=env, timeout=600)
    if r.returncode != 0:
        print("warning: could not refresh records; using the cached ones", file=sys.stderr)


def load(labels):
    """{label: {kind: [records, oldest first]}} for labelled hosts only."""
    data = {}
    for host, label in labels.items():
        kinds = {}
        for kind in ("backup", "mirror", "drill", "litedrill"):
            d = os.path.join(CACHE, host, kind)
            recs = []
            if os.path.isdir(d):
                for name in sorted(os.listdir(d)):
                    if re.fullmatch(r"\d{8}T\d{6}Z\.json", name):
                        try:
                            recs.append(json.load(open(os.path.join(d, name))))
                        except (ValueError, OSError):
                            pass
            kinds[kind] = recs
        data[label] = kinds
    rep = []
    d = os.path.join(CACHE, "eu", "replica")
    if os.path.isdir(d):
        for name in sorted(os.listdir(d)):
            if re.fullmatch(r"\d{8}T\d{6}Z\.json", name):
                try:
                    rep.append(json.load(open(os.path.join(d, name))))
                except (ValueError, OSError):
                    pass
    return data, rep


def ts(s):
    try:
        return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp()
    except (TypeError, ValueError):
        return None


def age_h(s, now):
    t = ts(s)
    return None if t is None else (now - t) / 3600


def when(s):
    t = ts(s)
    return "never" if t is None else datetime.fromtimestamp(t, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")


def ago(h):
    if h is None:
        return ""
    if h < 1:
        return "%d min ago" % max(1, round(h * 60))
    if h < 48:
        return "%d h ago" % round(h)
    return "%d days ago" % round(h / 24)


def size(n):
    if not isinstance(n, (int, float)):
        return "n/a"
    for unit in ("B", "kB", "MB", "GB", "TB"):
        if abs(n) < 1000 or unit == "TB":
            return ("%d %s" if unit == "B" else "%.1f %s") % (n, unit)
        n /= 1000.0


def dur(s):
    if not isinstance(s, (int, float)):
        return "n/a"
    return "%d s" % s if s < 90 else "%d min" % round(s / 60)


# Dot colours (the class number), and what each means:
#   0 green:         completed 100% with no additional actions
#   3 blue:          completed 100% with a repair (bit rot fixed from parity
#                    or another copy); nothing else went wrong
#   1 yellow/orange: succeeded with warnings
#   2 red:           failed
RESULT_WORD = {0: "completed 100%, no additional actions", 3: "completed 100% with repair",
               1: "succeeded with warnings", 2: "failed"}
SEVERITY = {0: 0, 3: 0, 1: 1, 2: 2}   # blue is healthy: the repair worked


def dot_of(rec, kind):
    """The dot class for one record: 0 green, 3 blue, 1 yellow, 2 red."""
    if kind == "drill":
        return 0 if rec.get("result") == "PASS" else 2
    r = rec.get("result")
    if kind == "litedrill":
        return {0: 0, 1: 1}.get(r, 2)
    if r == 0:
        return 0
    if r != 1:
        return 2
    if kind == "mirror":
        heal = rec.get("heal") or {}
        return 3 if heal.get("repaired") and not heal.get("unrepaired") else 1
    # backup: blue only when every non-clean item was a repair
    if rec.get("repairs") and not rec.get("warnings"):
        return 3
    return 1


def level_of(rec, kind):
    """Severity of one record: 0 ok (green or blue), 1 attention, 2 failing."""
    return SEVERITY[dot_of(rec, kind)]


def assess(data, rep, now):
    """Per-item findings and the overall level (0 ok, 1 attention, 2 failing)."""
    findings = []
    worst = 0

    def note(level, text):
        nonlocal worst
        worst = max(worst, level)
        findings.append((level, text))

    for label, k in data.items():
        b = k["backup"]
        if not b:
            note(2, "%s: no backup recorded" % label)
        else:
            last = b[-1]
            a = age_h(last.get("ended"), now)
            lv = level_of(last, "backup")
            if lv == 2:
                note(2, "%s: the last backup failed" % label)
            elif lv == 1:
                note(1, "%s: the last backup succeeded with warnings" % label)
            if a is None or a > FRESH["backup"]:
                note(2, "%s: no backup for %s" % (label, ago(a) or "a long time"))
        m = k["mirror"]
        if m:
            last = m[-1]
            if level_of(last, "mirror") == 2:
                note(2, "%s: the copy on the other server failed" % label)
            elif level_of(last, "mirror") == 1:
                note(1, "%s: the copy on the other server succeeded with warnings" % label)
            a = age_h(last.get("ended"), now)
            if a is None or a > FRESH["mirror"]:
                note(1, "%s: the copy on the other server is %s old" % (label, ago(a)))
        ld = k["litedrill"]
        if not ld:
            note(1, "%s: no nightly lite drill yet" % label)
        else:
            last = ld[-1]
            if level_of(last, "litedrill") == 2:
                note(2, "%s: the last lite drill found a copy that differs or is missing files" % label)
            a = age_h(last.get("checked"), now)
            if a is None or a > FRESH["litedrill"]:
                note(1, "%s: last lite drill %s" % (label, ago(a)))
        d = k["drill"]
        if not d:
            note(1, "%s: never drilled" % label)
        else:
            last = d[-1]
            if last.get("result") != "PASS":
                note(2, "%s: the last restore drill failed" % label)
            a = age_h(last.get("ended"), now)
            if a is not None and a > FRESH["drill"]:
                note(1, "%s: last restore drill %s" % (label, ago(a)))
    if not rep:
        note(1, "EU copy: never checked")
    else:
        last = rep[-1]
        if last.get("result") != 0:
            note(2, "EU copy: files missing, different or unlocked")
        a = age_h(last.get("checked"), now)
        if a is None or a > FRESH["replica"]:
            note(1, "EU copy: last checked %s" % ago(a))
    return worst, findings


CSS = """
:root{--bg:#f4f5f7;--card:#fff;--ink:#1c2230;--muted:#5b6475;--line:#dde1e8;
--ok:#1f8a4c;--warn:#e08a00;--bad:#c62828;--rep:#1f6fd1;--none:#b8bfcc;--okbg:#e7f4ec;--warnbg:#fbf0de;--badbg:#fbe6e6}
@media (prefers-color-scheme:dark){:root{--bg:#12151c;--card:#1b1f29;--ink:#e6e9ef;--muted:#9aa3b5;
--line:#2c3240;--ok:#4cc27d;--warn:#f5b041;--bad:#ef6b6b;--rep:#5b9cf0;--none:#4a5163;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.w{max-width:960px;margin:0 auto;padding:24px 16px 48px}
h1{font-size:1.6rem;margin:0 0 4px}h2{font-size:1.15rem;margin:0}
.sub{color:var(--muted);margin:0 0 20px;font-size:.92rem}
.banner{border-radius:10px;padding:16px 18px;margin:0 0 24px;border:1px solid var(--line)}
.banner b{font-size:1.2rem;display:block}
.banner ul{margin:6px 0 0;padding-left:20px}
.l0{background:var(--okbg);border-color:var(--ok)}.l1{background:var(--warnbg);border-color:var(--warn)}.l2{background:var(--badbg);border-color:var(--bad)}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px;margin:0 0 18px}
.head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.pill{font-size:.85rem;font-weight:600;padding:2px 10px;border-radius:99px;border:1px solid}
.p0{color:var(--ok);border-color:var(--ok)}.p3{color:var(--rep);border-color:var(--rep)}.p1{color:var(--warn);border-color:var(--warn)}.p2{color:var(--bad);border-color:var(--bad)}
.dots{display:flex;flex-wrap:wrap;gap:4px;margin:6px 0 4px}
.dot{width:14px;height:14px;border-radius:50%;background:var(--none);display:inline-block}
.d0{background:var(--ok)}.d1{background:var(--warn)}.d2{background:var(--bad)}.d3{background:var(--rep)}
.cap{color:var(--muted);font-size:.85rem;margin:0 0 12px}
dl{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px 18px;margin:0}
dt{color:var(--muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.03em}
dd{margin:0;font-variant-numeric:tabular-nums}
h3{font-size:.95rem;margin:16px 0 8px;color:var(--muted);font-weight:600}
.legend{color:var(--muted);font-size:.85rem}.legend .dot{vertical-align:-2px;margin:0 4px 0 12px}
footer{color:var(--muted);font-size:.85rem;margin-top:28px}
a{color:inherit}
.stale{display:none;color:var(--bad);font-weight:600}
"""

AGE_JS = """(function(){var e=document.getElementById('age');if(!e)return;
var t=Date.parse(e.getAttribute('data-generated'));function u(){var m=Math.round((Date.now()-t)/60000);
e.textContent=m<1?'just now':m<120?m+' min ago':Math.round(m/60)+' h ago';
var s=document.getElementById('stale');if(s)s.style.display=m>150?'inline':'none';}u();setInterval(u,60000);})();
"""


def e(s):
    return html.escape(str(s), quote=True)


def dot_title(r):
    s = r.get("summary") or {}
    parts = [when(r.get("ended")), RESULT_WORD[dot_of(r, "backup")]]
    if s.get("total_bytes_processed") is not None:
        parts.append("%s backed up" % size(s["total_bytes_processed"]))
    if s.get("data_added_packed") is not None:
        parts.append("%s new" % size(s["data_added_packed"]))
    parts.append(dur(r.get("duration_s")))
    return " \u00b7 ".join(parts)


def server_card(label, k, now):
    b, m, d = k["backup"], k["mirror"], k["drill"]
    last = b[-1] if b else {}
    dot = dot_of(last, "backup") if b else 2
    a = age_h(last.get("ended"), now) if b else None
    if a is None or a > FRESH["backup"]:
        dot = 2
    shown = b[-DOTS:]
    counts = [sum(1 for r in shown if dot_of(r, "backup") == x) for x in (0, 3, 1, 2)]
    dots = "".join('<span class="dot d%d" title="%s"></span>' % (dot_of(r, "backup"), e(dot_title(r))) for r in shown)
    summary = "%d backups: %d completed with no additional actions, %d completed with repair, %d succeeded with warnings, %d failed" % (len(shown), *counts)
    s = last.get("summary") or {}
    repo = last.get("repo") or {}
    dbs = last.get("databases") or {}
    chk = last.get("check") or {}
    par = last.get("par2") or {}
    sha = last.get("sha1_check") or {}
    out = ['<section class="card"><div class="head"><h2>%s</h2>' % e(label),
           '<span class="pill p%d">%s</span></div>' % (dot, e({0: "Healthy", 3: "Healthy, repaired", 1: "Attention", 2: "Failing"}[dot])),
           '<div class="dots" role="img" aria-label="%s">%s</div>' % (e(summary), dots or '<span class="cap">no backups recorded yet</span>'),
           '<p class="cap">%s. Newest on the right. Hover a dot for details.</p>' % e(summary),
           "<dl>",
           "<div><dt>Last backup</dt><dd>%s<br>%s</dd></div>" % (e(when(last.get("ended"))), e(ago(a))),
           "<div><dt>Result</dt><dd>%s</dd></div>" % e(RESULT_WORD[dot_of(last, "backup")] if b else "n/a"),
           "<div><dt>Duration</dt><dd>%s</dd></div>" % e(dur(last.get("duration_s"))),
           "<div><dt>Data backed up</dt><dd>%s in %s files</dd></div>" % (e(size(s.get("total_bytes_processed"))), e("{:,}".format(s["total_files_processed"]) if isinstance(s.get("total_files_processed"), int) else "n/a")),
           "<div><dt>New in last backup</dt><dd>%s stored</dd></div>" % e(size(s.get("data_added_packed"))),
           "<div><dt>Repository</dt><dd>%s stored, %s snapshots</dd></div>" % (e(size(repo.get("stored_bytes"))), e(repo.get("snapshots", "n/a"))),
           "<div><dt>Compression</dt><dd>%s</dd></div>" % e("%.1f to 1" % (repo["raw_bytes"] / repo["stored_bytes"]) if repo.get("raw_bytes") and repo.get("stored_bytes") else "n/a"),
           "<div><dt>Databases dumped</dt><dd>%s</dd></div>" % e(", ".join("%d %s" % (dbs[x], x) for x in ("postgres", "sqlite", "redis") if dbs.get(x)) or "none"),
           "<div><dt>Integrity check</dt><dd>%s: %s</dd></div>" % (e(chk.get("kind", "n/a")), e(chk.get("result", "n/a"))),
           "<div><dt>Parity sets</dt><dd>%s files covered%s</dd></div>" % (e(par.get("repo_files", "n/a")), e(", %d new" % par["sets_created"] if par.get("sets_created") else "")),
           "<div><dt>SHA-1 check, no download</dt><dd>%s</dd></div>" % e(
               "%s match, %s mismatched, %s missing" % (sha.get("match"), sha.get("mismatched"), sha.get("missing"))
               if sha.get("match") is not None else "n/a"),
           "</dl>"]
    out.append("<h3>Copy held by the other server</h3><dl>")
    if m:
        lm = m[-1]
        heal = lm.get("heal") or {}
        out += ["<div><dt>Last copy</dt><dd>%s<br>%s</dd></div>" % (e(when(lm.get("ended"))), e(ago(age_h(lm.get("ended"), now)))),
                "<div><dt>Result</dt><dd>%s</dd></div>" % e(RESULT_WORD[dot_of(lm, "mirror")]),
                "<div><dt>Size</dt><dd>%s, %s files</dd></div>" % (e(size(lm.get("bytes"))), e(lm.get("files", "n/a"))),
                "<div><dt>Parity sets</dt><dd>%s</dd></div>" % e(lm.get("par2_sets", "n/a")),
                "<div><dt>Bit rot repaired</dt><dd>%s repaired, %s not</dd></div>" % (e(heal.get("repaired", 0)), e(heal.get("unrepaired", 0)))]
    else:
        out.append("<div><dt>Last copy</dt><dd>none recorded yet</dd></div>")
    ld = k["litedrill"]
    out.append('</dl><h3>Nightly lite drill (SHA-1 of every copy, no download)</h3>')
    if ld:
        shown = ld[-DOTS:]
        def ld_title(r):
            parts = [when(r.get("checked")), RESULT_WORD[dot_of(r, "litedrill")] if dot_of(r, "litedrill") != 0 else "every copy matched"]
            for c in ("us", "mirror", "eu"):
                v = r.get(c)
                if v:
                    parts.append("%s %d/%d" % ({"us": "US", "mirror": "peer copy", "eu": "EU"}[c], v.get("match", 0), r.get("files", 0)))
            return " \u00b7 ".join(parts)
        out.append('<div class="dots">%s</div>' % "".join('<span class="dot d%d" title="%s"></span>' % (dot_of(r, "litedrill"), e(ld_title(r))) for r in shown))
        last = ld[-1]
        out.append("<dl><div><dt>Last lite drill</dt><dd>%s<br>%s</dd></div>" % (e(when(last.get("checked"))), e(ago(age_h(last.get("checked"), now)))))
        out.append("<div><dt>Files checked</dt><dd>%s</dd></div>" % e(last.get("files", "n/a")))
        for c, name in (("us", "US bucket"), ("mirror", "Copy on the other server"), ("eu", "Locked EU copy")):
            v = last.get(c)
            out.append("<div><dt>%s</dt><dd>%s</dd></div>" % (e(name), e("%d match, %d differ, %d missing" % (v["match"], v["mismatched"], v["missing"]) if v else "not checked nightly")))
        out.append("</dl>")
    else:
        out.append('<p class="cap">none recorded yet</p>')
    out.append("<h3>Restore drills</h3><dl>")
    if d:
        ld = d[-1]
        sv, db, st = ld.get("services") or {}, ld.get("databases") or {}, ld.get("sites") or {}
        passed = sum(1 for r in d if r.get("result") == "PASS")
        out += ["<div><dt>Last drill</dt><dd>%s<br>%s</dd></div>" % (e(when(ld.get("ended"))), e(ago(age_h(ld.get("ended"), now)))),
                "<div><dt>Result</dt><dd>%s</dd></div>" % e(ld.get("result", "n/a")),
                "<div><dt>Services</dt><dd>%s of %s running, %s unexplained</dd></div>" % (e(sv.get("running", "n/a")), e(sv.get("expected", "n/a")), e(sv.get("unexplained", "n/a"))),
                "<div><dt>Databases</dt><dd>%s identical, %s different</dd></div>" % (e(db.get("identical", "n/a")), e(db.get("different", "n/a"))),
                "<div><dt>Sites</dt><dd>%s identical, %s expected, %s unexpected</dd></div>" % (e(st.get("identical", "n/a")), e(st.get("expected", "n/a")), e(st.get("unexpected", "n/a"))),
                "<div><dt>All drills</dt><dd>%d of %d passed</dd></div>" % (passed, len(d))]
    else:
        out.append("<div><dt>Last drill</dt><dd>none recorded yet</dd></div>")
    out.append("</dl></section>")
    return "".join(out)


def replica_card(rep, now):
    out = ['<section class="card"><div class="head"><h2>Locked EU copy</h2>']
    if not rep:
        return "".join(out) + '<span class="pill p1">Not checked</span></div></section>'
    r = rep[-1]
    lv = 0 if r.get("result") == 0 else 2
    a = age_h(r.get("checked"), now)
    if lv == 0 and (a is None or a > FRESH["replica"]):
        lv = 1
    out += ['<span class="pill p%d">%s</span></div>' % (lv, e({0: "Complete and locked", 1: "Check overdue", 2: "Problem"}[lv])),
            '<p class="cap">Every file in the primary bucket is replicated to a second bucket in the EU, locked in compliance mode for 30 days. Nobody can delete or shorten the lock, the account owner included.</p><dl>',
            "<div><dt>Last checked</dt><dd>%s<br>%s</dd></div>" % (e(when(r.get("checked"))), e(ago(a))),
            "<div><dt>Files checked</dt><dd>%s</dd></div>" % e(r.get("us_files", "n/a")),
            "<div><dt>Missing or different</dt><dd>%s missing, %s different</dd></div>" % (e(r.get("missing", "n/a")), e(r.get("differ", "n/a"))),
            "<div><dt>Still replicating</dt><dd>%s</dd></div>" % e(r.get("replicating", "n/a")),
            "<div><dt>Held only by the lock</dt><dd>%s files</dd></div>" % e(r.get("eu_only", "n/a")),
            "<div><dt>Newest snapshots locked</dt><dd>%s</dd></div>" % e("yes" if not r.get("unlocked_hosts") else "no"),
            "</dl></section>"]
    return "".join(out)


def render(conf, data, rep, now):
    worst, findings = assess(data, rep, now)
    gen = datetime.fromtimestamp(now, timezone.utc)
    title = conf.get("TITLE", "Backups")
    head = {0: "All backups healthy", 1: "Backups need attention", 2: "Backups failing"}[worst]
    items = "".join("<li>%s</li>" % e(t) for lv, t in sorted(findings, key=lambda x: -x[0]))
    links = []
    if conf.get("LINK"):
        links.append('<a href="%s">How this works</a>' % e(conf["LINK"]))
    if conf.get("CODE"):
        links.append('<a href="%s">Source code</a>' % e(conf["CODE"]))
    body = [
        "<!doctype html><html lang=en><head><meta charset=utf-8>",
        '<meta name=viewport content="width=device-width,initial-scale=1">',
        "<title>%s</title><style>%s</style></head><body><div class=w>" % (e(title), CSS),
        "<h1>%s</h1>" % e(title),
        '<p class=sub>Nightly backups of two servers, copied to three other places, checked for bit rot, and restored in drills. '
        'Page built <span id=age data-generated="%s">%s</span><span id=stale class=stale> (stale)</span>.</p>' % (gen.strftime("%Y-%m-%dT%H:%M:%SZ"), e(gen.strftime("%Y-%m-%d %H:%M UTC"))),
        '<div class="banner l%d" role="status"><b>%s</b>%s</div>' % (worst, e(head), "<ul>%s</ul>" % items if items else ""),
    ]
    body += [server_card(label, k, now) for label, k in data.items()]
    body.append(replica_card(rep, now))
    body.append('<p class=legend><span class="dot d0"></span>Green: completed 100%% with no additional actions'
                '<span class="dot d3"></span>Blue: completed 100%% with repair'
                '<span class="dot d1"></span>Yellow: succeeded with warnings'
                '<span class="dot d2"></span>Red: failed. A backup older than %d hours, a failed backup, copy or drill, or a problem with the EU copy turns the status red. '
                'A drill older than %d days or an overdue check turns it amber.</p>' % (FRESH["backup"], FRESH["drill"] // 24))
    body.append("<footer>Each backup, copy, lite drill, drill and check writes a status record; this page is rebuilt from them every hour. "
                "Machine-readable: <a href=status.json>status.json</a>.%s</footer>" % (" " + " \u00b7 ".join(links) if links else ""))
    body.append("</div><script src=age.js></script></body></html>")
    return "".join(body), worst, findings


def public_json(data, rep, worst, findings, now):
    def strip_b(r):
        s = r.get("summary") or {}
        return {"ended": r.get("ended"), "result": r.get("result"), "state": RESULT_WORD[dot_of(r, "backup")],
                "duration_s": r.get("duration_s"),
                "bytes_processed": s.get("total_bytes_processed"), "files": s.get("total_files_processed"),
                "stored_added": s.get("data_added_packed"), "repo": r.get("repo"), "check": r.get("check"),
                "par2": r.get("par2"), "sha1_check": r.get("sha1_check")}
    servers = {}
    for label, k in data.items():
        servers[label] = {
            "backups": [strip_b(r) for r in k["backup"][-DOTS:]],
            "copy": ({x: k["mirror"][-1].get(x) for x in ("ended", "result", "bytes", "files", "par2_sets", "heal")} if k["mirror"] else None),
            "drills": [{x: r.get(x) for x in ("ended", "result", "services", "databases", "sites", "duration_s")} for r in k["drill"]],
            "lite_drills": [{x: r.get(x) for x in ("checked", "result", "files", "us", "mirror", "eu")} for r in k["litedrill"][-DOTS:]],
        }
    return {"generated": datetime.fromtimestamp(now, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
            "status": {0: "healthy", 1: "attention", 2: "failing"}[worst],
            "findings": [t for _, t in findings], "servers": servers,
            "eu_copy": ({x: rep[-1].get(x) for x in ("checked", "result", "us_files", "missing", "differ", "replicating", "eu_only", "unlocked_hosts")} if rep else None)}


def main():
    conf = read_kv(CONF)
    labels = {}
    for pair in conf.get("LABELS", "").split(";"):
        if "=" in pair:
            h, l = pair.split("=", 1)
            labels[h.strip()] = l.strip()
    if not labels or not conf.get("OUT"):
        sys.exit("set LABELS and OUT in %s" % CONF)
    if "--no-fetch" not in sys.argv:
        fetch(conf)
    now = time.time()
    data, rep = load(labels)
    page, worst, findings = render(conf, data, rep, now)
    out = conf["OUT"]
    os.makedirs(out, exist_ok=True)
    for name, content in (("index.html", page), ("age.js", AGE_JS),
                          ("status.json", json.dumps(public_json(data, rep, worst, findings, now), indent=1))):
        fd, tmp = tempfile.mkstemp(dir=out)
        with os.fdopen(fd, "w") as f:
            f.write(content)
        os.chmod(tmp, 0o644)
        os.replace(tmp, os.path.join(out, name))
    print("status page: %s (%s)" % ({0: "healthy", 1: "attention", 2: "failing"}[worst], out))


if __name__ == "__main__":
    try:
        main()
    except subprocess.TimeoutExpired:
        sys.exit(2)
