NOC v2: clean premium design — Inter font, proper cards, no scanlines, color-coded metrics
This commit is contained in:
563
dashboard/noc.py
563
dashboard/noc.py
@@ -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]
|
||||
|
||||
return state
|
||||
s["error"] = str(e)[:50]
|
||||
return vertical, s
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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>"""
|
||||
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),
|
||||
)
|
||||
|
||||
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
|
||||
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():
|
||||
"""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>
|
||||
</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 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", 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)
|
||||
|
||||
Reference in New Issue
Block a user