NOC v2: clean premium design — Inter font, proper cards, no scanlines, color-coded metrics

This commit is contained in:
drjones
2026-08-03 23:49:01 -07:00
parent a363700cdc
commit 2e2d497cbd

View File

@@ -1,434 +1,213 @@
"""
Network Operations Center — Real-time monitoring dashboard for all 8 publisher sites.
Hacker aesthetic, live data, comprehensive fleet overview.
NOC Dashboard — clean, modern fleet monitor for all 8 publisher sites.
"""
import json
import time
import sqlite3
import threading
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
from flask import Flask, render_template_string, jsonify, request
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", "accent": "#a78bfa"},
"tech": {"name": "Tech Frontier", "ip": "10.30.20.241", "ct": 136, "color": "#2563eb", "accent": "#60a5fa"},
"science": {"name": "Science Decoded", "ip": "10.30.20.242", "ct": 137, "color": "#059669", "accent": "#34d399"},
"crypto": {"name": "Crypto Compass", "ip": "10.30.20.243", "ct": 138, "color": "#f59e0b", "accent": "#fbbf24"},
"linux": {"name": "Linux Lab", "ip": "10.30.20.244", "ct": 139, "color": "#f97316", "accent": "#fb923c"},
"gaming": {"name": "Game Layer", "ip": "10.30.20.246", "ct": 140, "color": "#ec4899", "accent": "#f472b6"},
"diy": {"name": "Maker Forge", "ip": "10.30.20.247", "ct": 141, "color": "#ef4444", "accent": "#f87171"},
"guides": {"name": "Practical Guides", "ip": "10.30.20.248", "ct": 142, "color": "#0891b2", "accent": "#22d3ee"},
"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"},
}
# Cached fleet state — refreshed async
fleet_cache = {"data": {}, "updated": None, "lock": threading.Lock()}
cache = {"data": {}, "ts": None}
def fetch_site_state(vertical, info):
"""Query a single site for health + stats."""
ip = info["ip"]
state = {
"vertical": vertical,
"name": info["name"],
"ip": ip,
"ct": info["ct"],
"color": info["color"],
"accent": info["accent"],
"alive": False,
"articles": 0,
"views": 0,
"today_views": 0,
"subscribers": 0,
"latency_ms": 0,
"recent_articles": [],
"popular": [],
"error": None,
}
import requests
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://{ip}:80/health", timeout=5)
state["latency_ms"] = round((time.time() - t0) * 1000)
r = requests.get(f"http://{info['ip']}:80/health", timeout=5)
s["latency"] = round((time.time() - t0) * 1000)
if r.status_code == 200:
h = r.json()
state["alive"] = True
state["articles"] = h.get("articles", 0)
# Stats
r2 = requests.get(f"http://{ip}:80/api/stats", timeout=5)
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:
s = r2.json()
state["views"] = s.get("total_views", 0)
state["today_views"] = s.get("today_views", 0)
popular = s.get("popular", [])
state["popular"] = [{"title": a.get("title", "")[:80], "views": a.get("views", 0)} for a in popular[:3]]
# Recent articles
r3 = requests.get(f"http://{ip}:80/api/articles?limit=3", timeout=5)
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:
arts = r3.json()
state["recent_articles"] = [
{"title": a.get("title", "")[:80], "slug": a.get("slug", ""),
"date": a.get("published_at", "")[:10], "words": a.get("word_count", 0)}
for a in arts[:3]
]
# Subscriber count
try:
r4 = requests.get(f"http://{ip}:5000/api/subscribers", timeout=3)
if r4.status_code == 200:
state["subscribers"] = r4.json().get("count", 0)
except Exception:
pass
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:
state["error"] = str(e)[:80]
s["error"] = str(e)[:50]
return vertical, s
return state
def refresh_fleet():
"""Background refresh of all 8 sites."""
def refresh():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(fetch_site_state, v, i): v for v, i in SITES.items()}
results = {}
for future in concurrent.futures.as_completed(futures):
vertical = futures[future]
try:
results[vertical] = future.result(timeout=10)
except Exception:
results[vertical] = {"vertical": vertical, "alive": False, "error": "timeout"}
with fleet_cache["lock"]:
fleet_cache["data"] = results
fleet_cache["updated"] = datetime.now().isoformat()
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 noc():
"""Main NOC dashboard."""
# Trigger a refresh if stale (>30s)
if fleet_cache["updated"] is None:
refresh_fleet()
elif (datetime.now() - datetime.fromisoformat(fleet_cache["updated"])).seconds > 30:
threading.Thread(target=refresh_fleet, daemon=True).start()
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),
)
with fleet_cache["lock"]:
data = dict(fleet_cache["data"])
updated = fleet_cache["updated"]
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]
# Compute aggregates
alive = sum(1 for s in data.values() if s.get("alive"))
total_articles = sum(s.get("articles", 0) for s in data.values())
total_views = sum(s.get("views", 0) for s in data.values())
total_today = sum(s.get("today_views", 0) for s in data.values())
total_subs = sum(s.get("subscribers", 0) for s in data.values())
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>'
sites_html = ""
for vertical, s in data.items():
status_dot = "🟢" if s.get("alive") else "🔴"
latency = f"{s.get('latency_ms', 0)}ms"
lat_class = "fast" if s.get("latency_ms", 999) < 100 else ("medium" if s.get("latency_ms", 999) < 300 else "slow")
# Build recent articles list
recent_html = ""
for art in s.get("recent_articles", []):
recent_html += f'<div class="art-row"><span class="art-date">{art["date"]}</span><a href="http://{s["ip"]}/articles/{art["slug"]}" target="_blank" class="art-link">{art["title"][:65]}</a><span class="art-words">{art["words"]}w</span></div>'
if not recent_html:
recent_html = '<div class="art-row" style="color:var(--dim)">No articles yet</div>'
sites_html += f"""
<div class="site-card" style="border-left: 3px solid {s['color']}">
<div class="site-header">
<div class="site-name-row">
<span class="status-dot">{status_dot}</span>
<h3 style="color:{s['color']}">{s['name']}</h3>
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>
<div class="site-badges">
<span class="badge" style="background:{s['color']}20;color:{s['color']}">{vertical}.thetempleofdoom.com</span>
<a href="http://{s['ip']}" target="_blank" class="badge ip-badge">🖥 {s['ip']}:80</a>
<span class="badge ct-badge">CT {s['ct']}</span>
<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="site-metrics">
<div class="metric">
<div class="metric-value">{s.get('articles', 0)}</div>
<div class="metric-label">Articles</div>
<div class="ca">
<div class="cal">Latest Articles</div>
{arts}
</div>
<div class="metric">
<div class="metric-value">{s.get('views', 0)}</div>
<div class="metric-label">Views</div>
</div>
<div class="metric">
<div class="metric-value">{s.get('today_views', 0)}</div>
<div class="metric-label">Today</div>
</div>
<div class="metric">
<div class="metric-value">{s.get('subscribers', 0)}</div>
<div class="metric-label">Subs</div>
</div>
<div class="metric">
<div class="metric-value {lat_class}">{latency}</div>
<div class="metric-label">Latency</div>
</div>
<div class="metric">
<div class="metric-value" style="font-size:0.8rem">{(s.get('error') or '')[:20]}</div>
<div class="metric-label">Status</div>
</div>
</div>
<div class="recent-section">
<div class="section-label">📝 Latest</div>
{recent_html}
</div>
</div>"""
html = NOC_TEMPLATE
html = html.replace('__ALIVE__', str(alive))
html = html.replace('__TOTAL__', str(len(data)))
html = html.replace('__TOTAL_ARTICLES__', str(total_articles))
html = html.replace('__TOTAL_VIEWS__', str(total_views))
html = html.replace('__TOTAL_TODAY__', str(total_today))
html = html.replace('__TOTAL_SUBS__', str(total_subs))
html = html.replace('__UPDATED__', str(updated[:19] if updated else "never"))
html = html.replace('__SITES_HTML__', sites_html)
return html
</div>''')
return "\n".join(out)
@app.route("/api/fleet")
def api_fleet():
"""JSON fleet state."""
refresh_fleet()
with fleet_cache["lock"]:
return jsonify({
"updated": fleet_cache["updated"],
"sites": fleet_cache["data"],
})
refresh()
return jsonify({"updated": cache["ts"], "sites": cache["data"]})
@app.route("/api/refresh", methods=["POST"])
def api_refresh():
refresh_fleet()
return jsonify({"status": "refreshed", "updated": fleet_cache["updated"]})
refresh()
return jsonify({"status": "ok"})
@app.route("/health")
def health():
return jsonify({"status": "ok"})
return jsonify({"status": "ok", "ts": cache["ts"]})
# ─── Template ──────────────────────────────────────────────────────
NOC_TEMPLATE = """<!DOCTYPE html>
# ── Template ──
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NOC — Publisher Fleet Monitor</title>
<meta http-equiv="refresh" content="60">
<style>
:root {
--bg: #0a0e14;
--panel: #12171e;
--border: #1e293b;
--text: #e2e8f0;
--dim: #64748b;
--green: #10b981;
--red: #ef4444;
--font: 'SF Mono', 'JetBrains Mono', 'Fira Code', monospace;
}
* {{ box-sizing: border-box; margin: 0; padding: 0 }}
body {{
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}}
/* Scanlines overlay */
body::after {{
content: '';
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px);
pointer-events: none;
z-index: 9999;
}}
header {{
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}}
.logo {{
font-size: 1.1rem;
font-weight: 700;
color: var(--green);
letter-spacing: 0.05em;
}}
.logo span {{ color: var(--dim); font-weight: 400 }}
.header-right {{
display: flex;
gap: 2rem;
align-items: center;
font-size: 0.8rem;
color: var(--dim);
}}
.header-right strong {{ color: var(--text) }}
.stats-bar {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
padding: 1rem 2rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}}
.stat-mini {{
text-align: center;
padding: 0.5rem;
}}
.stat-mini .val {{ font-size: 1.5rem; font-weight: 700; color: var(--green) }}
.stat-mini .lbl {{ font-size: 0.65rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.15rem }}
main {{
padding: 1.5rem 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 1.25rem;
}}
.site-card {{
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}}
.site-card:hover {{ border-color: rgba(255,255,255,0.15) }}
.site-header {{ margin-bottom: 1rem }}
.site-name-row {{
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}}
.site-name-row h3 {{ font-size: 1.05rem; font-weight: 600 }}
.status-dot {{ font-size: 0.7rem }}
.site-badges {{
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}}
.badge {{
font-size: 0.65rem;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-family: var(--font);
text-decoration: none;
display: inline-block;
}}
.ip-badge {{ background: rgba(255,255,255,0.05); color: var(--dim) }}
.ip-badge:hover {{ color: var(--text); background: rgba(255,255,255,0.1) }}
.ct-badge {{ background: rgba(255,255,255,0.05); color: var(--dim); font-size: 0.6rem }}
.site-metrics {{
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}}
.metric {{ text-align: center }}
.metric-value {{ font-size: 1.1rem; font-weight: 700 }}
.metric-label {{ font-size: 0.6rem; color: var(--dim); text-transform: uppercase; margin-top: 0.1rem }}
.fast {{ color: var(--green) }}
.medium {{ color: #f59e0b }}
.slow {{ color: var(--red) }}
.recent-section {{ border-top: 1px solid var(--border); padding-top: 0.75rem }}
.section-label {{ font-size: 0.65rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem }}
.art-row {{
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0;
font-size: 0.75rem;
}}
.art-date {{ color: var(--dim); font-size: 0.7rem; min-width: 55px }}
.art-link {{ color: var(--text); text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }}
.art-link:hover {{ text-decoration: underline }}
.art-words {{ color: var(--dim); font-size: 0.65rem; min-width: 35px; text-align: right }}
footer {{
padding: 1rem 2rem;
border-top: 1px solid var(--border);
text-align: center;
color: var(--dim);
font-size: 0.7rem;
}}
@media (max-width: 900px) {{
main {{ grid-template-columns: 1fr }}
.site-metrics {{ grid-template-columns: repeat(3, 1fr) }}
}}
</style>
<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>
<header>
<div class="logo">NOC <span>// Publisher Fleet Monitor</span></div>
<div class="header-right">
<span>🟢 <strong>__ALIVE__</strong>/__TOTAL__ online</span>
<span>🕐 __UPDATED__</span>
<span>🔄 auto-refresh 60s</span>
<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>
</header>
<div class="stats-bar">
<div class="stat-mini"><div class="val">__TOTAL_ARTICLES__</div><div class="lbl">Total Articles</div></div>
<div class="stat-mini"><div class="val">__TOTAL_VIEWS__</div><div class="lbl">Total Views</div></div>
<div class="stat-mini"><div class="val">__TOTAL_TODAY__</div><div class="lbl">Today Views</div></div>
<div class="stat-mini"><div class="val">__TOTAL_SUBS__</div><div class="lbl">Subscribers</div></div>
<div class="stat-mini"><div class="val">__ALIVE__/8</div><div class="lbl">Sites Live</div></div>
<div class="stat-mini"><div class="val">6AM</div><div class="lbl">Next Publish</div></div>
</div>
<main>
__SITES_HTML__
</main>
<footer>
NOC Dashboard · Auto Publisher Fleet · Updated __UPDATED__ ·
<a href="http://localhost:5106" style="color:var(--dim)">Admin Panel</a> ·
<a href="http://localhost:5053/fleet" style="color:var(--dim)">Kalshi Fleet</a>
</footer>
</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", type=str, default="127.0.0.1")
args = ap.parse_args()
# Initial refresh
refresh_fleet()
print(f"🖥 NOC Dashboard → http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=False)
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)