2026-06-04 20:30:04 -04:00
<!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 >
2026-08-24 17:35:44 -04:00
< link rel = "stylesheet" href = "/static/vendor/leaflet/leaflet.css" >
2026-06-04 20:30:04 -04:00
< 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; }
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
.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); }
2026-08-24 17:35:44 -04:00
/* ── 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)); }
2026-08-24 18:02:56 -04:00
/* ── 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: 120px; 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 .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; }
2026-06-04 20:30:04 -04:00
< / 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 >
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
< button class = "btn" onclick = "showTab('keys')" > Keys< / button >
2026-08-24 17:35:44 -04:00
< button class = "btn" onclick = "showTab('map')" > Map< / button >
2026-06-04 20:30:04 -04:00
< / 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 >
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
<!-- 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 >
2026-08-24 18:02:56 -04:00
<!-- Map (NASA GIBS satellite basemap + FIRMS heatmap + open cameras) -->
2026-08-24 17:35:44 -04:00
< div class = "section" id = "tab-map" style = "display:none" >
< h2 > Global Satellite Map< / h2 >
2026-08-24 18:02:56 -04:00
< 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 >
2026-08-24 17:35:44 -04:00
< 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 >
2026-08-24 18:02:56 -04:00
< 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 >
2026-08-24 17:35:44 -04:00
< / div >
2026-06-04 20:30:04 -04:00
<!-- 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 >
2026-08-24 17:35:44 -04:00
< script src = "/static/vendor/leaflet/leaflet.js" > < / script >
2026-08-24 18:02:56 -04:00
< script src = "/static/vendor/leaflet/leaflet.heat.js" > < / script >
2026-06-04 20:30:04 -04:00
< 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 > `;
}
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
// ── 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); }
}
2026-06-04 20:30:04 -04:00
function showTab(name) {
2026-08-24 17:35:44 -04:00
['recent','alerts','entities','ingest','keys','map'].forEach(t => {
2026-06-04 20:30:04 -04:00
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
});
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
2026-08-24 17:35:44 -04:00
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
2026-06-04 20:30:04 -04:00
});
if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities();
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
if (name==='keys') loadKeys();
2026-08-24 17:35:44 -04:00
if (name==='map') initMap();
2026-06-04 20:30:04 -04:00
}
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 = '';
}
}
2026-08-24 18:02:56 -04:00
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
2026-08-24 17:35:44 -04:00
let map = null, mapLayer = null, mapInitStarted = false;
2026-08-24 18:02:56 -04:00
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;
let firesOpacity = 0.65, firesColor = 'brightness', firesSince = '24h';
let camsOpacity = 1.0, baseOpacity = 1.0;
2026-08-24 17:35:44 -04:00
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,
2026-08-24 18:02:56 -04:00
zoomControl: false,
2026-08-24 17:35:44 -04:00
worldCopyJump: true,
minZoom: 1,
maxZoom: 12,
attributionControl: true,
});
2026-08-24 18:02:56 -04:00
L.control.zoom({ position: 'topright' }).addTo(map);
2026-08-24 17:35:44 -04:00
map.attributionControl.setPrefix('');
2026-08-24 18:02:56 -04:00
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();
});
2026-08-24 17:35:44 -04:00
// default selection = first layer
await mapLayerChanged();
2026-08-24 18:02:56 -04:00
// 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();
2026-08-24 17:35:44 -04:00
} 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();
2026-08-24 18:02:56 -04:00
// Clamp to world bounds: at low zoom with worldCopyJump the map spans
// multiple world copies, and /api/cameras rejects coords outside ±180/±90.
const west = Math.max(-180, Math.min(180, b.getWest()));
const east = Math.max(-180, Math.min(180, b.getEast()));
const south = Math.max(-90, Math.min(90, b.getSouth()));
const north = Math.max(-90, Math.min(90, b.getNorth()));
return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`;
2026-08-24 17:35:44 -04:00
}
2026-08-24 18:02:56 -04:00
function toggleLayerPanel() {
document.getElementById('layer-panel').classList.toggle('collapsed');
2026-08-24 17:35:44 -04:00
}
2026-08-24 18:02:56 -04:00
// ── 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();
}
2026-08-24 17:35:44 -04:00
async function loadFires() {
if (!map) return;
try {
2026-08-24 18:02:56 -04:00
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000`;
const since = sinceToISO(firesSince);
if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await fetch(url);
2026-08-24 17:35:44 -04:00
const fires = await r.json();
2026-08-24 18:02:56 -04:00
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();
2026-08-24 17:35:44 -04:00
document.getElementById('map-hint').textContent =
2026-08-24 18:02:56 -04:00
`${fires.length.toLocaleString()} fire hotspots in view` +
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
2026-08-24 17:35:44 -04:00
} catch(e) {
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
console.error('Fires load failed', e);
}
}
2026-08-24 18:02:56 -04:00
// ── Open IP cameras (thumbnail popups) ───────────────────────────────────
async function toggleCams() {
camsOn = document.getElementById('lp-cams-on').checked;
2026-08-24 17:35:44 -04:00
if (camsOn) await loadCams();
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
2026-08-24 18:02:56 -04:00
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 camThumb(c) {
// Live snapshot proxied through the backend TTL cache. Only render the
// < img > when the camera actually has a snapshot URL (else the backend
// 404s); onerror swaps to a placeholder so a dead cam never breaks layout.
if (!c.snapshot_url) return '< div class = "thumb placeholder" > no snapshot available< / div > ';
const onerr = "this.classList.add('placeholder'); this.onerror=null; this.alt='snapshot unavailable'; this.removeAttribute('src');";
return `< img class = "thumb" src = "/api/cameras/${esc(c.id)}/snapshot" alt = "live snapshot" loading = "lazy" onerror = "${onerr}" > `;
2026-08-24 17:35:44 -04:00
}
async function loadCams() {
if (!map) return;
try {
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=500`);
const cams = await r.json();
if (camsGroup) map.removeLayer(camsGroup);
camsGroup = L.layerGroup(cams.map(c => {
const icon = L.divIcon({
className: '',
2026-08-24 18:02:56 -04:00
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],
2026-08-24 17:35:44 -04:00
});
return L.marker([c.lat, c.lon], { icon })
2026-08-24 18:02:56 -04:00
.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 > ` +
(c.source_url ? `< a href = "${esc(c.source_url)}" target = "_blank" rel = "noopener" > source page ↗< / a > ` : '') +
`< / div > `);
2026-08-24 17:35:44 -04:00
}));
camsGroup.addTo(map);
2026-08-24 18:02:56 -04:00
camsGroup.eachLayer(l => l.setOpacity & & l.setOpacity(camsOpacity));
document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString();
2026-08-24 17:35:44 -04:00
document.getElementById('map-hint').textContent =
2026-08-24 18:02:56 -04:00
`${cams.length.toLocaleString()} open cameras in view`;
2026-08-24 17:35:44 -04:00
} catch(e) {
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
console.error('Cameras load failed', e);
}
}
2026-06-04 20:30:04 -04:00
// Initial load
loadSummary(); loadEvents(); checkHealth();
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
< / script >
< / body >
< / html >