307 lines
14 KiB
HTML
307 lines
14 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>
|
|
<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; }
|
|
</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>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
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>`;
|
|
}
|
|
|
|
function showTab(name) {
|
|
['recent','alerts','entities','ingest'].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'][i]===name);
|
|
});
|
|
if (name==='alerts') loadAlerts();
|
|
if (name==='entities') loadEntities();
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
// Initial load
|
|
loadSummary(); loadEvents(); checkHealth();
|
|
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|