All checks were successful
build-and-deploy / build (push) Successful in 2m54s
Viewing CA on a wrapped map copy clamped both edges to 180, so the API returned the newest 2000 feeds (mostly VDOT HLS in Virginia) and the CA viewport was empty. Normalize longitudes into [-180,180] and load 5000.
1156 lines
63 KiB
HTML
1156 lines
63 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OSINT Dashboard</title>
|
||
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
|
||
<style>
|
||
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; }
|
||
header h1 { font-size: 1.25rem; color: var(--accent); }
|
||
.status { font-size: 0.85rem; color: var(--muted); }
|
||
.status .ok { color: var(--green); }
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 1.5rem; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; }
|
||
.card h3 { font-size: 0.8rem; text-transform: uppercase; color: var(--muted); margin-bottom: 0.5rem; }
|
||
.card .value { font-size: 2rem; font-weight: 700; }
|
||
.card .sub { font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }
|
||
.section { margin-bottom: 1.5rem; }
|
||
.section h2 { font-size: 1rem; margin-bottom: 0.75rem; color: var(--accent); }
|
||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||
th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); }
|
||
th { color: var(--muted); font-weight: 500; font-size: 0.8rem; }
|
||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
|
||
.badge-positive { background: #052e16; color: var(--green); }
|
||
.badge-negative { background: #450a0a; color: var(--red); }
|
||
.badge-neutral { background: #1e293b; color: var(--muted); }
|
||
.badge-high { background: #450a0a; color: var(--red); }
|
||
.badge-medium { background: #451a03; color: var(--yellow); }
|
||
.badge-low { background: #052e16; color: var(--green); }
|
||
.search-box { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||
.search-box input { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem 1rem; color: var(--text); font-size: 0.9rem; }
|
||
.search-box input:focus { outline: none; border-color: var(--accent); }
|
||
.search-box button { background: var(--accent); color: #0f172a; border: none; border-radius: 6px; padding: 0.6rem 1.25rem; font-weight: 500; cursor: pointer; }
|
||
.btn { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.85rem; }
|
||
.btn:hover { border-color: var(--accent); }
|
||
.sentiment-bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin-top: 0.5rem; }
|
||
.sentiment-bar div { transition: width 0.3s; }
|
||
.tab-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||
.tab-bar .btn.active { background: var(--accent); color: #0f172a; }
|
||
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||
.key-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.1rem; box-shadow: 0 0 18px rgba(56,189,248,0.06); transition: box-shadow .2s, border-color .2s; }
|
||
.key-card:hover { box-shadow: 0 0 22px rgba(56,189,248,0.14); }
|
||
.key-card.set { border-color: rgba(74,222,128,0.5); box-shadow: 0 0 18px rgba(74,222,128,0.14); }
|
||
.key-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
|
||
.key-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; font-weight: 600; color: var(--accent); word-break: break-all; }
|
||
.key-status { font-size: 0.72rem; font-family: ui-monospace, monospace; padding: 0.2rem 0.55rem; border-radius: 9999px; white-space: nowrap; }
|
||
.key-status.set { background: rgba(74,222,128,0.12); color: var(--green); box-shadow: 0 0 10px rgba(74,222,128,0.35); }
|
||
.key-status.missing { background: rgba(248,113,113,0.12); color: var(--red); box-shadow: 0 0 10px rgba(248,113,113,0.25); }
|
||
.key-desc { font-size: 0.82rem; color: var(--muted); margin-bottom: 0.7rem; }
|
||
.key-hint { font-size: 0.72rem; color: var(--muted); opacity: 0.8; margin-top: 0.15rem; }
|
||
.key-form { display: flex; gap: 0.5rem; }
|
||
.key-form input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.6rem; color: var(--text); font-size: 0.85rem; font-family: ui-monospace, monospace; }
|
||
.key-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
|
||
.key-form .btn { white-space: nowrap; }
|
||
.key-form .btn-danger { border-color: var(--red); color: var(--red); }
|
||
.key-form .btn-danger:hover { background: rgba(248,113,113,0.12); }
|
||
.key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; }
|
||
.key-msg.ok { color: var(--green); }
|
||
.key-msg.err { color: var(--red); }
|
||
|
||
/* ── Map tab ─────────────────────────────────────────────── */
|
||
.map-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 0.9rem; }
|
||
.map-toolbar label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||
.map-toolbar select, .map-toolbar input[type="date"], .map-toolbar input[type="datetime-local"] {
|
||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.85rem;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color-scheme: dark;
|
||
}
|
||
.map-toolbar select:focus, .map-toolbar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
|
||
.map-toolbar .btn { white-space: nowrap; }
|
||
.map-toolbar .btn.active { background: var(--accent); color: #0f172a; }
|
||
.map-hint { font-size: 0.78rem; color: var(--muted); align-self: center; flex: 1 1 100%; margin-top: -0.2rem; }
|
||
#map { width: 100%; height: 70vh; min-height: 420px; border-radius: 10px; border: 1px solid var(--border); z-index: 0; background: #0b1220; }
|
||
.leaflet-container { background: #0b1220; font-family: inherit; }
|
||
.leaflet-control-zoom a { background: var(--surface) !important; color: var(--text) !important; border-color: var(--border) !important; }
|
||
.leaflet-control-zoom a:hover { background: #273449 !important; }
|
||
.leaflet-control-attribution { background: rgba(15,23,42,0.85) !important; color: var(--muted) !important; font-size: 10px !important; }
|
||
.leaflet-control-attribution a { color: var(--accent) !important; }
|
||
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--surface) !important; color: var(--text) !important; border: 1px solid var(--border); }
|
||
.leaflet-popup-content-wrapper a { color: var(--accent); }
|
||
.leaflet-popup-content { font-size: 0.82rem; }
|
||
.leaflet-popup-close-button { color: var(--muted) !important; }
|
||
.leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); }
|
||
/* ── Map layer control panel (dark neon) ─────────────────── */
|
||
#map-wrap { position: relative; }
|
||
#map { z-index: 0; }
|
||
#layer-panel {
|
||
position: absolute; top: 12px; left: 12px; z-index: 600;
|
||
width: 244px; max-height: calc(100% - 24px); overflow: hidden;
|
||
background: rgba(15,23,42,0.88); backdrop-filter: blur(6px);
|
||
border: 1px solid var(--border); border-radius: 10px;
|
||
box-shadow: 0 0 18px rgba(56,189,248,0.18), 0 0 2px rgba(56,189,248,0.5);
|
||
color: var(--text); font-size: 0.78rem; display: flex; flex-direction: column;
|
||
}
|
||
.lp-head {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 0.55rem 0.7rem; border-bottom: 1px solid var(--border);
|
||
font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
|
||
text-transform: uppercase; color: var(--accent);
|
||
text-shadow: 0 0 10px rgba(56,189,248,0.55); cursor: pointer; user-select: none;
|
||
}
|
||
.lp-head .lp-caret { color: var(--muted); font-size: 0.8rem; transition: transform .18s; }
|
||
#layer-panel.collapsed .lp-caret { transform: rotate(-90deg); }
|
||
.lp-body { padding: 0.55rem 0.7rem 0.65rem; display: flex; flex-direction: column; gap: 0.55rem; overflow-y: auto; }
|
||
#layer-panel.collapsed .lp-body { display: none; }
|
||
.lp-layer { display: flex; flex-direction: column; gap: 0.3rem; padding-bottom: 0.5rem; border-bottom: 1px dashed var(--border); }
|
||
.lp-layer:last-child { border-bottom: none; padding-bottom: 0; }
|
||
.lp-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||
.lp-name { display: flex; align-items: center; gap: 0.42rem; font-weight: 600; cursor: pointer; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; }
|
||
.lp-name input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
|
||
.lp-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||
.lp-dot.base { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
|
||
.lp-dot.fires { background: #fb923c; box-shadow: 0 0 7px #fb923c; }
|
||
.lp-dot.cams { background: #4ade80; box-shadow: 0 0 7px #4ade80; }
|
||
.lp-count { font-family: ui-monospace, monospace; font-size: 0.72rem; color: var(--accent); }
|
||
.lp-sub { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
|
||
.lp-opacity { display: flex; align-items: center; gap: 0.4rem; font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||
.lp-opacity input[type="range"] { flex: 1; accent-color: var(--accent); height: 4px; cursor: pointer; }
|
||
.lp-opacity b { color: var(--text); font-family: ui-monospace, monospace; font-weight: 400; min-width: 2.4em; text-align: right; }
|
||
.lp-select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 0.25rem 0.4rem; font-size: 0.72rem; font-family: ui-monospace, monospace; color-scheme: dark; }
|
||
.lp-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
|
||
.lp-legend { border-top: 1px solid var(--border); padding-top: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.lp-legend h4 { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0; }
|
||
.lp-ramp { height: 8px; border-radius: 4px; background: linear-gradient(90deg, #3b82f6, #f59e0b, #ef4444, #fde047); box-shadow: 0 0 8px rgba(251,146,60,0.35) inset; }
|
||
.lp-ramp-scale { display: flex; justify-content: space-between; font-size: 0.6rem; color: var(--muted); }
|
||
.lp-heat-legend { display: flex; align-items: center; justify-content: space-between; font-size: 0.68rem; color: var(--muted); gap: 0.5rem; }
|
||
.lp-heat-legend .sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-right: 0.3rem; box-shadow: 0 0 6px currentColor; }
|
||
.lp-error { color: var(--red); font-size: 0.68rem; }
|
||
/* ── Camera popup thumbnail ───────────────────────────────── */
|
||
.cam-pop { min-width: 210px; max-width: 260px; }
|
||
.cam-pop .thumb { width: 100%; height: 160px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(56,189,248,0.25); background: #0b1220; }
|
||
.cam-pop video.thumb, .cam-pop iframe.thumb { height: 180px; background: #000; }
|
||
.cam-pop .thumb.placeholder { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.7rem; height: 80px; }
|
||
.cam-pop table { width: 100%; font-size: 0.72rem; border-collapse: collapse; }
|
||
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
|
||
.cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap; width: 34%; }
|
||
.cam-pop a { color: var(--accent); word-break: break-all; }
|
||
|
||
/* ── News panel (scraper feed + exec summaries) ─────────────── */
|
||
.news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
|
||
.news-refresh { display: flex; align-items: center; gap: 0.6rem; }
|
||
.news-updated { font-size: 0.72rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.news-summary {
|
||
position: relative; background: linear-gradient(160deg, rgba(56,189,248,0.10), rgba(30,41,59,0.6) 45%);
|
||
border: 1px solid rgba(56,189,248,0.35); border-radius: 12px; padding: 1rem 1.25rem 1.1rem;
|
||
box-shadow: 0 0 24px rgba(56,189,248,0.14), inset 0 0 32px rgba(56,189,248,0.05);
|
||
margin-bottom: 1.2rem; overflow: hidden;
|
||
}
|
||
.news-summary::after {
|
||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||
background: radial-gradient(120px 60px at 85% -10%, rgba(56,189,248,0.22), transparent 70%);
|
||
}
|
||
.ns-kicker { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 12px rgba(56,189,248,0.6); display: flex; align-items: center; gap: 0.5rem; }
|
||
.ns-badge { font-size: 0.6rem; font-weight: 700; background: rgba(56,189,248,0.16); border: 1px solid rgba(56,189,248,0.4); border-radius: 9999px; padding: 0.12rem 0.5rem; color: var(--accent); letter-spacing: 0.08em; }
|
||
.ns-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; margin: 0.35rem 0 0.6rem; }
|
||
.ns-body { font-size: 0.92rem; line-height: 1.62; color: var(--text); white-space: pre-wrap; position: relative; z-index: 1; }
|
||
.ns-body h1, .ns-body h2, .ns-body h3, .ns-body h4 { font-size: 1rem; margin: 0.5rem 0 0.25rem; color: var(--accent); }
|
||
.ns-body ul, .ns-body ol { margin: 0.35rem 0 0.35rem 1.2rem; }
|
||
.ns-body li { margin-bottom: 0.2rem; }
|
||
.ns-body code { background: rgba(15,23,42,0.6); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; }
|
||
.ns-empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
|
||
.ns-loading { color: var(--muted); font-size: 0.85rem; }
|
||
.ns-error { color: var(--red); font-size: 0.85rem; }
|
||
.news-list-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.4rem; }
|
||
.news-count { font-size: 0.7rem; color: var(--accent); font-family: ui-monospace, monospace; }
|
||
.news-list { max-height: 46vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(15,23,42,0.35); }
|
||
.news-list::-webkit-scrollbar { width: 8px; }
|
||
.news-list::-webkit-scrollbar-track { background: rgba(30,41,59,0.4); border-radius: 8px; }
|
||
.news-list::-webkit-scrollbar-thumb { background: rgba(56,189,248,0.35); border-radius: 8px; }
|
||
.news-list::-webkit-scrollbar-thumb:hover { background: rgba(56,189,248,0.55); }
|
||
.news-item {
|
||
display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem;
|
||
border-bottom: 1px solid rgba(51,65,85,0.5); text-decoration: none; color: var(--text); transition: background .15s, box-shadow .15s;
|
||
}
|
||
.news-item:last-child { border-bottom: none; }
|
||
.news-item:hover { background: rgba(56,189,248,0.07); box-shadow: inset 2px 0 0 var(--accent); }
|
||
.news-dom { font-size: 0.66rem; font-family: ui-monospace, monospace; color: var(--accent); background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.25); border-radius: 9999px; padding: 0.12rem 0.5rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
|
||
.news-title { flex: 1; font-size: 0.87rem; line-height: 1.35; color: var(--text); word-break: break-word; }
|
||
.news-item:hover .news-title { color: var(--accent); }
|
||
.news-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; flex-shrink: 0; }
|
||
.news-geo { color: var(--green); font-size: 0.8rem; flex-shrink: 0; text-shadow: 0 0 8px rgba(74,222,128,0.6); }
|
||
.news-loading, .news-error { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; }
|
||
.news-error { color: var(--red); }
|
||
.news-empty { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; font-style: italic; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>OSINT Dashboard</h1>
|
||
<div class="status">
|
||
Status: <span id="health" class="ok">checking...</span>
|
||
| Last update: <span id="last-update">-</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="container">
|
||
<!-- Summary Cards -->
|
||
<div class="grid" id="summary-cards">
|
||
<div class="card"><h3>Total Events</h3><div class="value" id="total-events">-</div></div>
|
||
<div class="card"><h3>Events (24h)</h3><div class="value" id="events-24h">-</div><div class="sub">last 24 hours</div></div>
|
||
<div class="card"><h3>Active Sources</h3><div class="value" id="active-sources">-</div></div>
|
||
<div class="card"><h3>Open Alerts</h3><div class="value" id="open-alerts" style="color:var(--red)">-</div></div>
|
||
<div class="card"><h3>Tracked Entities</h3><div class="value" id="tracked-entities">-</div></div>
|
||
<div class="card">
|
||
<h3>Sentiment (24h)</h3>
|
||
<div class="sentiment-bar">
|
||
<div id="sent-pos" style="background:var(--green)"></div>
|
||
<div id="sent-neu" style="background:var(--muted)"></div>
|
||
<div id="sent-neg" style="background:var(--red)"></div>
|
||
</div>
|
||
<div class="sub" id="sent-detail"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="section">
|
||
<h2>Search Events</h2>
|
||
<div class="search-box">
|
||
<input type="text" id="search-q" placeholder="Search events by keyword..." />
|
||
<button onclick="searchEvents()">Search</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="tab-bar">
|
||
<button class="btn active" onclick="showTab('recent')">Recent Events</button>
|
||
<button class="btn" onclick="showTab('alerts')">Alerts</button>
|
||
<button class="btn" onclick="showTab('entities')">Entities</button>
|
||
<button class="btn" onclick="showTab('ingest')">Ingest</button>
|
||
<button class="btn" onclick="showTab('keys')">Keys</button>
|
||
<button class="btn" onclick="showTab('map')">Map</button>
|
||
<button class="btn" onclick="showTab('news')">News</button>
|
||
</div>
|
||
|
||
<!-- Recent Events -->
|
||
<div class="section" id="tab-recent">
|
||
<h2>Recent Events</h2>
|
||
<table>
|
||
<thead><tr><th>Time</th><th>Source</th><th>Title</th><th>Sentiment</th><th>Location</th></tr></thead>
|
||
<tbody id="events-body"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Alerts -->
|
||
<div class="section" id="tab-alerts" style="display:none">
|
||
<h2>Open Alerts</h2>
|
||
<table>
|
||
<thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Title</th></tr></thead>
|
||
<tbody id="alerts-body"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Entities -->
|
||
<div class="section" id="tab-entities" style="display:none">
|
||
<h2>Tracked Entities</h2>
|
||
<table>
|
||
<thead><tr><th>Name</th><th>Type</th><th>Events</th><th>Last Seen</th></tr></thead>
|
||
<tbody id="entities-body"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Ingest -->
|
||
<div class="section" id="tab-ingest" style="display:none">
|
||
<h2>Data Ingestion</h2>
|
||
<div class="grid">
|
||
<div class="card">
|
||
<h3>RSS Feed</h3>
|
||
<div style="margin-top:0.5rem;display:flex;gap:0.5rem">
|
||
<input type="text" id="rss-url" placeholder="https://example.com/feed" style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:0.4rem;color:var(--text);font-size:0.85rem">
|
||
<button class="btn" onclick="ingestRSS()">Fetch</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>GDELT Articles</h3>
|
||
<p class="sub" style="margin:0.5rem 0">Global news monitoring</p>
|
||
<button class="btn" onclick="ingestGDELT()">Fetch Latest</button>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Earthquakes (USGS)</h3>
|
||
<p class="sub" style="margin:0.5rem 0">Last hour of seismic data</p>
|
||
<button class="btn" onclick="ingestEarthquakes()">Fetch Latest</button>
|
||
</div>
|
||
<div class="card">
|
||
<h3>NATS Consumer</h3>
|
||
<p class="sub" style="margin:0.5rem 0">Process pending messages</p>
|
||
<button class="btn" onclick="processNATS()">Process</button>
|
||
</div>
|
||
</div>
|
||
<div id="ingest-result" style="margin-top:1rem;color:var(--muted);font-size:0.9rem"></div>
|
||
</div>
|
||
|
||
<!-- API Keys -->
|
||
<div class="section" id="tab-keys" style="display:none">
|
||
<h2>API Keys</h2>
|
||
<p class="sub" style="margin-bottom:0.75rem">Keys used by ingest services (FIRMS, Gemini, Telegram, ...). Values are stored in Postgres and never shown again — only set/missing status and the last 4 chars are displayed.</p>
|
||
<div class="key-grid" id="keys-grid"></div>
|
||
</div>
|
||
|
||
<!-- Map (NASA GIBS satellite basemap + FIRMS heatmap + open cameras) -->
|
||
<div class="section" id="tab-map" style="display:none">
|
||
<h2>Global Satellite Map</h2>
|
||
<p class="sub" style="margin-bottom:0.75rem">NASA GIBS satellite basemap (the same tiles worldview.earthdata.nasa.gov renders). Pick a layer and date, then zoom/pan — fires and open cameras can be overlaid. Use the <b>Layers</b> panel to toggle feeds, adjust opacity, and filter fires by time.</p>
|
||
<div class="map-toolbar">
|
||
<label>Layer
|
||
<select id="map-layer" onchange="mapLayerChanged()"></select>
|
||
</label>
|
||
<label id="map-time-label" style="display:none">Date / Time (UTC)
|
||
<input type="date" id="map-date" onchange="mapDateChanged()">
|
||
</label>
|
||
<button class="btn" id="map-latest-btn" onclick="mapGoLatest()" style="display:none">Latest</button>
|
||
<button class="btn" id="map-7d-btn" onclick="mapGoDays(7)" style="display:none">-7d</button>
|
||
<button class="btn" id="map-30d-btn" onclick="mapGoDays(30)" style="display:none">-30d</button>
|
||
<button class="btn" id="map-reset" onclick="mapResetView()">Reset view</button>
|
||
<div class="map-hint" id="map-hint"></div>
|
||
</div>
|
||
<div id="map-wrap">
|
||
<div id="map"></div>
|
||
<div id="layer-panel">
|
||
<div class="lp-head" onclick="toggleLayerPanel()">◈ Layers <span class="lp-caret">▼</span></div>
|
||
<div class="lp-body">
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-base-on" checked onchange="toggleBase()"> <span class="lp-dot base"></span> Basemap</label>
|
||
<span class="lp-count" id="lp-base-count">GIBS</span>
|
||
</div>
|
||
<div class="lp-opacity">Opacity <input type="range" id="lp-base-opacity" min="5" max="100" value="100" oninput="setBaseOpacity(this.value)"><b id="lp-base-val">100%</b></div>
|
||
</div>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-fires-on" checked onchange="toggleFires()"> <span class="lp-dot fires"></span> FIRMS Fires</label>
|
||
<span class="lp-count" id="lp-fires-count">0</span>
|
||
</div>
|
||
<div class="lp-opacity">Opacity <input type="range" id="lp-fires-opacity" min="5" max="100" value="65" oninput="setFiresOpacity(this.value)"><b id="lp-fires-val">65%</b></div>
|
||
<div class="lp-sub">
|
||
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;">Color
|
||
<select class="lp-select" id="lp-fires-color" onchange="setFiresColor()">
|
||
<option value="brightness">Brightness</option>
|
||
<option value="confidence">Confidence</option>
|
||
</select>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:0.35rem;font-size:0.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;">Since
|
||
<select class="lp-select" id="lp-fires-since" onchange="setFiresSince()">
|
||
<option value="">All time</option>
|
||
<option value="6h">Last 6h</option>
|
||
<option value="24h" selected>Last 24h</option>
|
||
<option value="48h">Last 48h</option>
|
||
<option value="7d">Last 7d</option>
|
||
<option value="30d">Last 30d</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="lp-layer">
|
||
<div class="lp-row">
|
||
<label class="lp-name"><input type="checkbox" id="lp-cams-on" checked onchange="toggleCams()"> <span class="lp-dot cams"></span> Open Cameras</label>
|
||
<span class="lp-count" id="lp-cams-count">0</span>
|
||
</div>
|
||
<div class="lp-opacity">Opacity <input type="range" id="lp-cams-opacity" min="5" max="100" value="100" oninput="setCamsOpacity(this.value)"><b id="lp-cams-val">100%</b></div>
|
||
</div>
|
||
<div class="lp-legend">
|
||
<h4>Fire heat intensity</h4>
|
||
<div class="lp-ramp"></div>
|
||
<div class="lp-ramp-scale"><span>cool / low</span><span>hot / high</span></div>
|
||
<div class="lp-heat-legend" id="lp-heat-legend"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- News (scraper feed + executive summaries) -->
|
||
<div class="section" id="tab-news" style="display:none">
|
||
<div class="news-head">
|
||
<div>
|
||
<h2>Global News Feed</h2>
|
||
<p class="sub">Scraped hourly from ~257 RSS sources · latest executive summary pinned above</p>
|
||
</div>
|
||
<div class="news-refresh">
|
||
<span class="news-updated" id="news-updated">—</span>
|
||
<button class="btn" id="news-refresh-btn" onclick="loadNews(true)">Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Executive summary (pinned) -->
|
||
<div class="news-summary" id="news-summary-wrap">
|
||
<div class="ns-kicker">Executive Summary <span class="ns-badge" id="ns-badge">LATEST</span></div>
|
||
<div class="ns-time" id="ns-time">—</div>
|
||
<div class="ns-body" id="ns-body">
|
||
<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scrolling headline list -->
|
||
<div class="news-list-head">
|
||
<span>Recent Headlines</span>
|
||
<span class="news-count" id="news-count">0 articles</span>
|
||
</div>
|
||
<div class="news-list" id="news-list">
|
||
<div class="news-loading">Loading feed…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search Results -->
|
||
<div class="section" id="search-results" style="display:none">
|
||
<h2>Search Results (<span id="search-total">0</span>)</h2>
|
||
<table>
|
||
<thead><tr><th>Time</th><th>Source</th><th>Title</th><th>Sentiment</th><th>Location</th></tr></thead>
|
||
<tbody id="search-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/static/vendor/leaflet/leaflet.js"></script>
|
||
<script src="/static/vendor/leaflet/leaflet.heat.js"></script>
|
||
<script src="/static/vendor/hls/hls.min.js"></script>
|
||
<script>
|
||
const API = '';
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
const r = await fetch(`${API}/api/analytics/summary`);
|
||
const d = await r.json();
|
||
document.getElementById('total-events').textContent = d.total_events;
|
||
document.getElementById('events-24h').textContent = d.events_last_24h;
|
||
document.getElementById('active-sources').textContent = d.active_sources;
|
||
document.getElementById('open-alerts').textContent = d.open_alerts;
|
||
document.getElementById('tracked-entities').textContent = d.tracked_entities;
|
||
const s = d.sentiment;
|
||
const total = s.positive_count + s.neutral_count + s.negative_count || 1;
|
||
document.getElementById('sent-pos').style.width = ((s.positive_count/total)*100)+'%';
|
||
document.getElementById('sent-neu').style.width = ((s.neutral_count/total)*100)+'%';
|
||
document.getElementById('sent-neg').style.width = ((s.negative_count/total)*100)+'%';
|
||
document.getElementById('sent-detail').textContent = `+${s.positive_count} | ~${s.neutral_count} | -${s.negative_count} (avg: ${s.avg_score.toFixed(3)})`;
|
||
} catch(e) { console.error('Summary load failed', e); }
|
||
}
|
||
|
||
async function loadEvents() {
|
||
try {
|
||
const r = await fetch(`${API}/api/events?limit=30`);
|
||
const d = await r.json();
|
||
const tb = document.getElementById('events-body');
|
||
tb.innerHTML = d.map(e => `<tr>
|
||
<td>${new Date(e.ingested_at).toLocaleString()}</td>
|
||
<td>${e.source_type}</td>
|
||
<td>${(e.title||'').substring(0,80)}</td>
|
||
<td>${sentimentBadge(e.sentiment_label)}</td>
|
||
<td>${e.location_name || '-'}</td>
|
||
</tr>`).join('');
|
||
} catch(e) { console.error('Events load failed', e); }
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
try {
|
||
const r = await fetch(`${API}/api/alerts?limit=20`);
|
||
const d = await r.json();
|
||
const tb = document.getElementById('alerts-body');
|
||
tb.innerHTML = d.map(a => `<tr>
|
||
<td>${new Date(a.created_at).toLocaleString()}</td>
|
||
<td><span class="badge badge-${a.severity}">${a.severity}</span></td>
|
||
<td>${a.alert_type}</td>
|
||
<td>${a.title}</td>
|
||
</tr>`).join('');
|
||
} catch(e) { console.error('Alerts load failed', e); }
|
||
}
|
||
|
||
async function loadEntities() {
|
||
try {
|
||
const r = await fetch(`${API}/api/entities?limit=20`);
|
||
const d = await r.json();
|
||
const tb = document.getElementById('entities-body');
|
||
tb.innerHTML = d.map(e => `<tr>
|
||
<td>${e.name}</td>
|
||
<td>${e.entity_type}</td>
|
||
<td>${e.event_count}</td>
|
||
<td>${new Date(e.last_seen).toLocaleString()}</td>
|
||
</tr>`).join('');
|
||
} catch(e) { console.error('Entities load failed', e); }
|
||
}
|
||
|
||
async function searchEvents() {
|
||
const q = document.getElementById('search-q').value.trim();
|
||
if (!q) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/search`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({q, limit: 50})
|
||
});
|
||
const d = await r.json();
|
||
document.getElementById('search-total').textContent = d.total;
|
||
document.getElementById('search-results').style.display = 'block';
|
||
const tb = document.getElementById('search-body');
|
||
tb.innerHTML = d.events.map(e => `<tr>
|
||
<td>${new Date(e.ingested_at).toLocaleString()}</td>
|
||
<td>${e.source_type}</td>
|
||
<td>${(e.title||'').substring(0,80)}</td>
|
||
<td>${sentimentBadge(e.sentiment_label)}</td>
|
||
<td>${e.location_name || '-'}</td>
|
||
</tr>`).join('');
|
||
} catch(e) { console.error('Search failed', e); }
|
||
}
|
||
|
||
function sentimentBadge(label) {
|
||
if (!label) return '-';
|
||
const cls = {positive:'badge-positive',negative:'badge-negative',neutral:'badge-neutral'}[label]||'badge-neutral';
|
||
return `<span class="badge ${cls}">${label}</span>`;
|
||
}
|
||
|
||
// ── API Keys ────────────────────────────────────────────────────────────
|
||
// Mirrors the backend keystore registry for instant client-side format hints.
|
||
const KEY_PATTERNS = {
|
||
'FIRMS_MAP_KEY': /^[0-9a-fA-F]{32}$/,
|
||
'GEMINI_API_KEY': /^AIza[0-9A-Za-z_-]{35}$/,
|
||
'TELEGRAM_TOKEN': /^\d{8,10}:[0-9A-Za-z_-]{35}$/,
|
||
};
|
||
|
||
async function loadKeys() {
|
||
try {
|
||
const r = await fetch(`${API}/api/keys`);
|
||
const d = await r.json();
|
||
const grid = document.getElementById('keys-grid');
|
||
grid.innerHTML = d.map(k => {
|
||
const hint = k.validated && k.example ? `<div class="key-hint">expected: ${k.example}</div>` : '';
|
||
return `<div class="key-card ${k.set?'set':''}">
|
||
<div class="key-head">
|
||
<span class="key-name">${k.name}</span>
|
||
<span class="key-status ${k.set?'set':'missing'}">${k.set?'●':'○'} ${k.set ? (k.masked||'set') : 'missing'}</span>
|
||
</div>
|
||
<div class="key-desc">${k.description || 'Custom key (not in registry).'}${hint}</div>
|
||
<div class="key-form">
|
||
<input type="password" id="key-in-${k.name}" placeholder="Paste new value…" autocomplete="off" spellcheck="false">
|
||
<button class="btn" onclick="saveKey('${k.name}')">Save</button>
|
||
${k.set ? `<button class="btn btn-danger" onclick="clearKey('${k.name}')">Clear</button>` : ''}
|
||
</div>
|
||
<div class="key-msg" id="key-msg-${k.name}"></div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) { console.error('Keys load failed', e); }
|
||
}
|
||
|
||
function keyMsg(name, text, isErr) {
|
||
const el = document.getElementById('key-msg-'+name);
|
||
if (el) { el.textContent = text; el.className = 'key-msg ' + (isErr ? 'err' : 'ok'); }
|
||
}
|
||
|
||
async function saveKey(name) {
|
||
const input = document.getElementById('key-in-'+name);
|
||
const value = (input.value||'').trim();
|
||
if (!value) { keyMsg(name, 'Enter a value to save.', true); return; }
|
||
const pat = KEY_PATTERNS[name];
|
||
if (pat && !pat.test(value)) { keyMsg(name, 'Format check failed — see the expected format above.', true); return; }
|
||
try {
|
||
const r = await fetch(`${API}/api/keys/${encodeURIComponent(name)}`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({value})
|
||
});
|
||
const d = await r.json().catch(() => ({}));
|
||
if (!r.ok) { keyMsg(name, d.detail || 'Save failed', true); return; }
|
||
input.value = '';
|
||
keyMsg(name, `Saved (${d.masked || 'set'}).`, false);
|
||
loadKeys();
|
||
} catch(e) { keyMsg(name, 'Network error.', true); }
|
||
}
|
||
|
||
async function clearKey(name) {
|
||
if (!confirm(`Delete ${name}? This cannot be undone.`)) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/keys/${encodeURIComponent(name)}`, {method:'DELETE'});
|
||
if (!r.ok) { keyMsg(name, 'Clear failed.', true); return; }
|
||
keyMsg(name, 'Key removed.', false);
|
||
loadKeys();
|
||
} catch(e) { keyMsg(name, 'Network error.', true); }
|
||
}
|
||
|
||
function showTab(name) {
|
||
['recent','alerts','entities','ingest','keys','map','news'].forEach(t => {
|
||
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
|
||
});
|
||
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
|
||
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map','news'][i]===name);
|
||
});
|
||
if (name==='alerts') loadAlerts();
|
||
if (name==='entities') loadEntities();
|
||
if (name==='keys') loadKeys();
|
||
if (name==='map') initMap();
|
||
if (name==='news') loadNews();
|
||
}
|
||
|
||
async function ingestRSS() {
|
||
const url = document.getElementById('rss-url').value;
|
||
const r = await fetch(`${API}/api/ingest/rss?feed_url=${encodeURIComponent(url)}`, {method:'POST'});
|
||
const d = await r.json();
|
||
document.getElementById('ingest-result').textContent = `RSS: ${d.items_ingested} items ingested`;
|
||
loadSummary(); loadEvents();
|
||
}
|
||
|
||
async function ingestGDELT() {
|
||
const r = await fetch(`${API}/api/ingest/gdelt?max_articles=50`, {method:'POST'});
|
||
const d = await r.json();
|
||
document.getElementById('ingest-result').textContent = `GDELT: ${d.articles_ingested} articles ingested`;
|
||
loadSummary(); loadEvents();
|
||
}
|
||
|
||
async function ingestEarthquakes() {
|
||
const r = await fetch(`${API}/api/ingest/earthquakes`, {method:'POST'});
|
||
const d = await r.json();
|
||
document.getElementById('ingest-result').textContent = `USGS: ${d.events_ingested} events ingested`;
|
||
loadSummary(); loadEvents();
|
||
}
|
||
|
||
async function processNATS() {
|
||
const r = await fetch(`${API}/api/ingest/process?batch_size=100`, {method:'POST'});
|
||
const d = await r.json();
|
||
document.getElementById('ingest-result').textContent = `NATS: ${d.processed} messages processed`;
|
||
loadSummary(); loadEvents();
|
||
}
|
||
|
||
async function checkHealth() {
|
||
try {
|
||
const r = await fetch(`${API}/api/health`);
|
||
const d = await r.json();
|
||
document.getElementById('health').textContent = 'healthy';
|
||
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
||
} catch(e) {
|
||
document.getElementById('health').textContent = 'unreachable';
|
||
document.getElementById('health').className = '';
|
||
}
|
||
}
|
||
|
||
// ── News feed (scraper pipe + exec summaries) ─────────────────────────────
|
||
let newsInterval = null;
|
||
const NEWS_REFRESH_MS = 900000; // 15 min
|
||
let newsGeo = [];
|
||
let newsPinsLayer = null;
|
||
|
||
function newsEsc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
function newsTimeAgo(ts) {
|
||
if (!ts) return '—';
|
||
const diff = Date.now() - new Date(ts).getTime();
|
||
if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
|
||
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
|
||
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
|
||
return `${Math.floor(diff/86400000)}d ago`;
|
||
}
|
||
function newsSimpleMD(text) {
|
||
// Lightweight markdown→HTML for summary bodies (bold, headers, lists, paragraphs)
|
||
if (!text) return '';
|
||
let h = newsEsc(String(text));
|
||
// code blocks (already escaped so match escaped backticks)
|
||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
// bold
|
||
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
// headers (start of line # through ##)
|
||
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
||
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
||
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
||
// unordered lists
|
||
h = h.replace(/^[\*\-]\s(.+)$/gm, '<li>$1</li>');
|
||
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
||
// double-newline = paragraph
|
||
h = h.replace(/\n{2,}/g, '</p><p>');
|
||
// single newline = br
|
||
h = h.replace(/\n/g, '<br>');
|
||
return '<p>'+h+'</p>';
|
||
}
|
||
async function loadNews(force) {
|
||
document.getElementById('news-updated').textContent = 'updating…';
|
||
let articles = [];
|
||
let summaries = [];
|
||
try {
|
||
const [rArticles, rSumm] = await Promise.all([
|
||
fetch(`${API}/api/news?limit=100`),
|
||
fetch(`${API}/api/news/summaries?limit=1`)
|
||
]);
|
||
if (rArticles.ok) articles = await rArticles.json();
|
||
if (rSumm.ok) summaries = await rSumm.json();
|
||
} catch(e) {
|
||
document.getElementById('news-list').innerHTML = '<div class="news-error">Failed to load news feed: ' + newsEsc(e.message || e) + '</div>';
|
||
document.getElementById('ns-body').innerHTML = '<div class="ns-error">Failed to load summary: ' + newsEsc(e.message || e) + '</div>';
|
||
document.getElementById('news-updated').textContent = 'error';
|
||
console.error('News load failed', e);
|
||
return;
|
||
}
|
||
renderNewsSummary(summaries);
|
||
renderNewsList(articles);
|
||
// Geo-pin stretch: if articles carry lat/lon (future backend), collect + plot on GIBS map
|
||
newsGeo = articles.filter(a => typeof a.lat === 'number' && typeof a.lon === 'number');
|
||
if (newsGeo.length && map) {
|
||
renderNewsPins();
|
||
}
|
||
document.getElementById('news-updated').textContent = 'updated ' + new Date().toLocaleTimeString();
|
||
if (!newsInterval) {
|
||
newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
|
||
}
|
||
}
|
||
function renderNewsSummary(summaries) {
|
||
const bodyEl = document.getElementById('ns-body');
|
||
const timeEl = document.getElementById('ns-time');
|
||
const badgeEl = document.getElementById('ns-badge');
|
||
if (!summaries || !summaries.length) {
|
||
bodyEl.innerHTML = '<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>';
|
||
timeEl.textContent = '—';
|
||
badgeEl.textContent = 'WAITING';
|
||
return;
|
||
}
|
||
const s = summaries[0];
|
||
badgeEl.textContent = 'LATEST';
|
||
timeEl.textContent = s.batch_timestamp ? 'Batch: ' + new Date(s.batch_timestamp).toLocaleString() : '—';
|
||
bodyEl.innerHTML = newsSimpleMD(s.summary_text || '');
|
||
}
|
||
function renderNewsList(articles) {
|
||
const listEl = document.getElementById('news-list');
|
||
document.getElementById('news-count').textContent = articles.length + ' articles';
|
||
if (!articles || !articles.length) {
|
||
listEl.innerHTML = '<div class="news-empty">No articles scraped yet — the scraper runs hourly at :00. Check news-scraper logs.</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = articles.map(a => {
|
||
const geoTag = a.lat != null && a.lon != null ? '<span class="news-geo" title="geotagged">📍</span>' : '';
|
||
return '<a class="news-item" href="' + newsEsc(a.url || '#') + '" target="_blank" rel="noopener">' +
|
||
'<span class="news-dom">' + newsEsc(a.domain || '?') + '</span>' +
|
||
'<span class="news-title">' + newsEsc((a.title||'').trim()) + '</span>' +
|
||
geoTag +
|
||
'<span class="news-time">' + newsTimeAgo(a.timestamp) + '</span>' +
|
||
'</a>';
|
||
}).join('');
|
||
}
|
||
function renderNewsPins() {
|
||
if (!map || !newsGeo.length) return;
|
||
if (newsPinsLayer) { map.removeLayer(newsPinsLayer); newsPinsLayer = null; }
|
||
newsPinsLayer = L.layerGroup(newsGeo.map(a => {
|
||
const icon = L.divIcon({
|
||
className: '',
|
||
html:'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f0abfc;box-shadow:0 0 10px #f0abfc,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
|
||
iconSize: [12, 12], iconAnchor: [6, 6],
|
||
});
|
||
return L.marker([a.lat, a.lon], { icon })
|
||
.bindPopup(`<div class="cam-pop"><b>${newsEsc(a.title||'Headline')}</b><br><a href="${newsEsc(a.url||'#')}" target="_blank" rel="noopener">read article →</a><br><span class="k">${newsEsc(a.domain||'')}</span></div>`);
|
||
}));
|
||
newsPinsLayer.addTo(map);
|
||
}
|
||
|
||
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
|
||
let map = null, mapLayer = null, mapInitStarted = false;
|
||
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
|
||
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
|
||
let camsOpacity = 1.0, baseOpacity = 1.0;
|
||
let mapLayers = []; // catalog from /api/map/layers
|
||
let mapDomain = null; // time windows for current layer
|
||
let mapLatest = null; // ISO date of most recent imagery
|
||
let mapAttributionAdded = null; // last attribution string added to control
|
||
|
||
async function initMap() {
|
||
if (mapInitStarted) { if (map) setTimeout(() => map.invalidateSize(), 60); return; }
|
||
mapInitStarted = true;
|
||
const hint = document.getElementById('map-hint');
|
||
hint.textContent = 'Loading layer catalog…';
|
||
try {
|
||
const r = await fetch(`${API}/api/map/layers`);
|
||
const d = await r.json();
|
||
mapLayers = d.layers || [];
|
||
const sel = document.getElementById('map-layer');
|
||
sel.innerHTML = mapLayers.map(l =>
|
||
`<option value="${l.id}">${l.title}</option>`).join('');
|
||
// default to the first time-aware layer (more interesting than static basemap)
|
||
const preferred = mapLayers.find(l => l.has_time) || mapLayers[0];
|
||
if (preferred) sel.value = preferred.id;
|
||
map = L.map('map', {
|
||
center: [25, 10], zoom: 2,
|
||
zoomControl: false,
|
||
worldCopyJump: true,
|
||
minZoom: 1,
|
||
maxZoom: 12,
|
||
attributionControl: true,
|
||
});
|
||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||
map.attributionControl.setPrefix('');
|
||
let activeHls = null;
|
||
map.on('popupopen', (e) => {
|
||
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
|
||
const root = e.popup.getElement();
|
||
const v = root && root.querySelector('video[data-hls]');
|
||
if (!v) return;
|
||
const fallback = () => {
|
||
const img = document.createElement('img');
|
||
img.className = 'thumb';
|
||
img.src = v.dataset.fallback;
|
||
img.alt = 'camera preview';
|
||
v.replaceWith(img);
|
||
};
|
||
if (window.Hls && Hls.isSupported()) {
|
||
activeHls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
||
activeHls.loadSource(v.dataset.hls);
|
||
activeHls.attachMedia(v);
|
||
v.play && v.play().catch(() => {});
|
||
activeHls.on(Hls.Events.ERROR, (_, data) => {
|
||
if (data && data.fatal) fallback();
|
||
});
|
||
} else if (v.canPlayType && v.canPlayType('application/vnd.apple.mpegurl')) {
|
||
v.src = v.dataset.hls;
|
||
v.addEventListener('error', fallback, { once: true });
|
||
} else {
|
||
fallback();
|
||
}
|
||
});
|
||
map.on('popupclose', () => {
|
||
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
|
||
});
|
||
updateHeatLegend();
|
||
// Reload overlays when the user pans/zooms. Skip when a popup is open:
|
||
// opening a camera popup auto-pans the map to fit it, and that moveend
|
||
// must NOT rebuild the marker group under the open popup (which would
|
||
// close it). Leaflet closes popups on manual drag, so a live popup here
|
||
// means the move was autopan — safe to skip.
|
||
map.on('moveend', () => {
|
||
if (map._popup) return;
|
||
if (firesOn) loadFires();
|
||
if (camsOn) loadCams();
|
||
});
|
||
// default selection = first layer
|
||
await mapLayerChanged();
|
||
// Overlays default ON (checked in the layer panel)
|
||
firesOn = document.getElementById('lp-fires-on').checked;
|
||
camsOn = document.getElementById('lp-cams-on').checked;
|
||
if (firesOn) loadFires();
|
||
if (camsOn) loadCams();
|
||
} catch(e) {
|
||
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
||
console.error('Map init failed', e);
|
||
}
|
||
}
|
||
|
||
function setMapAttribution(text) {
|
||
if (!map) return;
|
||
if (mapAttributionAdded) map.attributionControl.removeAttribution(mapAttributionAdded);
|
||
mapAttributionAdded = text;
|
||
if (text) map.attributionControl.addAttribution(text);
|
||
}
|
||
|
||
function tileUrlFor(layerMeta, time) {
|
||
// RESTful WMTS template: .../best/{layer}/default[/{time}]/{tms}/{z}/{y}/{x}.{fmt}
|
||
const base = `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layerMeta.id}/default`;
|
||
const timePart = layerMeta.has_time ? `/${time || 'default'}` : '';
|
||
return `${base}${timePart}/${layerMeta.tms}/{z}/{y}/{x}.${layerMeta.format}`;
|
||
}
|
||
|
||
async function mapLayerChanged() {
|
||
const hint = document.getElementById('map-hint');
|
||
const sel = document.getElementById('map-layer');
|
||
const meta = mapLayers.find(l => l.id === sel.value);
|
||
if (!meta) return;
|
||
|
||
// Show/hide time controls depending on whether layer has a Time dimension
|
||
const hasTime = !!meta.has_time;
|
||
document.getElementById('map-time-label').style.display = hasTime ? 'flex' : 'none';
|
||
document.getElementById('map-latest-btn').style.display = hasTime ? '' : 'none';
|
||
document.getElementById('map-7d-btn').style.display = hasTime ? '' : 'none';
|
||
document.getElementById('map-30d-btn').style.display = hasTime ? '' : 'none';
|
||
|
||
let time = null;
|
||
if (hasTime) {
|
||
hint.textContent = 'Loading available dates…';
|
||
const input = document.getElementById('map-date');
|
||
try {
|
||
const r = await fetch(`${API}/api/map/times?layer=${encodeURIComponent(meta.id)}`);
|
||
mapDomain = await r.json();
|
||
mapLatest = mapDomain.latest;
|
||
if (mapDomain.min && mapDomain.max) {
|
||
input.min = mapDomain.min;
|
||
input.max = mapDomain.max;
|
||
}
|
||
input.value = mapLatest || mapDomain.max || '';
|
||
time = input.value;
|
||
} catch(e) {
|
||
// fall back to today if the times endpoint is unreachable
|
||
mapDomain = null;
|
||
const today = new Date().toISOString().slice(0,10);
|
||
input.value = today;
|
||
time = today;
|
||
}
|
||
} else {
|
||
mapDomain = null;
|
||
mapLatest = null;
|
||
time = null;
|
||
}
|
||
|
||
const url = tileUrlFor(meta, time);
|
||
const attribution = `NASA GIBS / EOSDIS · <a href="https://earthdata.nasa.gov/gibs" target="_blank" rel="noopener">${meta.id}</a>`;
|
||
if (mapLayer) {
|
||
mapLayer.setUrl(url);
|
||
mapLayer.options.maxNativeZoom = meta.max_zoom;
|
||
} else {
|
||
mapLayer = L.tileLayer(url, {
|
||
maxZoom: 12,
|
||
maxNativeZoom: meta.max_zoom,
|
||
attribution: '',
|
||
}).addTo(map);
|
||
}
|
||
setMapAttribution(attribution);
|
||
hint.textContent = hasTime
|
||
? `${meta.subtitle || meta.title} · tile date ${time || 'default'} UTC`
|
||
: `${meta.subtitle || meta.title} · static basemap`;
|
||
}
|
||
|
||
function mapDateChanged() {
|
||
const meta = mapLayers.find(l => l.id === document.getElementById('map-layer').value);
|
||
if (!meta || !meta.has_time) return;
|
||
const time = document.getElementById('map-date').value;
|
||
if (!time) return;
|
||
mapLayer.setUrl(tileUrlFor(meta, time));
|
||
document.getElementById('map-hint').textContent =
|
||
`${meta.subtitle || meta.title} · tile date ${time} UTC`;
|
||
if (mapDomain && mapDomain.ranges && mapDomain.ranges.length) {
|
||
const inRange = mapDomain.ranges.some(r => time >= r.start && time <= r.end);
|
||
if (!inRange) {
|
||
document.getElementById('map-hint').textContent += ' ⚠ outside published windows (GIBS serves nearest-time)';
|
||
}
|
||
}
|
||
}
|
||
|
||
function mapGoLatest() { document.getElementById('map-date').value = mapLatest || ''; mapDateChanged(); }
|
||
function mapGoDays(n) {
|
||
const base = mapLatest || document.getElementById('map-date').value;
|
||
if (!base) return;
|
||
const dt = new Date(base + 'T00:00:00Z');
|
||
dt.setUTCDate(dt.getUTCDate() - n);
|
||
const iso = dt.toISOString().slice(0,10);
|
||
document.getElementById('map-date').value = iso;
|
||
mapDateChanged();
|
||
}
|
||
|
||
function mapResetView() { if (map) map.setView([25, 10], 2); }
|
||
|
||
function currentBBox() {
|
||
const b = map.getBounds();
|
||
let west = b.getWest();
|
||
let east = b.getEast();
|
||
let south = Math.max(-90, Math.min(90, b.getSouth()));
|
||
let north = Math.max(-90, Math.min(90, b.getNorth()));
|
||
const lonSpan = east - west;
|
||
// World view, or a wrap that spans ~the whole globe.
|
||
if (map.getZoom() <= 3 || lonSpan >= 300) {
|
||
return '-180.0000,-85.0000,180.0000,85.0000';
|
||
}
|
||
// Normalize each edge into [-180, 180] WITHOUT clamping both to 180
|
||
// (that collapsed CA-on-a-wrapped-copy to an empty sliver).
|
||
const norm = (x) => ((x + 180) % 360 + 360) % 360 - 180;
|
||
west = norm(west);
|
||
east = norm(east);
|
||
if (west >= east) {
|
||
// Crosses the dateline: keep the latitude strip, don't drop the state.
|
||
return `-180.0000,${south.toFixed(4)},180.0000,${north.toFixed(4)}`;
|
||
}
|
||
return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`;
|
||
}
|
||
|
||
function toggleLayerPanel() {
|
||
document.getElementById('layer-panel').classList.toggle('collapsed');
|
||
}
|
||
|
||
// ── Basemap visibility / opacity ─────────────────────────────────────────
|
||
function toggleBase() {
|
||
const on = document.getElementById('lp-base-on').checked;
|
||
if (mapLayer) mapLayer.setOpacity(on ? baseOpacity : 0);
|
||
}
|
||
function setBaseOpacity(v) {
|
||
baseOpacity = v / 100;
|
||
document.getElementById('lp-base-val').textContent = `${Math.round(v)}%`;
|
||
if (mapLayer && document.getElementById('lp-base-on').checked) mapLayer.setOpacity(baseOpacity);
|
||
}
|
||
|
||
// ── FIRMS fire heatmap ───────────────────────────────────────────────────
|
||
function firesIntensity(f) {
|
||
// Color-by mode drives the intensity fed to leaflet.heat:
|
||
// * brightness — normalize bright_ti4 (~290–400K) into 0..1
|
||
// * confidence — VIIRS confidence rank (l=0.35, n=0.6, h=1.0)
|
||
if (firesColor === 'confidence') {
|
||
return f.confidence === 'h' ? 1.0 : f.confidence === 'l' ? 0.35 : 0.6;
|
||
}
|
||
const b = f.brightness || 300;
|
||
return Math.max(0.05, Math.min(1, (b - 290) / 110));
|
||
}
|
||
function firesGradient() {
|
||
// Neon ramp that reads on the dark basemap; low→high intensity.
|
||
return firesColor === 'confidence'
|
||
? { 0.2: '#3b82f6', 0.5: '#fbbf24', 0.75: '#fb923c', 1.0: '#ef4444' }
|
||
: { 0.1: '#2563eb', 0.4: '#38bdf8', 0.65: '#f59e0b', 0.85: '#ef4444', 1.0: '#fde047' };
|
||
}
|
||
function updateHeatLegend() {
|
||
const el = document.getElementById('lp-heat-legend');
|
||
if (firesColor === 'confidence') {
|
||
el.innerHTML = '<span><span class="sw" style="background:#fbbf24">l</span><span class="sw" style="background:#fb923c">n</span><span class="sw" style="background:#ef4444">h</span></span><span>VIIRS confidence</span>';
|
||
} else {
|
||
el.innerHTML = '<span>~290K</span><span>~400K</span>';
|
||
}
|
||
}
|
||
function sinceToISO(sel) {
|
||
if (!sel) return '';
|
||
const m = sel.match(/^(\d+)([hd])$/);
|
||
if (!m) return '';
|
||
const n = parseInt(m[1], 10) * (m[2] === 'h' ? 3600 : 86400) * 1000;
|
||
return new Date(Date.now() - n).toISOString();
|
||
}
|
||
async function toggleFires() {
|
||
firesOn = document.getElementById('lp-fires-on').checked;
|
||
if (firesOn) await loadFires();
|
||
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; }
|
||
document.getElementById('lp-fires-count').textContent = '0';
|
||
}
|
||
function setFiresOpacity(v) {
|
||
firesOpacity = v / 100;
|
||
document.getElementById('lp-fires-val').textContent = `${Math.round(v)}%`;
|
||
if (firesHeat && firesHeat._canvas) firesHeat._canvas.style.opacity = firesOpacity;
|
||
}
|
||
function setFiresColor() {
|
||
firesColor = document.getElementById('lp-fires-color').value;
|
||
updateHeatLegend();
|
||
if (firesOn) loadFires(); // re-weight intensities
|
||
}
|
||
function setFiresSince() {
|
||
firesSince = document.getElementById('lp-fires-since').value;
|
||
if (firesOn) loadFires();
|
||
}
|
||
async function loadFires() {
|
||
if (!map) return;
|
||
try {
|
||
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000`;
|
||
const since = sinceToISO(firesSince);
|
||
if (since) url += `&since=${encodeURIComponent(since)}`;
|
||
const r = await fetch(url);
|
||
const fires = await r.json();
|
||
if (firesHeat) map.removeLayer(firesHeat);
|
||
const pts = fires.map(f => [f.latitude, f.longitude, firesIntensity(f)]);
|
||
firesHeat = L.heatLayer(pts, {
|
||
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
|
||
gradient: firesGradient(),
|
||
}).addTo(map);
|
||
firesHeat._canvas.style.opacity = firesOpacity;
|
||
document.getElementById('lp-fires-count').textContent = fires.length.toLocaleString();
|
||
document.getElementById('map-hint').textContent =
|
||
`${fires.length.toLocaleString()} fire hotspots in view` +
|
||
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
|
||
} catch(e) {
|
||
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
|
||
console.error('Fires load failed', e);
|
||
}
|
||
}
|
||
|
||
// ── Open IP cameras (thumbnail popups) ───────────────────────────────────
|
||
async function toggleCams() {
|
||
camsOn = document.getElementById('lp-cams-on').checked;
|
||
if (camsOn) await loadCams();
|
||
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
|
||
document.getElementById('lp-cams-count').textContent = '0';
|
||
}
|
||
function setCamsOpacity(v) {
|
||
camsOpacity = v / 100;
|
||
document.getElementById('lp-cams-val').textContent = `${Math.round(v)}%`;
|
||
if (camsGroup) camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
|
||
}
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
function camSourceLink(c) {
|
||
const url = c.source_url || '';
|
||
if (!url) return '';
|
||
// Never emit an rtsp:// href — the OS opens VLC (or another player).
|
||
if (url.toLowerCase().startsWith('rtsp://')) {
|
||
const host = url.replace(/^rtsp:\/\//i, '').replace(/\/.*$/, '');
|
||
return `<span style="color:var(--muted)">RTSP ${esc(host)}:554</span>`;
|
||
}
|
||
return `<a href="${esc(url)}" target="_blank" rel="noopener">source page ↗</a>`;
|
||
}
|
||
function youtubeId(url) {
|
||
const m = String(url || '').match(/(?:youtu\.be\/|v=)([A-Za-z0-9_-]{6,})/);
|
||
return m ? m[1] : '';
|
||
}
|
||
function camKind(c) {
|
||
const u = String(c.snapshot_url || c.source_url || '').toLowerCase();
|
||
if (c.device_type === 'youtube' || u.includes('youtube.com') || u.includes('youtu.be')) return 'youtube';
|
||
if (c.device_type === 'hls' || u.includes('.m3u8')) return 'hls';
|
||
if (c.device_type === 'rtsp' || u.startsWith('rtsp://')) return 'rtsp';
|
||
return 'http';
|
||
}
|
||
function camThumb(c) {
|
||
if (!c.id) return '<div class="thumb placeholder">no preview available</div>';
|
||
const kind = camKind(c);
|
||
if (kind === 'youtube') {
|
||
const vid = youtubeId(c.snapshot_url || c.source_url);
|
||
if (!vid) return '<div class="thumb placeholder">youtube id missing</div>';
|
||
return `<iframe class="thumb" src="https://www.youtube-nocookie.com/embed/${esc(vid)}?autoplay=1&mute=1" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen loading="lazy"></iframe>`;
|
||
}
|
||
if (kind === 'hls') {
|
||
return `<video class="thumb" data-hls="/api/cameras/${esc(c.id)}/hls.m3u8" data-fallback="/api/cameras/${esc(c.id)}/stream" muted autoplay playsinline controls></video>`;
|
||
}
|
||
const first = kind === 'rtsp'
|
||
? `/api/cameras/${esc(c.id)}/snapshot`
|
||
: `/api/cameras/${esc(c.id)}/stream`;
|
||
const onerr = (
|
||
"if (!this.dataset.f) { this.dataset.f='1'; this.src='/api/cameras/" + esc(c.id) + "/snapshot'; } " +
|
||
"else { this.classList.add('placeholder'); this.onerror=null; this.alt='preview unavailable'; this.removeAttribute('src'); }"
|
||
);
|
||
return `<img class="thumb" src="${first}" alt="camera preview" loading="lazy" onerror="${onerr}">`;
|
||
}
|
||
async function loadCams() {
|
||
if (!map) return;
|
||
try {
|
||
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=5000`);
|
||
const cams = await r.json();
|
||
if (camsGroup) map.removeLayer(camsGroup);
|
||
camsGroup = L.layerGroup(cams.map(c => {
|
||
const icon = L.divIcon({
|
||
className: '',
|
||
html: '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 8px #4ade80,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
|
||
iconSize: [12, 12], iconAnchor: [6, 6],
|
||
});
|
||
return L.marker([c.lat, c.lon], { icon })
|
||
.bindPopup(`<div class="cam-pop">` +
|
||
`<b>${esc(c.location_name || 'Open camera')}</b>` +
|
||
`${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` +
|
||
`<table>` +
|
||
`<tr><td class="k">Vendor</td><td>${esc(c.vendor || '?')} ${esc(c.device_type || '')}</td></tr>` +
|
||
`<tr><td class="k">Source</td><td>${esc(c.discovery_source || '?')}</td></tr>` +
|
||
`<tr><td class="k">First seen</td><td>${esc((c.first_seen||'').slice(0,16).replace('T',' '))}</td></tr>` +
|
||
`<tr><td class="k">Last seen</td><td>${esc((c.last_seen||'').slice(0,16).replace('T',' '))}</td></tr>` +
|
||
`<tr><td class="k">Coords</td><td>${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'}</td></tr>` +
|
||
`</table>` +
|
||
camSourceLink(c) +
|
||
`</div>`);
|
||
}));
|
||
camsGroup.addTo(map);
|
||
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
|
||
document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString();
|
||
document.getElementById('map-hint').textContent =
|
||
`${cams.length.toLocaleString()} open cameras in view`;
|
||
} catch(e) {
|
||
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
|
||
console.error('Cameras load failed', e);
|
||
}
|
||
}
|
||
|
||
// Initial load
|
||
loadSummary(); loadEvents(); checkHealth();
|
||
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
|
||
</script>
|
||
</body>
|
||
</html>
|