feat: add live intelligence dashboard with SSE streaming
Starlette app serving a real-time dashboard at http://127.0.0.1:8501. 17 intelligence domains updated every 30s via Server-Sent Events: markets, crypto, sectors, macro, cyber, military, earthquakes, wildfires, climate, news, predictions, energy, aviation, infra, cables, nav warnings, trending keywords. - DOMPurify for XSS-safe DOM updates - Exponential backoff SSE reconnection - Responsive dark-theme grid layout with Chart.js - CLI: `intel dashboard [--port 8501]` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c12f45eaa8
commit
7a4036dc8d
@@ -974,5 +974,16 @@ def sync_cmd(source: str | None) -> None:
|
||||
console.print(f"Evicted {removed} expired cache entries")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("--port", default=8501, type=int, help="Port to listen on")
|
||||
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
||||
def dashboard(port: int, host: str) -> None:
|
||||
"""Launch the live intelligence dashboard."""
|
||||
from .dashboard.app import run as run_dashboard
|
||||
|
||||
console.print(f"[bold]Starting Intelligence Dashboard[/bold] on http://{host}:{port}")
|
||||
run_dashboard(host=host, port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""World Intelligence live dashboard."""
|
||||
@@ -0,0 +1,183 @@
|
||||
"""World Intelligence Dashboard — live real-time intelligence overview.
|
||||
|
||||
Starlette app serving a self-contained HTML dashboard with SSE streaming.
|
||||
All data pulled from the same source modules used by the MCP server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from world_intel_mcp.cache import Cache
|
||||
from world_intel_mcp.circuit_breaker import CircuitBreaker
|
||||
from world_intel_mcp.fetcher import Fetcher
|
||||
from world_intel_mcp.sources import (
|
||||
markets,
|
||||
seismology,
|
||||
military,
|
||||
infrastructure,
|
||||
maritime,
|
||||
economic,
|
||||
wildfire,
|
||||
cyber,
|
||||
news,
|
||||
prediction,
|
||||
displacement,
|
||||
aviation,
|
||||
climate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared infrastructure — lazily initialised
|
||||
# ---------------------------------------------------------------------------
|
||||
_fetcher: Fetcher | None = None
|
||||
_cache: Cache | None = None
|
||||
_breaker: CircuitBreaker | None = None
|
||||
|
||||
|
||||
def _ensure_fetcher() -> Fetcher:
|
||||
global _fetcher, _cache, _breaker
|
||||
if _fetcher is None:
|
||||
_cache = Cache()
|
||||
_breaker = CircuitBreaker()
|
||||
_fetcher = Fetcher(cache=_cache, breaker=_breaker)
|
||||
return _fetcher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_overview() -> dict:
|
||||
"""Fetch all dashboard domains in parallel, return unified dict."""
|
||||
fetcher = _ensure_fetcher()
|
||||
|
||||
coros = {
|
||||
"market_quotes": markets.fetch_market_quotes(fetcher),
|
||||
"crypto_quotes": markets.fetch_crypto_quotes(fetcher),
|
||||
"macro_signals": markets.fetch_macro_signals(fetcher),
|
||||
"sector_heatmap": markets.fetch_sector_heatmap(fetcher),
|
||||
"earthquakes": seismology.fetch_earthquakes(fetcher),
|
||||
"military_flights": military.fetch_military_flights(fetcher),
|
||||
"cyber_threats": cyber.fetch_cyber_threats(fetcher),
|
||||
"news_feed": news.fetch_news_feed(fetcher),
|
||||
"trending_keywords": news.fetch_trending_keywords(fetcher),
|
||||
"nav_warnings": maritime.fetch_nav_warnings(fetcher),
|
||||
"internet_outages": infrastructure.fetch_internet_outages(fetcher),
|
||||
"cable_health": infrastructure.fetch_cable_health(fetcher),
|
||||
"wildfires": wildfire.fetch_wildfires(fetcher),
|
||||
"prediction_markets": prediction.fetch_prediction_markets(fetcher),
|
||||
"airport_delays": aviation.fetch_airport_delays(fetcher),
|
||||
"climate_anomalies": climate.fetch_climate_anomalies(fetcher),
|
||||
"energy_prices": economic.fetch_energy_prices(fetcher),
|
||||
}
|
||||
|
||||
gathered = await asyncio.gather(
|
||||
*[asyncio.create_task(c) for c in coros.values()],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
result: dict = {}
|
||||
for name, data in zip(coros.keys(), gathered):
|
||||
if isinstance(data, Exception):
|
||||
logger.warning("Dashboard fetch %s failed: %s", name, data)
|
||||
result[name] = {"error": type(data).__name__ + ": " + str(data)[:120]}
|
||||
else:
|
||||
result[name] = data
|
||||
|
||||
# Attach source health + timestamp
|
||||
result["source_health"] = _breaker.status() if _breaker else {}
|
||||
result["cache_stats"] = _cache.stats() if _cache else {}
|
||||
result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INDEX_HTML: str | None = None
|
||||
|
||||
|
||||
async def index(request):
|
||||
"""Serve the dashboard HTML page."""
|
||||
global _INDEX_HTML
|
||||
if _INDEX_HTML is None:
|
||||
html_path = Path(__file__).parent / "index.html"
|
||||
_INDEX_HTML = html_path.read_text()
|
||||
return HTMLResponse(_INDEX_HTML)
|
||||
|
||||
|
||||
async def api_overview(request):
|
||||
"""REST endpoint — full snapshot of all intelligence domains."""
|
||||
data = await _fetch_overview()
|
||||
return JSONResponse(data, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
async def api_stream(request):
|
||||
"""SSE endpoint — pushes full overview every 30 seconds."""
|
||||
|
||||
async def event_generator():
|
||||
while True:
|
||||
try:
|
||||
data = await _fetch_overview()
|
||||
payload = json.dumps(data, default=str)
|
||||
yield f"data: {payload}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.exception("SSE tick failed")
|
||||
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
||||
await asyncio.sleep(30)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def api_health(request):
|
||||
"""Health check."""
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/", index),
|
||||
Route("/api/overview", api_overview),
|
||||
Route("/api/stream", api_stream),
|
||||
Route("/api/health", api_health),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def run(host: str = "127.0.0.1", port: int = 8501) -> None:
|
||||
"""Launch the dashboard server."""
|
||||
import uvicorn
|
||||
|
||||
logger.info("Starting Intelligence Dashboard on http://%s:%d", host, port)
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=False,
|
||||
)
|
||||
@@ -0,0 +1,598 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Phoenix Intelligence Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', 'Fira Code', monospace; background: #0a0e1a; color: #c8d6e5; line-height: 1.5; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1a1f36 100%);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.status-bar .title { font-size: 1rem; font-weight: 700; color: #f1f5f9; letter-spacing: 0.5px; }
|
||||
.status-bar .title span { color: #f59e0b; }
|
||||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot-green { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
||||
.dot-yellow { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
|
||||
.dot-red { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
|
||||
.pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Grid layout */
|
||||
.container { max-width: 1600px; margin: 0 auto; padding: 1rem; }
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: linear-gradient(180deg, #111827 0%, #0f1523 100%);
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.card:hover { border-color: #334155; }
|
||||
.card-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.75rem; padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.card-title { font-size: 0.85rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.card-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: #1e293b; color: #64748b; }
|
||||
.card-error { color: #64748b; font-style: italic; font-size: 0.85rem; padding: 1rem 0; }
|
||||
.span-2 { grid-column: span 2; }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
th { color: #64748b; font-weight: 500; text-align: left; padding: 0.35rem 0.5rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 0.35rem 0.5rem; border-top: 1px solid #1a2236; white-space: nowrap; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
|
||||
/* Values */
|
||||
.up { color: #22c55e; }
|
||||
.down { color: #ef4444; }
|
||||
.muted { color: #4a5568; }
|
||||
.bright { color: #f1f5f9; font-weight: 600; }
|
||||
.warn { color: #f59e0b; }
|
||||
|
||||
/* Stat boxes */
|
||||
.stat-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.stat-box {
|
||||
flex: 1; min-width: 100px;
|
||||
background: #0d1117; border: 1px solid #1e293b; border-radius: 6px;
|
||||
padding: 0.75rem; text-align: center;
|
||||
}
|
||||
.stat-value { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; }
|
||||
.stat-label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; margin-top: 0.25rem; }
|
||||
|
||||
/* Heatmap cells */
|
||||
.heatmap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.hm-cell {
|
||||
padding: 6px 10px; border-radius: 4px; font-size: 0.78rem;
|
||||
font-weight: 600; min-width: 90px; text-align: center;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; margin: 2px; background: #1a2236; color: #94a3b8; }
|
||||
.tag-hot { background: #7f1d1d; color: #fca5a5; }
|
||||
|
||||
/* Scrollable */
|
||||
.scroll-box { max-height: 280px; overflow-y: auto; }
|
||||
.scroll-box::-webkit-scrollbar { width: 4px; }
|
||||
.scroll-box::-webkit-scrollbar-thumb { background: #334155; border-radius: 2px; }
|
||||
|
||||
/* Severity badges */
|
||||
.sev-critical { color: #ef4444; font-weight: 700; }
|
||||
.sev-high { color: #f59e0b; }
|
||||
.sev-medium { color: #6b7280; }
|
||||
|
||||
/* Loading overlay */
|
||||
#loading {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0a0e1a; transition: opacity 0.5s;
|
||||
}
|
||||
#loading.hidden { opacity: 0; pointer-events: none; }
|
||||
.loader { width: 40px; height: 40px; border: 3px solid #1e293b; border-top-color: #f59e0b; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) { .grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 800px) { .grid-3, .grid-2 { grid-template-columns: 1fr; } .span-2 { grid-column: span 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="loading"><div class="loader"></div></div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<div class="title"><span>PHOENIX</span> INTELLIGENCE DASHBOARD</div>
|
||||
<div>
|
||||
<span class="status-dot dot-green pulse" id="connDot"></span>
|
||||
<span id="connLabel">Connecting...</span>
|
||||
·
|
||||
<span id="sourceHealth">--</span>
|
||||
·
|
||||
<span id="lastUpdate">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Row 1: Market Overview -->
|
||||
<div class="grid grid-3" style="margin-top:1rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Equity Indices</span>
|
||||
<span class="card-badge" id="equityBadge">--</span>
|
||||
</div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Symbol</th><th>Price</th><th>Chg</th><th>%</th></tr></thead><tbody id="equityBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Cryptocurrency</span>
|
||||
<span class="card-badge" id="cryptoBadge">--</span>
|
||||
</div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Coin</th><th>Price</th><th>24h</th><th>MCap</th></tr></thead><tbody id="cryptoBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Macro Signals</span>
|
||||
<span class="card-badge">Live</span>
|
||||
</div>
|
||||
<div id="macroContent" class="stat-row" style="flex-wrap:wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Sector Heatmap + Energy -->
|
||||
<div class="grid grid-3" style="margin-top:1rem;">
|
||||
<div class="card span-2">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Sector Performance</span>
|
||||
</div>
|
||||
<div class="heatmap" id="sectorHeatmap"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Energy Prices</span>
|
||||
</div>
|
||||
<div id="energyContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Threats + Military + Infra -->
|
||||
<div class="grid grid-3" style="margin-top:1rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Cyber Threats</span>
|
||||
<span class="card-badge" id="cyberBadge">--</span>
|
||||
</div>
|
||||
<div id="cyberStats" class="stat-row" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Threat</th><th>Type</th><th>Severity</th></tr></thead><tbody id="cyberBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Military Activity</span>
|
||||
<span class="card-badge" id="milBadge">--</span>
|
||||
</div>
|
||||
<div id="milStats" class="stat-row" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Callsign</th><th>Type</th><th>Alt</th><th>Origin</th></tr></thead><tbody id="milBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Infrastructure</span>
|
||||
</div>
|
||||
<div id="infraStats" class="stat-row" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="scroll-box" id="infraContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Natural + News + Predictions -->
|
||||
<div class="grid grid-3" style="margin-top:1rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Natural Events</span>
|
||||
<span class="card-badge" id="naturalBadge">--</span>
|
||||
</div>
|
||||
<div id="naturalStats" class="stat-row" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Mag</th><th>Location</th><th>Depth</th><th>Time</th></tr></thead><tbody id="quakeBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Intelligence News</span>
|
||||
<span class="card-badge" id="newsBadge">--</span>
|
||||
</div>
|
||||
<div id="trendingKeywords" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Headline</th><th>Source</th></tr></thead><tbody id="newsBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Prediction Markets</span>
|
||||
<span class="card-badge" id="predBadge">--</span>
|
||||
</div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Market</th><th>Prob</th><th>Vol</th></tr></thead><tbody id="predBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 5: Aviation + Climate + Nav -->
|
||||
<div class="grid grid-3" style="margin-top:1rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Airport Delays</span>
|
||||
<span class="card-badge" id="aviationBadge">--</span>
|
||||
</div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Airport</th><th>Reason</th><th>Delay</th></tr></thead><tbody id="aviationBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Climate Anomalies</span>
|
||||
</div>
|
||||
<div class="scroll-box" id="climateContent"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Nav Warnings</span>
|
||||
<span class="card-badge" id="navBadge">--</span>
|
||||
</div>
|
||||
<div class="scroll-box">
|
||||
<table><thead><tr><th>Area</th><th>Warning</th></tr></thead><tbody id="navBody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========================================================================
|
||||
// Phoenix Intelligence Dashboard — Live Update Engine
|
||||
// All DOM updates use DOMPurify.sanitize() for XSS protection.
|
||||
// ========================================================================
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const safe = (html) => DOMPurify.sanitize(html, {ALLOWED_TAGS: ['tr','td','th','table','thead','tbody','a','span','div','br'], ALLOWED_ATTR: ['class','style','href','target']});
|
||||
|
||||
// Formatters
|
||||
function fmtNum(n, dec) { dec = dec != null ? dec : 2; return n == null ? '\u2014' : Number(n).toLocaleString(undefined, {minimumFractionDigits: dec, maximumFractionDigits: dec}); }
|
||||
function fmtPct(n) { return n == null ? '\u2014' : (n >= 0 ? '+' : '') + fmtNum(n); }
|
||||
function fmtBig(n) { if (n == null) return '\u2014'; if (Math.abs(n) >= 1e12) return '$' + (n/1e12).toFixed(1) + 'T'; if (Math.abs(n) >= 1e9) return '$' + (n/1e9).toFixed(1) + 'B'; if (Math.abs(n) >= 1e6) return '$' + (n/1e6).toFixed(1) + 'M'; return '$' + fmtNum(n, 0); }
|
||||
function cls(n) { return n == null ? '' : n >= 0 ? 'up' : 'down'; }
|
||||
function sevCls(s) { s = (s||'').toLowerCase(); return s === 'critical' ? 'sev-critical' : s === 'high' ? 'sev-high' : 'sev-medium'; }
|
||||
function ago(ts) {
|
||||
if (!ts) return '\u2014';
|
||||
var d = new Date(ts), s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s/3600) + 'h ago'; return Math.floor(s/86400) + 'd ago';
|
||||
}
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
function trunc(s, n) { n = n || 50; s = s || ''; return s.length > n ? s.slice(0, n) + '...' : s; }
|
||||
|
||||
// ========================================================================
|
||||
// Update functions — all use safe() for innerHTML
|
||||
// ========================================================================
|
||||
|
||||
function updateEquity(data) {
|
||||
if (!data || data.error) { $('#equityBody').innerHTML = safe('<tr><td colspan="4" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var quotes = data.quotes || data.data || (Array.isArray(data) ? data : []);
|
||||
$('#equityBadge').textContent = quotes.length + ' symbols';
|
||||
var rows = quotes.map(function(q) {
|
||||
var price = q.price || q.regularMarketPrice || 0;
|
||||
var chg = q.change || q.regularMarketChange || 0;
|
||||
var pct = q.change_pct || q.regularMarketChangePercent || 0;
|
||||
return '<tr><td class="bright">' + esc(q.symbol || '?') + '</td><td>' + fmtNum(price) + '</td><td class="' + cls(chg) + '">' + fmtPct(chg) + '</td><td class="' + cls(pct) + '">' + fmtPct(pct) + '%</td></tr>';
|
||||
}).join('');
|
||||
$('#equityBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateCrypto(data) {
|
||||
if (!data || data.error) { $('#cryptoBody').innerHTML = safe('<tr><td colspan="4" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var coins = data.coins || data.data || (Array.isArray(data) ? data : []);
|
||||
$('#cryptoBadge').textContent = coins.length + ' coins';
|
||||
var rows = coins.slice(0, 15).map(function(c) {
|
||||
var price = c.current_price || c.price || 0;
|
||||
var chg = c.price_change_percentage_24h || c.change_24h || 0;
|
||||
var mcap = c.market_cap || 0;
|
||||
return '<tr><td class="bright">' + esc((c.symbol || c.id || '?').toUpperCase()) + '</td><td>$' + fmtNum(price) + '</td><td class="' + cls(chg) + '">' + fmtPct(chg) + '%</td><td class="muted">' + fmtBig(mcap) + '</td></tr>';
|
||||
}).join('');
|
||||
$('#cryptoBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateMacro(data) {
|
||||
if (!data || data.error) { $('#macroContent').innerHTML = safe('<div class="card-error">Unavailable</div>'); return; }
|
||||
var signals = data.signals || data;
|
||||
if (typeof signals !== 'object') { $('#macroContent').innerHTML = safe('<div class="card-error">No data</div>'); return; }
|
||||
var html = '';
|
||||
for (var name in signals) {
|
||||
if (!signals.hasOwnProperty(name)) continue;
|
||||
var info = signals[name];
|
||||
if (info && typeof info === 'object' && !Array.isArray(info)) {
|
||||
var entries = Object.entries(info);
|
||||
var mainVal = entries[0] ? entries[0][1] : '\u2014';
|
||||
html += '<div class="stat-box"><div class="stat-value" style="font-size:1.1rem;">' + (typeof mainVal === 'number' ? fmtNum(mainVal, mainVal < 1 ? 4 : 2) : esc(String(mainVal))) + '</div><div class="stat-label">' + esc(name.replace(/_/g, ' ')) + '</div></div>';
|
||||
} else {
|
||||
html += '<div class="stat-box"><div class="stat-value" style="font-size:1.1rem;">' + (info != null ? esc(String(info)) : '\u2014') + '</div><div class="stat-label">' + esc(name.replace(/_/g, ' ')) + '</div></div>';
|
||||
}
|
||||
}
|
||||
$('#macroContent').innerHTML = safe(html);
|
||||
}
|
||||
|
||||
function updateSectorHeatmap(data) {
|
||||
if (!data || data.error) { $('#sectorHeatmap').innerHTML = safe('<div class="card-error">Unavailable</div>'); return; }
|
||||
var sectors = data.sectors || data.data || (Array.isArray(data) ? data : []);
|
||||
var html = sectors.map(function(s) {
|
||||
var pct = s.change_pct || 0;
|
||||
var bg, fg;
|
||||
if (pct >= 2) { bg = '#166534'; fg = '#86efac'; }
|
||||
else if (pct >= 0.5) { bg = '#14532d'; fg = '#86efac'; }
|
||||
else if (pct >= 0) { bg = '#1a2e1a'; fg = '#86efac'; }
|
||||
else if (pct >= -0.5) { bg = '#2e1a1a'; fg = '#fca5a5'; }
|
||||
else if (pct >= -2) { bg = '#7f1d1d'; fg = '#fca5a5'; }
|
||||
else { bg = '#991b1b'; fg = '#fca5a5'; }
|
||||
return '<div class="hm-cell" style="background:' + bg + ';color:' + fg + ';">' + esc(s.symbol || s.name || '?') + '<br>' + (pct >= 0 ? '+' : '') + pct.toFixed(1) + '%</div>';
|
||||
}).join('');
|
||||
$('#sectorHeatmap').innerHTML = safe(html);
|
||||
}
|
||||
|
||||
function updateEnergy(data) {
|
||||
if (!data || data.error) { $('#energyContent').innerHTML = safe('<div class="card-error">Unavailable</div>'); return; }
|
||||
var prices = data.prices || data.data || data;
|
||||
if (typeof prices !== 'object') { $('#energyContent').innerHTML = safe('<div class="card-error">No data</div>'); return; }
|
||||
var html = '<table><thead><tr><th>Commodity</th><th>Price</th></tr></thead><tbody>';
|
||||
var entries = Array.isArray(prices) ? prices : Object.entries(prices).map(function(e) { return Object.assign({name: e[0]}, typeof e[1] === 'object' ? e[1] : {value: e[1]}); });
|
||||
entries.forEach(function(item) {
|
||||
var name = item.name || item.series_id || '?';
|
||||
var val = item.value || item.price || item.last_value || '\u2014';
|
||||
html += '<tr><td>' + esc(name.replace(/_/g, ' ')) + '</td><td class="bright">' + (typeof val === 'number' ? fmtNum(val) : esc(String(val))) + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$('#energyContent').innerHTML = safe(html);
|
||||
}
|
||||
|
||||
function updateCyber(data) {
|
||||
if (!data || data.error) { $('#cyberBody').innerHTML = safe('<tr><td colspan="3" class="card-error">Unavailable</td></tr>'); $('#cyberStats').innerHTML = ''; return; }
|
||||
var threats = data.threats || data.data || (Array.isArray(data) ? data : []);
|
||||
var bySev = data.by_severity || {};
|
||||
$('#cyberBadge').textContent = threats.length + ' IOCs';
|
||||
$('#cyberStats').innerHTML = safe(
|
||||
'<div class="stat-box"><div class="stat-value sev-critical">' + (bySev.critical || 0) + '</div><div class="stat-label">Critical</div></div>' +
|
||||
'<div class="stat-box"><div class="stat-value sev-high">' + (bySev.high || 0) + '</div><div class="stat-label">High</div></div>' +
|
||||
'<div class="stat-box"><div class="stat-value">' + (bySev.medium || 0) + '</div><div class="stat-label">Medium</div></div>'
|
||||
);
|
||||
var rows = threats.slice(0, 20).map(function(t) {
|
||||
return '<tr><td>' + esc(trunc(t.indicator || t.url || t.ioc || '?', 40)) + '</td><td class="muted">' + esc(t.threat_type || t.type || t.source || '\u2014') + '</td><td class="' + sevCls(t.severity) + '">' + esc(t.severity || '\u2014') + '</td></tr>';
|
||||
}).join('');
|
||||
$('#cyberBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateMilitary(data) {
|
||||
if (!data || data.error) { $('#milBody').innerHTML = safe('<tr><td colspan="4" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var flights = data.flights || data.aircraft || data.data || (Array.isArray(data) ? data : []);
|
||||
var total = data.total_military_aircraft || flights.length;
|
||||
$('#milBadge').textContent = total + ' aircraft';
|
||||
$('#milStats').innerHTML = safe('<div class="stat-box"><div class="stat-value">' + total + '</div><div class="stat-label">Military Aircraft</div></div>');
|
||||
var rows = flights.slice(0, 15).map(function(f) {
|
||||
var alt = f.altitude ? Math.round(f.altitude) + 'ft' : f.baro_altitude ? Math.round(f.baro_altitude) + 'ft' : '\u2014';
|
||||
return '<tr><td class="bright">' + esc(f.callsign || f.icao24 || '?') + '</td><td class="muted">' + esc(f.type || f.category || '\u2014') + '</td><td>' + alt + '</td><td class="muted">' + esc(f.origin_country || '\u2014') + '</td></tr>';
|
||||
}).join('');
|
||||
$('#milBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateInfra(data_outages, data_cables) {
|
||||
var statsHtml = '';
|
||||
var contentHtml = '';
|
||||
if (data_outages && !data_outages.error) {
|
||||
var outages = data_outages.outages || data_outages.data || (Array.isArray(data_outages) ? data_outages : []);
|
||||
var count = data_outages.ongoing_count || outages.length;
|
||||
statsHtml += '<div class="stat-box"><div class="stat-value ' + (count > 0 ? 'warn' : '') + '">' + count + '</div><div class="stat-label">Outages</div></div>';
|
||||
if (outages.length > 0) {
|
||||
contentHtml += '<table><thead><tr><th>Location</th><th>Status</th></tr></thead><tbody>';
|
||||
outages.slice(0, 8).forEach(function(o) {
|
||||
contentHtml += '<tr><td>' + esc(o.location || o.asName || '?') + '</td><td class="warn">' + esc(o.status || 'ongoing') + '</td></tr>';
|
||||
});
|
||||
contentHtml += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
if (data_cables && !data_cables.error) {
|
||||
var score = data_cables.health_score || data_cables.overall_score;
|
||||
var warnings = data_cables.warnings || data_cables.data || [];
|
||||
var warnCount = Array.isArray(warnings) ? warnings.length : 0;
|
||||
statsHtml += '<div class="stat-box"><div class="stat-value">' + (score != null ? score + '%' : warnCount) + '</div><div class="stat-label">' + (score != null ? 'Cable Health' : 'Cable Warnings') + '</div></div>';
|
||||
}
|
||||
$('#infraStats').innerHTML = safe(statsHtml || '<div class="stat-box"><div class="stat-value muted">\u2014</div><div class="stat-label">No data</div></div>');
|
||||
$('#infraContent').innerHTML = safe(contentHtml || '<div class="card-error">No active outages</div>');
|
||||
}
|
||||
|
||||
function updateNatural(data_quakes, data_fires) {
|
||||
var statsHtml = '';
|
||||
if (data_quakes && !data_quakes.error) {
|
||||
var quakes = data_quakes.earthquakes || data_quakes.features || data_quakes.data || (Array.isArray(data_quakes) ? data_quakes : []);
|
||||
statsHtml += '<div class="stat-box"><div class="stat-value">' + quakes.length + '</div><div class="stat-label">Quakes</div></div>';
|
||||
$('#naturalBadge').textContent = quakes.length + ' events';
|
||||
var rows = quakes.slice(0, 15).map(function(q) {
|
||||
var mag = q.magnitude || (q.properties && q.properties.mag) || '?';
|
||||
var place = q.place || (q.properties && q.properties.place) || '?';
|
||||
var depth = q.depth || (q.geometry && q.geometry.coordinates && q.geometry.coordinates[2]) || '?';
|
||||
var time = q.time || (q.properties && q.properties.time) || '';
|
||||
var magClass = mag >= 6 ? 'sev-critical' : mag >= 5 ? 'sev-high' : '';
|
||||
return '<tr><td class="' + magClass + ' bright">' + (typeof mag === 'number' ? mag.toFixed(1) : mag) + '</td><td>' + esc(trunc(place, 35)) + '</td><td class="muted">' + (typeof depth === 'number' ? depth.toFixed(0) + 'km' : depth) + '</td><td class="muted">' + ago(time) + '</td></tr>';
|
||||
}).join('');
|
||||
$('#quakeBody').innerHTML = safe(rows);
|
||||
}
|
||||
if (data_fires && !data_fires.error) {
|
||||
var count = data_fires.fire_count || data_fires.count || 0;
|
||||
statsHtml += '<div class="stat-box"><div class="stat-value ' + (count > 100 ? 'warn' : '') + '">' + count + '</div><div class="stat-label">Wildfires</div></div>';
|
||||
}
|
||||
$('#naturalStats').innerHTML = safe(statsHtml || '<div class="card-error">Unavailable</div>');
|
||||
}
|
||||
|
||||
function updateNews(data_news, data_trending) {
|
||||
if (data_trending && !data_trending.error) {
|
||||
var keywords = data_trending.keywords || data_trending.data || (Array.isArray(data_trending) ? data_trending : []);
|
||||
var top = keywords.slice(0, 20);
|
||||
if (top.length > 0) {
|
||||
var khtml = top.map(function(k) {
|
||||
var word = typeof k === 'string' ? k : (k.keyword || k.word || k.term || '?');
|
||||
var count = typeof k === 'object' ? (k.count || k.frequency || '') : '';
|
||||
var hot = count && count > 5;
|
||||
return '<span class="tag ' + (hot ? 'tag-hot' : '') + '">' + esc(word) + (count ? ' (' + count + ')' : '') + '</span>';
|
||||
}).join('');
|
||||
$('#trendingKeywords').innerHTML = safe(khtml);
|
||||
}
|
||||
}
|
||||
if (data_news && !data_news.error) {
|
||||
var articles = data_news.articles || data_news.items || data_news.data || (Array.isArray(data_news) ? data_news : []);
|
||||
$('#newsBadge').textContent = articles.length + ' items';
|
||||
var rows = articles.slice(0, 20).map(function(a) {
|
||||
var title = esc(trunc(a.title || '?', 55));
|
||||
var link = a.link ? '<a href="' + esc(a.link) + '" target="_blank">' + title + '</a>' : title;
|
||||
return '<tr><td>' + link + '</td><td class="muted">' + esc(trunc(a.source || a.feed || '', 15)) + '</td></tr>';
|
||||
}).join('');
|
||||
$('#newsBody').innerHTML = safe(rows);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePredictions(data) {
|
||||
if (!data || data.error) { $('#predBody').innerHTML = safe('<tr><td colspan="3" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var mkts = data.markets || data.data || (Array.isArray(data) ? data : []);
|
||||
$('#predBadge').textContent = mkts.length + ' markets';
|
||||
var rows = mkts.slice(0, 15).map(function(m) {
|
||||
var prob = m.probability || m.outcomePrices || m.yes_price;
|
||||
var vol = m.volume || m.liquidityCloses || 0;
|
||||
return '<tr><td>' + esc(trunc(m.question || m.title || m.groupItemTitle || '?', 45)) + '</td><td class="bright">' + (prob != null ? (typeof prob === 'number' ? (prob * 100).toFixed(0) + '%' : esc(String(prob))) : '\u2014') + '</td><td class="muted">' + fmtBig(vol) + '</td></tr>';
|
||||
}).join('');
|
||||
$('#predBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateAviation(data) {
|
||||
if (!data || data.error) { $('#aviationBody').innerHTML = safe('<tr><td colspan="3" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var delays = data.delays || data.data || (Array.isArray(data) ? data : []);
|
||||
$('#aviationBadge').textContent = delays.length + ' delays';
|
||||
if (delays.length === 0) { $('#aviationBody').innerHTML = safe('<tr><td colspan="3" class="card-error">No active delays</td></tr>'); return; }
|
||||
var rows = delays.slice(0, 15).map(function(d) {
|
||||
return '<tr><td class="bright">' + esc(d.airport || d.iata || d.name || '?') + '</td><td class="muted">' + esc(trunc(d.reason || d.type || '\u2014', 30)) + '</td><td class="warn">' + esc(d.delay || d.average || d.min || '\u2014') + '</td></tr>';
|
||||
}).join('');
|
||||
$('#aviationBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
function updateClimate(data) {
|
||||
if (!data || data.error) { $('#climateContent').innerHTML = safe('<div class="card-error">Unavailable</div>'); return; }
|
||||
var anomalies = data.anomalies || data.zones || data.data || (Array.isArray(data) ? data : []);
|
||||
if (anomalies.length === 0) { $('#climateContent').innerHTML = safe('<div class="card-error">No anomalies detected</div>'); return; }
|
||||
var html = '<table><thead><tr><th>Zone</th><th>Temp</th><th>Precip</th></tr></thead><tbody>';
|
||||
anomalies.slice(0, 15).forEach(function(a) {
|
||||
var temp = a.temperature_anomaly || a.temp_anomaly || a.temp_deviation;
|
||||
var precip = a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation;
|
||||
html += '<tr><td>' + esc(a.zone || a.name || a.region || '?') + '</td><td class="' + (temp > 0 ? 'warn' : 'up') + '">' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '</td><td class="muted">' + (precip != null ? fmtNum(precip, 1) + 'mm' : '\u2014') + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$('#climateContent').innerHTML = safe(html);
|
||||
}
|
||||
|
||||
function updateNavWarnings(data) {
|
||||
if (!data || data.error) { $('#navBody').innerHTML = safe('<tr><td colspan="2" class="card-error">Unavailable</td></tr>'); return; }
|
||||
var warnings = data.warnings || data.data || (Array.isArray(data) ? data : []);
|
||||
$('#navBadge').textContent = warnings.length + ' active';
|
||||
if (warnings.length === 0) { $('#navBody').innerHTML = safe('<tr><td colspan="2" class="card-error">No active warnings</td></tr>'); return; }
|
||||
var rows = warnings.slice(0, 12).map(function(w) {
|
||||
return '<tr><td class="bright">' + esc(w.navArea || w.area || w.subregion || '?') + '</td><td class="muted">' + esc(trunc(w.text || w.description || w.subject || '\u2014', 50)) + '</td></tr>';
|
||||
}).join('');
|
||||
$('#navBody').innerHTML = safe(rows);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Master update
|
||||
// ========================================================================
|
||||
|
||||
function updateAll(data) {
|
||||
updateEquity(data.market_quotes);
|
||||
updateCrypto(data.crypto_quotes);
|
||||
updateMacro(data.macro_signals);
|
||||
updateSectorHeatmap(data.sector_heatmap);
|
||||
updateEnergy(data.energy_prices);
|
||||
updateCyber(data.cyber_threats);
|
||||
updateMilitary(data.military_flights);
|
||||
updateInfra(data.internet_outages, data.cable_health);
|
||||
updateNatural(data.earthquakes, data.wildfires);
|
||||
updateNews(data.news_feed, data.trending_keywords);
|
||||
updatePredictions(data.prediction_markets);
|
||||
updateAviation(data.airport_delays);
|
||||
updateClimate(data.climate_anomalies);
|
||||
updateNavWarnings(data.nav_warnings);
|
||||
|
||||
if (data.timestamp) {
|
||||
var t = new Date(data.timestamp);
|
||||
$('#lastUpdate').textContent = t.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SSE connection with exponential backoff reconnection
|
||||
// ========================================================================
|
||||
|
||||
var reconnectDelay = 1000;
|
||||
|
||||
function connectSSE() {
|
||||
var source = new EventSource('/api/stream');
|
||||
|
||||
source.onopen = function() {
|
||||
$('#connDot').className = 'status-dot dot-green pulse';
|
||||
$('#connLabel').textContent = 'Live';
|
||||
reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
source.onmessage = function(event) {
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
if (data.error && !data.market_quotes) {
|
||||
console.warn('Stream error:', data.error);
|
||||
return;
|
||||
}
|
||||
updateAll(data);
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = function() {
|
||||
source.close();
|
||||
$('#connDot').className = 'status-dot dot-red';
|
||||
$('#connLabel').textContent = 'Reconnecting...';
|
||||
setTimeout(connectSSE, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user