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:
Marc Shade
2026-02-23 12:20:48 -05:00
parent c12f45eaa8
commit 7a4036dc8d
4 changed files with 793 additions and 0 deletions
+11
View File
@@ -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."""
+183
View File
@@ -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,
)
+598
View File
@@ -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>
&nbsp;&middot;&nbsp;
<span id="sourceHealth">--</span>
&nbsp;&middot;&nbsp;
<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>