258 lines
13 KiB
Python
258 lines
13 KiB
Python
"""
|
|
NOC Dashboard — clean, modern fleet monitor for all 8 publisher sites.
|
|
"""
|
|
import json, time, threading, requests, sys, os
|
|
from datetime import datetime
|
|
from flask import Flask, jsonify
|
|
|
|
# Evolution log
|
|
sys.path.insert(0, os.path.expanduser("~/auto-publisher/core"))
|
|
from evolution_log import get_recent_evolutions, get_evolution_stats, log_evolution
|
|
|
|
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 {}
|
|
evo_stats = get_evolution_stats()
|
|
evo_recent = get_recent_evolutions(6)
|
|
evo_html = build_evo_html(evo_recent)
|
|
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],
|
|
evo_total=evo_stats["total"],
|
|
evo_html=evo_html,
|
|
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("/api/evolution")
|
|
def api_evolution():
|
|
return jsonify({
|
|
"stats": get_evolution_stats(),
|
|
"recent": get_recent_evolutions(20),
|
|
})
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
return jsonify({"status": "ok", "ts": cache["ts"]})
|
|
|
|
def build_evo_html(events):
|
|
if not events:
|
|
return '<div class="ev-empty">No evolution events yet. First sweep runs at midnight.</div>'
|
|
out = []
|
|
for e in events:
|
|
ts = e["timestamp"][:16].replace("T", " ")
|
|
out.append(f'<div class="ev-row"><span class="ev-ts">{ts}</span><span class="ev-v" style="color:{SITES.get(e["vertical"],{}).get("color","#888")}">{e["vertical"]}</span><span class="ev-feat">{e["feature"][:80]}</span><span class="ev-stat">{e["status"]}</span></div>')
|
|
return "\n".join(out)
|
|
|
|
|
|
# ── 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)}}
|
|
.ft a:hover{{color:var(--t)}}
|
|
.ev-panel{{max-width:1400px;margin:0 auto;padding:0 2rem 2rem}}
|
|
.ev-inner{{background:var(--s1);border:1px solid var(--b);border-radius:var(--r);padding:1.25rem}}
|
|
.ev-title{{font-weight:700;font-size:.95rem;margin-bottom:.75rem;display:flex;align-items:center;gap:.5rem}}
|
|
.ev-title span{{color:var(--t3);font-weight:400;font-size:.75rem}}
|
|
.ev-row{{display:flex;align-items:center;gap:.75rem;padding:.35rem 0;font-size:.75rem;border-bottom:1px solid var(--b)}}
|
|
.ev-row:last-child{{border-bottom:none}}
|
|
.ev-ts{{color:var(--t3);min-width:110px;font-variant-numeric:tabular-nums}}
|
|
.ev-v{{font-weight:600;min-width:55px;text-transform:capitalize}}
|
|
.ev-feat{{color:var(--t2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
.ev-stat{{color:#22c55e;font-size:.65rem;text-transform:uppercase;font-weight:600}}
|
|
.ev-empty{{color:var(--t3);font-size:.8rem;font-style:italic;padding:.5rem 0}}
|
|
@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}}.ev-panel{{padding:0 1rem 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="ev-panel">
|
|
<div class="ev-inner">
|
|
<div class="ev-title">🔄 Site Evolution <span>({evo_total} improvements)</span></div>
|
|
{evo_html}
|
|
</div>
|
|
</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)
|