#!/usr/bin/env python3
"""Site Hashrate & Uptime (Avg/Spec).

Promoted 2026-08-25 from an ad-hoc research-agent report. Rendering only --
the figures come from /opt/ngon/data/cache/site_hashrate_uptime.json, written
by apps/reporting/site_hashrate_uptime.py (cron hourly, ~17s per run). Uptime
here is a UTILISATION ratio (period avg hashrate / spec hashrate), which is a
different question from site_uptime.py's binary threshold measure. The two are
not meant to agree.
"""
import cgi
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone

sys.path.insert(0, '/opt/ngon/apps')
from managers.auth_manager import AuthManager, generate_login_page_html  # noqa: E402

sys.path.insert(0, '/var/www/html/ngon')
from links import (generate_dropdown_html, generate_dropdown_css,  # noqa: E402
                   generate_dropdown_js)

CACHE = "/opt/ngon/data/cache/site_hashrate_uptime.json"
BUILDER = "/opt/ngon/apps/reporting/site_hashrate_uptime.py"
LOCK = "/opt/ngon/data/cache/.site_hashrate_uptime.lock"
REFRESH_TIMEOUT = 120

form = cgi.FieldStorage()
auth = AuthManager('site_hashrate_uptime')
auth_required, should_exit, headers = auth.require_auth(form)

if should_exit:
    print("Content-Type: application/json")
    if headers:
        print(headers)
    print("")
    print('{"success": false, "error": "Authentication required"}' if auth_required
          else '{"success": true}')
    sys.exit(0)

if auth_required:
    print("Content-Type: text/html\n")
    print(generate_login_page_html("Site Hashrate & Uptime"))
    sys.exit(0)


def rebuild():
    """Recompute on demand. Lock-guarded: several people hitting Refresh at once
    should queue behind one run, not start five 17-second Influx pulls."""
    try:
        if os.path.exists(LOCK) and time.time() - os.path.getmtime(LOCK) < REFRESH_TIMEOUT:
            return "A refresh is already running — showing the current figures."
        open(LOCK, "w").close()
        r = subprocess.run([sys.executable, BUILDER], capture_output=True,
                           text=True, timeout=REFRESH_TIMEOUT)
        if r.returncode != 0:
            return "Refresh failed: " + (r.stderr or "unknown error").strip()[:300]
        return None
    except subprocess.TimeoutExpired:
        return f"Refresh timed out after {REFRESH_TIMEOUT}s — showing the last good figures."
    except Exception as e:
        return f"Refresh failed: {e.__class__.__name__}: {e}"
    finally:
        try:
            os.remove(LOCK)
        except OSError:
            pass


notice = rebuild() if form.getvalue("refresh") else None

try:
    data = json.load(open(CACHE))
except Exception as e:
    data = None
    notice = notice or f"No data available yet ({e.__class__.__name__}). Try Refresh."

rows = data["rows"] if data else []
gen = data["generated"] if data else None
age_txt = "unknown"
stale = False
if gen:
    try:
        age = (datetime.now(timezone.utc) - datetime.fromisoformat(gen)).total_seconds()
        stale = age > 7200
        age_txt = (f"{int(age)}s ago" if age < 90 else
                   f"{int(age // 60)} min ago" if age < 5400 else
                   f"{age / 3600:.1f} h ago")
    except ValueError:
        pass


def th(v):
    return "&mdash;" if v is None else f"{round(v):,}"


def pct(v):
    return "&mdash;" if v is None else f"{v:.1f}%"


def heat(v):
    """Colour by utilisation. None (never reported) is grey, not red -- a dark
    site is a different fact from a badly performing one."""
    if v is None:
        return "background:rgba(255,255,255,0.04);color:#888"
    a = 0.05 + 0.32 * min(v, 100) / 100
    return f"background:rgba(0,255,0,{a:.3f})"


body = []
for r in rows:
    u7, u30, u90 = (r["uptime"].get("7"), r["uptime"].get("30"), r["uptime"].get("90"))
    flag = ""
    if r.get("dark"):
        flag = ' <span style="color:#888" title="no telemetry in the window">dark</span>'
    elif r["site"] == "Osprey":
        flag = ' <span style="color:#888" title="non-producing buildout site">buildout</span>'
    body.append(f"""<tr>
      <td style="text-align:left">{r['site']}{flag}</td>
      <td>{r['pods']}</td><td>{r['miners']:,}</td>
      <td>{r['spec']:,}</td>
      <td>{th(r['avg'].get('7'))}</td>
      <td style="{heat(u7)}">{pct(u7)}</td>
      <td style="{heat(u30)}">{pct(u30)}</td>
      <td style="{heat(u90)}">{pct(u90)}</td>
      <td>{r['spec_contrib']:.2f}%</td>
      <td>{r['fleet_cont']:.2f}%</td>
      <td style="color:#ff4444">{r['fleet_miss']:.2f}%</td>
    </tr>""")

tot_spec = data["total_spec"] if data else 0
tot_cont = sum(r["fleet_cont"] for r in rows)
tot_miss = sum(r["fleet_miss"] for r in rows)

# generate_dropdown_html() returns only the inner links; they must sit inside
# .dropdown > .dropdown-content with the h1 as .dropdown-title, and the shared
# CSS/JS must be on the page -- without the wrapper the links render inline
# across the header.
dropdown_css = generate_dropdown_css()
dropdown_links = generate_dropdown_html(AuthManager.get_user_access())
dropdown_js = generate_dropdown_js()

print("Content-Type: text/html\n")
print(f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Site Hashrate &amp; Uptime</title>
<style>
  /* House style, matching gen_manager.py / gen_service.py (which in turn
     match the status page). Do not invent a palette here. */
  body {{ font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
          background:#1a1a1a; color:#e0e0e0; margin:0; padding:0; line-height:1.4; }}
  /* !important is REQUIRED here, not sloppiness. links.generate_dropdown_css()
     carries its own .header rule -- background #1a1a1a, padding 20px,
     border-bottom 2px solid #2a2a2a -- and it is injected AFTER this block. At
     equal specificity the later rule wins, flattening the floating box into a
     flush bar. gen_manager.py marks every header property the same way for the
     same reason. Background lifted 0.05 -> 0.08 on request 2026-08-25.
     NB: this whole style block is inside an f-string; literal CSS braces must
     be doubled, which is why the rule above is written without them. */
  .header {{ background-color:rgba(255,255,255,0.08) !important;
             border-radius:8px !important; padding:30px !important;
             margin:20px !important; border:1px solid rgba(255,255,255,0.1) !important;
             border-bottom:none !important;
             display:flex !important; justify-content:space-between !important;
             align-items:flex-start !important; }}
  /* Deliberately no font-size/weight/colour here: .dropdown-title (from
     links.generate_dropdown_css) styles the title to match every other page,
     and `.header h1` would out-specify it and silently shrink the heading. */
  .header h1 {{ margin:0 0 6px 0; }}
  .sub {{ color:#888; font-size:0.85rem; }}
  .btn {{ background:#2a2a2a; color:#e0e0e0; border:1px solid #3a3a3a;
          border-radius:6px; padding:0.5rem 0.9rem; cursor:pointer; font-size:0.85rem;
          text-decoration:none; display:inline-block; }}
  .btn:hover {{ background:#333; }}
  .wrap {{ margin:0 20px 40px 20px; overflow-x:auto; }}
  table {{ width:100%; border-collapse:collapse; font-size:13px; }}
  th {{ background:#2a2a2a; color:#00ff00; text-align:right; padding:8px 10px;
        white-space:nowrap; border-bottom:2px solid #3a3a3a; }}
  td {{ padding:6px 10px; text-align:right; border-bottom:1px solid #2a2a2a;
        white-space:nowrap; }}
  tr:hover {{ background:rgba(255,255,255,0.03); }}
  th:first-child, td:first-child {{ text-align:left; }}
  tfoot td {{ border-top:2px solid #3a3a3a; font-weight:bold; color:#00ff00;
              background:#222; }}
  .notice {{ margin:0 20px 12px 20px; padding:0.6rem 0.9rem; border-radius:6px;
             background:rgba(255,68,68,0.12); color:#ff4444; font-size:0.85rem; }}
  .stale {{ color:#ffff00; }}
  .foot {{ color:#888; font-size:0.78rem; margin:14px 20px 0 20px; line-height:1.5; }}
{dropdown_css}
</style></head><body>
<div class="header">
  <div class="dropdown">
    <h1 class="dropdown-title">Site Hashrate &amp; Uptime <span class="sub">(avg / spec)</span></h1>
    <div class="dropdown-content">{dropdown_links}</div>
    <div class="sub">Data <span class="{'stale' if stale else ''}">{age_txt}</span>
      &middot; {data['total_pods'] if data else 0} pods &middot;
      {data['total_miners']:,} miners &middot; fleet spec {tot_spec:,} TH/s</div>
  </div>
  <div style="display:flex;gap:8px;align-items:center">
    <a class="btn" href="?refresh=1">&#8635; Refresh</a>
  </div>
</div>
{f'<div class="notice">{notice}</div>' if notice else ''}
<div class="wrap">
<table>
  <thead><tr>
    <th>Site</th><th>Pods</th><th>Miners</th><th>Spec TH/s</th><th>7d Avg TH/s</th>
    <th>7d</th><th>30d</th><th>90d</th>
    <th>Spec Contrib</th><th>Fleet Cont</th><th>Fleet Miss</th>
  </tr></thead>
  <tbody>{''.join(body)}</tbody>
  <tfoot><tr>
    <td>Total</td><td>{data['total_pods'] if data else 0}</td>
    <td>{data['total_miners']:,}</td><td>{tot_spec:,}</td>
    <td>&mdash;</td><td>&mdash;</td><td>&mdash;</td><td>&mdash;</td>
    <td>100.00%</td><td>{tot_cont:.2f}%</td><td>{tot_miss:.2f}%</td>
  </tr></tfoot>
</table>
<div class="foot">
  <b>Uptime</b> here is <b>utilisation</b>: period average hashrate divided by spec
  (installed miners &times; {data['spec_th_per_miner'] if data else 180} TH/s).
  It is not the binary threshold uptime on the Site Uptime page &mdash; the two answer
  different questions and are not expected to match.<br>
  <b>Fleet Miss</b> is the share of total fleet capacity a site is failing to deliver
  &mdash; capacity-weighted, so it ranks by what the shortfall actually costs.<br>
  Sites whose Influx history is shorter than the window are averaged only over the
  period they have existed, so a low 90d against a healthy 7d usually means a pod
  came online recently, not a decline. Recomputed hourly; Refresh forces a rebuild
  (~17s).
</div>
</div>
<script>{dropdown_js}</script>
</body></html>""")
