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. NOC Dashboard — clean, modern fleet monitor for all 8 publisher sites.
Hacker aesthetic, live data, comprehensive fleet overview.
""" """
import json import json, time, threading, requests
import time from datetime import datetime
import sqlite3 from flask import Flask, jsonify
import threading
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
from flask import Flask, render_template_string, jsonify, request
app = Flask(__name__) app = Flask(__name__)
SITES = { SITES = {
"ai": {"name": "AI Insights", "ip": "10.30.20.240", "ct": 135, "color": "#7c3aed", "accent": "#a78bfa"}, "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", "accent": "#60a5fa"}, "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", "accent": "#34d399"}, "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", "accent": "#fbbf24"}, "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", "accent": "#fb923c"}, "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", "accent": "#f472b6"}, "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", "accent": "#f87171"}, "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", "accent": "#22d3ee"}, "guides": {"name": "Practical Guides", "ip": "10.30.20.248", "ct": 142, "color": "#0891b2"},
} }
# Cached fleet state — refreshed async cache = {"data": {}, "ts": None}
fleet_cache = {"data": {}, "updated": None, "lock": threading.Lock()}
def probe(vertical, info):
def fetch_site_state(vertical, info): s = dict(vertical=vertical, name=info["name"], ip=info["ip"], ct=info["ct"],
"""Query a single site for health + stats.""" color=info["color"], alive=False, articles=0, views=0, today=0,
ip = info["ip"] subs=0, latency=0, arts=[], error=None)
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
try: try:
t0 = time.time() t0 = time.time()
r = requests.get(f"http://{ip}:80/health", timeout=5) r = requests.get(f"http://{info['ip']}:80/health", timeout=5)
state["latency_ms"] = round((time.time() - t0) * 1000) s["latency"] = round((time.time() - t0) * 1000)
if r.status_code == 200: if r.status_code == 200:
h = r.json() s["alive"] = True
state["alive"] = True s["articles"] = r.json().get("articles", 0)
state["articles"] = h.get("articles", 0) r2 = requests.get(f"http://{info['ip']}:80/api/stats", timeout=5)
# Stats
r2 = requests.get(f"http://{ip}:80/api/stats", timeout=5)
if r2.status_code == 200: if r2.status_code == 200:
s = r2.json() d = r2.json()
state["views"] = s.get("total_views", 0) s["views"] = d.get("total_views", 0)
state["today_views"] = s.get("today_views", 0) s["today"] = d.get("today_views", 0)
popular = s.get("popular", []) r3 = requests.get(f"http://{info['ip']}:80/api/articles?limit=3", timeout=5)
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)
if r3.status_code == 200: if r3.status_code == 200:
arts = r3.json() s["arts"] = [{"t": a.get("title", "")[:65], "s": a.get("slug", ""),
state["recent_articles"] = [ "d": (a.get("published_at") or "")[:10], "w": a.get("word_count", 0)}
{"title": a.get("title", "")[:80], "slug": a.get("slug", ""), for a in r3.json()[:3]]
"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
except Exception as e: except Exception as e:
state["error"] = str(e)[:80] s["error"] = str(e)[:50]
return vertical, s
return state def refresh():
def refresh_fleet():
"""Background refresh of all 8 sites."""
import concurrent.futures import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
futures = {executor.submit(fetch_site_state, v, i): v for v, i in SITES.items()} results = dict(ex.map(lambda x: probe(*x), [(v, i) for v, i in SITES.items()]))
results = {} cache["data"] = results
for future in concurrent.futures.as_completed(futures): cache["ts"] = datetime.now().isoformat()
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()
@app.route("/") @app.route("/")
def noc(): def home():
"""Main NOC dashboard.""" if not cache["ts"] or (datetime.now() - datetime.fromisoformat(cache["ts"])).seconds > 30:
# Trigger a refresh if stale (>30s) threading.Thread(target=refresh, daemon=True).start()
if fleet_cache["updated"] is None: data = cache["data"] or {}
refresh_fleet() return HTML.format(
elif (datetime.now() - datetime.fromisoformat(fleet_cache["updated"])).seconds > 30: alive=sum(1 for s in data.values() if s.get("alive")),
threading.Thread(target=refresh_fleet, daemon=True).start() 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"]: def build_cards(data):
data = dict(fleet_cache["data"]) out = []
updated = fleet_cache["updated"] 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 arts = ""
alive = sum(1 for s in data.values() if s.get("alive")) for a in s.get("arts", []):
total_articles = sum(s.get("articles", 0) for s in data.values()) 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>'
total_views = sum(s.get("views", 0) for s in data.values()) if not arts:
total_today = sum(s.get("today_views", 0) for s in data.values()) arts = '<div class="al" style="color:var(--t3);font-style:italic">No articles yet</div>'
total_subs = sum(s.get("subscribers", 0) for s in data.values())
sites_html = "" out.append(f'''<div class="card" style="border-top:2px solid {s['color']}">
for vertical, s in data.items(): <div class="ct">
status_dot = "🟢" if s.get("alive") else "🔴" <div class="cg">
latency = f"{s.get('latency_ms', 0)}ms" <div class="cs"><div class="cd {dot}"></div><div class="cn">{s['name']}</div></div>
lat_class = "fast" if s.get("latency_ms", 999) < 100 else ("medium" if s.get("latency_ms", 999) < 300 else "slow") <div class="cdm">{v}.thetempleofdoom.com</div>
# 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>
</div> </div>
<div class="site-badges"> <a href="http://{s['ip']}" target="_blank" class="cip">🖥 {s['ip']}:80</a>
<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>
</div> </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>
<div class="site-metrics"> <div class="ca">
<div class="metric"> <div class="cal">Latest Articles</div>
<div class="metric-value">{s.get('articles', 0)}</div> {arts}
<div class="metric-label">Articles</div>
</div> </div>
<div class="metric"> </div>''')
<div class="metric-value">{s.get('views', 0)}</div> return "\n".join(out)
<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
@app.route("/api/fleet") @app.route("/api/fleet")
def api_fleet(): def api_fleet():
"""JSON fleet state.""" refresh()
refresh_fleet() return jsonify({"updated": cache["ts"], "sites": cache["data"]})
with fleet_cache["lock"]:
return jsonify({
"updated": fleet_cache["updated"],
"sites": fleet_cache["data"],
})
@app.route("/api/refresh", methods=["POST"]) @app.route("/api/refresh", methods=["POST"])
def api_refresh(): def api_refresh():
refresh_fleet() refresh()
return jsonify({"status": "refreshed", "updated": fleet_cache["updated"]}) return jsonify({"status": "ok"})
@app.route("/health") @app.route("/health")
def health(): def health():
return jsonify({"status": "ok"}) return jsonify({"status": "ok", "ts": cache["ts"]})
# ── Template ──
# ─── Template ────────────────────────────────────────────────────── HTML = """<!DOCTYPE html>
NOC_TEMPLATE = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>NOC — Publisher Fleet Monitor</title> <title>Publisher Fleet</title>
<meta http-equiv="refresh" content="60"> <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> <style>
:root { :root{{--bg:#09090b;--s1:#121214;--s2:#1a1a1f;--b:#27272a;--t:#fafafa;--t2:#a1a1aa;--t3:#71717a;--r:10px;--f:'Inter',-apple-system,sans-serif}}
--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}} *{{box-sizing:border-box;margin:0;padding:0}}
body {{ body{{font-family:var(--f);background:var(--bg);color:var(--t);min-height:100vh;-webkit-font-smoothing:antialiased}}
font-family: var(--font); .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}}
background: var(--bg); .tbl{{display:flex;align-items:center;gap:.75rem}}
color: var(--text); .tbd{{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5)}}
min-height: 100vh; .tbn{{font-weight:800;font-size:1rem;letter-spacing:-.02em}}
-webkit-font-smoothing: antialiased; .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}}
/* Scanlines overlay */ .tba{{display:flex;gap:.75rem}}
body::after {{ .tba a{{color:var(--t3);text-decoration:none;font-size:.75rem;padding:.3rem .6rem;border-radius:6px;transition:all .15s}}
content: ''; .tba a:hover{{color:var(--t);background:var(--s2)}}
position: fixed; .sr{{display:grid;grid-template-columns:repeat(6,1fr);gap:.75rem;padding:1.25rem 2rem;max-width:1400px;margin:0 auto}}
top: 0; left: 0; width: 100%; height: 100%; .sb{{background:var(--s1);border:1px solid var(--b);border-radius:var(--r);padding:1rem;text-align:center;transition:border-color .15s}}
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px); .sb:hover{{border-color:var(--t3)}}
pointer-events: none; .sv{{font-size:1.6rem;font-weight:700;letter-spacing:-.03em;line-height:1}}
z-index: 9999; .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}}
header {{ .card:hover{{border-color:var(--t3);box-shadow:0 4px 20px rgba(0,0,0,.3)}}
background: var(--panel); .ct{{padding:1.25rem 1.25rem .75rem;display:flex;justify-content:space-between;align-items:flex-start}}
border-bottom: 1px solid var(--border); .cg{{flex:1;min-width:0}}
padding: 1rem 2rem; .cs{{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem}}
display: flex; .cd{{width:7px;height:7px;border-radius:50%;flex-shrink:0}}
justify-content: space-between; .cd.up{{background:#22c55e;box-shadow:0 0 6px rgba(34,197,94,.4)}}
align-items: center; .cd.down{{background:#ef4444;box-shadow:0 0 6px rgba(239,68,68,.4)}}
position: sticky; .cn{{font-weight:700;font-size:1rem;letter-spacing:-.01em}}
top: 0; .cdm{{font-size:.7rem;color:var(--t3);margin-top:.1rem}}
z-index: 100; .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)}}
.logo {{ .cm{{display:grid;grid-template-columns:repeat(6,1fr);border-top:1px solid var(--b);border-bottom:1px solid var(--b)}}
font-size: 1.1rem; .cmi{{padding:.75rem .5rem;text-align:center;border-right:1px solid var(--b)}}
font-weight: 700; .cmi:last-child{{border-right:none}}
color: var(--green); .cmv{{font-size:.95rem;font-weight:700;letter-spacing:-.02em}}
letter-spacing: 0.05em; .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}}
.logo span {{ color: var(--dim); font-weight: 400 }} .ca{{padding:.75rem 1.25rem}}
.header-right {{ .cal{{font-size:.65rem;color:var(--t3);text-transform:uppercase;letter-spacing:.06em;font-weight:600;margin-bottom:.5rem}}
display: flex; .al{{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;font-size:.78rem;border-bottom:1px solid var(--b)}}
gap: 2rem; .al:last-child{{border-bottom:none}}
align-items: center; .ad{{color:var(--t3);font-size:.68rem;min-width:48px;font-variant-numeric:tabular-nums}}
font-size: 0.8rem; .at{{color:var(--t2);text-decoration:none;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:color .15s}}
color: var(--dim); .at:hover{{color:var(--t)}}
}} .aw{{color:var(--t3);font-size:.65rem;min-width:32px;text-align:right}}
.header-right strong {{ color: var(--text) }} .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}}
.stats-bar {{ .ft a:hover{{color:var(--t)}}
display: grid; @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}}}}
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> </style>
</head> </head>
<body> <body>
<header> <div class="tb">
<div class="logo">NOC <span>// Publisher Fleet Monitor</span></div> <div class="tbl"><div class="tbd"></div><div class="tbn">Publisher Fleet<span>NOC</span></div></div>
<div class="header-right"> <div class="tbr">
<span>🟢 <strong>__ALIVE__</strong>/__TOTAL__ online</span> <span><strong>{alive}</strong> / {total} online</span>
<span>🕐 __UPDATED__</span> <span>{updated}</span>
<span>🔄 auto-refresh 60s</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>
</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> </div>
<div class="sr">
<main> <div class="sb"><div class="sv" style="color:#fafafa">{articles}</div><div class="sl">Articles</div></div>
__SITES_HTML__ <div class="sb"><div class="sv" style="color:#22c55e">{views}</div><div class="sl">Total Views</div></div>
</main> <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>
<footer> <div class="sb"><div class="sv" style="color:#f59e0b">6 AM</div><div class="sl">Next Publish</div></div>
NOC Dashboard · Auto Publisher Fleet · Updated __UPDATED__ · <div class="sb"><div class="sv" style="color:#ec4899">{articles}</div><div class="sl">Network Total</div></div>
<a href="http://localhost:5106" style="color:var(--dim)">Admin Panel</a> · </div>
<a href="http://localhost:5053/fleet" style="color:var(--dim)">Kalshi Fleet</a> <div class="gr">{cards}</div>
</footer> <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> </body>
</html>""" </html>"""
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=5090) ap.add_argument("--port", type=int, default=5090)
ap.add_argument("--host", type=str, default="127.0.0.1") ap.add_argument("--host", default="127.0.0.1")
args = ap.parse_args() a = ap.parse_args()
print(f"NOC → http://{a.host}:{a.port}")
# Initial refresh refresh()
refresh_fleet() app.run(host=a.host, port=a.port, debug=False)
print(f"🖥 NOC Dashboard → http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=False)