Files
auto-publisher/dashboard/noc.py

214 lines
11 KiB
Python

"""
NOC Dashboard — clean, modern fleet monitor for all 8 publisher sites.
"""
import json, time, threading, requests
from datetime import datetime
from flask import Flask, jsonify
app = Flask(__name__)
SITES = {
"ai": {"name": "AI Insights", "ip": "10.30.20.240", "ct": 135, "color": "#7c3aed"},
"tech": {"name": "Tech Frontier", "ip": "10.30.20.241", "ct": 136, "color": "#2563eb"},
"science": {"name": "Science Decoded", "ip": "10.30.20.242", "ct": 137, "color": "#059669"},
"crypto": {"name": "Crypto Compass", "ip": "10.30.20.243", "ct": 138, "color": "#f59e0b"},
"linux": {"name": "Linux Lab", "ip": "10.30.20.244", "ct": 139, "color": "#f97316"},
"gaming": {"name": "Game Layer", "ip": "10.30.20.246", "ct": 140, "color": "#ec4899"},
"diy": {"name": "Maker Forge", "ip": "10.30.20.247", "ct": 141, "color": "#ef4444"},
"guides": {"name": "Practical Guides", "ip": "10.30.20.248", "ct": 142, "color": "#0891b2"},
}
cache = {"data": {}, "ts": None}
def probe(vertical, info):
s = dict(vertical=vertical, name=info["name"], ip=info["ip"], ct=info["ct"],
color=info["color"], alive=False, articles=0, views=0, today=0,
subs=0, latency=0, arts=[], error=None)
try:
t0 = time.time()
r = requests.get(f"http://{info['ip']}:80/health", timeout=5)
s["latency"] = round((time.time() - t0) * 1000)
if r.status_code == 200:
s["alive"] = True
s["articles"] = r.json().get("articles", 0)
r2 = requests.get(f"http://{info['ip']}:80/api/stats", timeout=5)
if r2.status_code == 200:
d = r2.json()
s["views"] = d.get("total_views", 0)
s["today"] = d.get("today_views", 0)
r3 = requests.get(f"http://{info['ip']}:80/api/articles?limit=3", timeout=5)
if r3.status_code == 200:
s["arts"] = [{"t": a.get("title", "")[:65], "s": a.get("slug", ""),
"d": (a.get("published_at") or "")[:10], "w": a.get("word_count", 0)}
for a in r3.json()[:3]]
except Exception as e:
s["error"] = str(e)[:50]
return vertical, s
def refresh():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
results = dict(ex.map(lambda x: probe(*x), [(v, i) for v, i in SITES.items()]))
cache["data"] = results
cache["ts"] = datetime.now().isoformat()
@app.route("/")
def home():
if not cache["ts"] or (datetime.now() - datetime.fromisoformat(cache["ts"])).seconds > 30:
threading.Thread(target=refresh, daemon=True).start()
data = cache["data"] or {}
return HTML.format(
alive=sum(1 for s in data.values() if s.get("alive")),
total=len(data),
articles=sum(s.get("articles", 0) for s in data.values()),
views=sum(s.get("views", 0) for s in data.values()),
today=sum(s.get("today", 0) for s in data.values()),
subs=sum(s.get("subs", 0) for s in data.values()),
updated=(cache["ts"] or "")[:19],
cards=build_cards(data),
)
def build_cards(data):
out = []
for v, s in data.items():
dot = "up" if s["alive"] else "down"
lat = s["latency"]
lc = "lat-ok" if lat < 80 else ("lat-warn" if lat < 250 else "lat-bad")
err = (s.get("error") or "OK")[:25]
arts = ""
for a in s.get("arts", []):
arts += f'<div class="al"><span class="ad">{a["d"]}</span><a href="http://{s["ip"]}/articles/{a["s"]}" target="_blank" class="at">{a["t"]}</a><span class="aw">{a["w"]}w</span></div>'
if not arts:
arts = '<div class="al" style="color:var(--t3);font-style:italic">No articles yet</div>'
out.append(f'''<div class="card" style="border-top:2px solid {s['color']}">
<div class="ct">
<div class="cg">
<div class="cs"><div class="cd {dot}"></div><div class="cn">{s['name']}</div></div>
<div class="cdm">{v}.thetempleofdoom.com</div>
</div>
<a href="http://{s['ip']}" target="_blank" class="cip">🖥 {s['ip']}:80</a>
</div>
<div class="cm">
<div class="cmi"><div class="cmv">{s['articles']}</div><div class="cml">Articles</div></div>
<div class="cmi"><div class="cmv">{s['views']}</div><div class="cml">Views</div></div>
<div class="cmi"><div class="cmv">{s['today']}</div><div class="cml">Today</div></div>
<div class="cmi"><div class="cmv">{s['subs']}</div><div class="cml">Subs</div></div>
<div class="cmi"><div class="cmv {lc}">{lat}ms</div><div class="cml">Latency</div></div>
<div class="cmi"><div class="cmv" style="font-size:.65rem">{err}</div><div class="cml">Status</div></div>
</div>
<div class="ca">
<div class="cal">Latest Articles</div>
{arts}
</div>
</div>''')
return "\n".join(out)
@app.route("/api/fleet")
def api_fleet():
refresh()
return jsonify({"updated": cache["ts"], "sites": cache["data"]})
@app.route("/api/refresh", methods=["POST"])
def api_refresh():
refresh()
return jsonify({"status": "ok"})
@app.route("/health")
def health():
return jsonify({"status": "ok", "ts": cache["ts"]})
# ── Template ──
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Publisher Fleet</title>
<meta http-equiv="refresh" content="60">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{{--bg:#09090b;--s1:#121214;--s2:#1a1a1f;--b:#27272a;--t:#fafafa;--t2:#a1a1aa;--t3:#71717a;--r:10px;--f:'Inter',-apple-system,sans-serif}}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:var(--f);background:var(--bg);color:var(--t);min-height:100vh;-webkit-font-smoothing:antialiased}}
.tb{{background:var(--s1);border-bottom:1px solid var(--b);padding:0 2rem;height:56px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}}
.tbl{{display:flex;align-items:center;gap:.75rem}}
.tbd{{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5)}}
.tbn{{font-weight:800;font-size:1rem;letter-spacing:-.02em}}
.tbn span{{color:var(--t3);font-weight:400;margin-left:.25rem}}
.tbr{{display:flex;align-items:center;gap:1.5rem;font-size:.8rem;color:var(--t2)}}
.tbr strong{{color:var(--t);font-weight:600}}
.tba{{display:flex;gap:.75rem}}
.tba a{{color:var(--t3);text-decoration:none;font-size:.75rem;padding:.3rem .6rem;border-radius:6px;transition:all .15s}}
.tba a:hover{{color:var(--t);background:var(--s2)}}
.sr{{display:grid;grid-template-columns:repeat(6,1fr);gap:.75rem;padding:1.25rem 2rem;max-width:1400px;margin:0 auto}}
.sb{{background:var(--s1);border:1px solid var(--b);border-radius:var(--r);padding:1rem;text-align:center;transition:border-color .15s}}
.sb:hover{{border-color:var(--t3)}}
.sv{{font-size:1.6rem;font-weight:700;letter-spacing:-.03em;line-height:1}}
.sl{{font-size:.7rem;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;margin-top:.35rem;font-weight:500}}
.gr{{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1rem;padding:0 2rem 2rem;max-width:1400px;margin:0 auto}}
.card{{background:var(--s1);border:1px solid var(--b);border-radius:var(--r);overflow:hidden;transition:border-color .15s,box-shadow .15s}}
.card:hover{{border-color:var(--t3);box-shadow:0 4px 20px rgba(0,0,0,.3)}}
.ct{{padding:1.25rem 1.25rem .75rem;display:flex;justify-content:space-between;align-items:flex-start}}
.cg{{flex:1;min-width:0}}
.cs{{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem}}
.cd{{width:7px;height:7px;border-radius:50%;flex-shrink:0}}
.cd.up{{background:#22c55e;box-shadow:0 0 6px rgba(34,197,94,.4)}}
.cd.down{{background:#ef4444;box-shadow:0 0 6px rgba(239,68,68,.4)}}
.cn{{font-weight:700;font-size:1rem;letter-spacing:-.01em}}
.cdm{{font-size:.7rem;color:var(--t3);margin-top:.1rem}}
.cip{{font-size:.65rem;color:var(--t3);font-family:'SF Mono',monospace;background:var(--s2);padding:.2rem .5rem;border-radius:4px;white-space:nowrap;text-decoration:none}}
.cip:hover{{color:var(--t)}}
.cm{{display:grid;grid-template-columns:repeat(6,1fr);border-top:1px solid var(--b);border-bottom:1px solid var(--b)}}
.cmi{{padding:.75rem .5rem;text-align:center;border-right:1px solid var(--b)}}
.cmi:last-child{{border-right:none}}
.cmv{{font-size:.95rem;font-weight:700;letter-spacing:-.02em}}
.cml{{font-size:.6rem;color:var(--t3);text-transform:uppercase;letter-spacing:.04em;margin-top:.15rem;font-weight:500}}
.lat-ok{{color:#22c55e}}.lat-warn{{color:#f59e0b}}.lat-bad{{color:#ef4444}}
.ca{{padding:.75rem 1.25rem}}
.cal{{font-size:.65rem;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-bottom:.5rem}}
.al{{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;font-size:.78rem;border-bottom:1px solid var(--b)}}
.al:last-child{{border-bottom:none}}
.ad{{color:var(--t3);font-size:.68rem;min-width:48px;font-variant-numeric:tabular-nums}}
.at{{color:var(--t2);text-decoration:none;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:color .15s}}
.at:hover{{color:var(--t)}}
.aw{{color:var(--t3);font-size:.65rem;min-width:32px;text-align:right}}
.ft{{text-align:center;padding:1.5rem;color:var(--t3);font-size:.7rem;border-top:1px solid var(--b)}}
.ft a{{color:var(--t2);text-decoration:none}}
.ft a:hover{{color:var(--t)}}
@media(max-width:800px){{.gr{{grid-template-columns:1fr;padding:0 1rem 1rem}}.sr{{grid-template-columns:repeat(3,1fr);padding:1rem}}.cm{{grid-template-columns:repeat(3,1fr)}}.tb{{padding:0 1rem}}}}
</style>
</head>
<body>
<div class="tb">
<div class="tbl"><div class="tbd"></div><div class="tbn">Publisher Fleet<span>NOC</span></div></div>
<div class="tbr">
<span><strong>{alive}</strong> / {total} online</span>
<span>{updated}</span>
<div class="tba"><a href="http://localhost:5106">Admin</a><a href="http://localhost:5053/fleet">Kalshi</a><a href="/api/fleet">JSON</a></div>
</div>
</div>
<div class="sr">
<div class="sb"><div class="sv" style="color:#fafafa">{articles}</div><div class="sl">Articles</div></div>
<div class="sb"><div class="sv" style="color:#22c55e">{views}</div><div class="sl">Total Views</div></div>
<div class="sb"><div class="sv" style="color:#3b82f6">{today}</div><div class="sl">Today</div></div>
<div class="sb"><div class="sv" style="color:#a78bfa">{subs}</div><div class="sl">Subscribers</div></div>
<div class="sb"><div class="sv" style="color:#f59e0b">6 AM</div><div class="sl">Next Publish</div></div>
<div class="sb"><div class="sv" style="color:#ec4899">{articles}</div><div class="sl">Network Total</div></div>
</div>
<div class="gr">{cards}</div>
<div class="ft">Publisher Fleet NOC · Auto-refresh 60s · <a href="http://localhost:5106">Pipeline</a> · <a href="http://localhost:5053/fleet">Kalshi</a></div>
</body>
</html>"""
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=5090)
ap.add_argument("--host", default="127.0.0.1")
a = ap.parse_args()
print(f"NOC → http://{a.host}:{a.port}")
refresh()
app.run(host=a.host, port=a.port, debug=False)