435 lines
16 KiB
Python
435 lines
16 KiB
Python
"""
|
|
Network Operations Center — Real-time monitoring dashboard for all 8 publisher sites.
|
|
Hacker aesthetic, live data, comprehensive fleet overview.
|
|
"""
|
|
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
|
|
|
|
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"},
|
|
}
|
|
|
|
# Cached fleet state — refreshed async
|
|
fleet_cache = {"data": {}, "updated": None, "lock": threading.Lock()}
|
|
|
|
|
|
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
|
|
try:
|
|
t0 = time.time()
|
|
r = requests.get(f"http://{ip}:80/health", timeout=5)
|
|
state["latency_ms"] = 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)
|
|
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)
|
|
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
|
|
|
|
except Exception as e:
|
|
state["error"] = str(e)[:80]
|
|
|
|
return state
|
|
|
|
|
|
def refresh_fleet():
|
|
"""Background refresh of all 8 sites."""
|
|
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()
|
|
|
|
|
|
@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()
|
|
|
|
with fleet_cache["lock"]:
|
|
data = dict(fleet_cache["data"])
|
|
updated = fleet_cache["updated"]
|
|
|
|
# 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())
|
|
|
|
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>
|
|
</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>
|
|
</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>
|
|
<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
|
|
|
|
|
|
@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"],
|
|
})
|
|
|
|
|
|
@app.route("/api/refresh", methods=["POST"])
|
|
def api_refresh():
|
|
refresh_fleet()
|
|
return jsonify({"status": "refreshed", "updated": fleet_cache["updated"]})
|
|
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
return jsonify({"status": "ok"})
|
|
|
|
|
|
# ─── Template ──────────────────────────────────────────────────────
|
|
NOC_TEMPLATE = """<!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>
|
|
</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>
|
|
</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>
|
|
</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)
|