ソースを参照

Add dashboard frontend and backend modules

Lukas Goldschmidt 3 週間 前
コミット
a5816e3b32

+ 349 - 0
dashboard/dashboard.js

@@ -0,0 +1,349 @@
+// News MCP Dashboard — pure JS + Chart.js
+// Clusters are ALWAYS ordered by date descending (freshest first)
+var API = '/api/v1';
+var _charts = {};
+var _clustersData = [];
+var _entitiesData = [];
+var _healthLoaded = false;
+
+function switchView(name) {
+  var views = ['health','clusters','sentiment','entities','detail'];
+  if (views.indexOf(name) === -1) return;
+  document.querySelectorAll('.view').forEach(function(v) { v.classList.toggle('active', v.id === 'view-' + name); });
+  document.querySelectorAll('.nav-links a').forEach(function(a) { a.classList.toggle('active', a.dataset.view === name); });
+  if (name === 'health') loadHealth();
+  if (name === 'clusters') reloadClusters();
+  if (name === 'sentiment') reloadSentiment();
+  if (name === 'entities') loadEntities();
+}
+
+function $(id) {
+  if (!id) return null;
+  return document.getElementById(id) || document.querySelector('#' + id);
+}
+
+function esc(s) {
+  if (s == null || s === undefined) return '';
+  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function topicChip(topic) {
+  var t = (topic || 'other').toString().replace(/[^a-z0-9]/g,'');
+  return '<span class="chip topic-' + t + '">' + esc(topic) + '</span>';
+}
+
+function sentimentClass(s) {
+  if (s === 'positive') return 'sentiment-pos';
+  if (s === 'negative') return 'sentiment-neg';
+  return 'sentiment-neu';
+}
+
+// ── Health ──────────────────────────────────────────────
+async function loadHealth() {
+  try {
+    var res = await fetch(API + '/health');
+    var d = await res.json();
+    var sc = $('stat-clusters'); if (sc) sc.textContent = d.total_clusters;
+    var se = $('stat-entities'); if (se) se.textContent = d.total_entities;
+    var sf = $('stat-fresh');
+    if (sf) {
+      if (d.data_fresh) { sf.textContent = 'Fresh'; sf.className = 'value green'; }
+      else { sf.textContent = 'Stale'; sf.className = 'value red'; }
+    }
+    var sr = $('stat-refresh'); if (sr) sr.textContent = d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleString() : 'never';
+    var nm = $('nav-meta'); if (nm) nm.textContent = 'Refresh: ' + (d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleTimeString() : 'never');
+    renderTopicPie(d.clusters_by_topic || {});
+    await renderSentimentOverview();
+    renderFeedStatus(d.feeds || {});
+    _healthLoaded = true;
+  } catch(e) {
+    console.error('Health load error:', e);
+    var hs = $('health-stats');
+    if (hs) hs.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
+  }
+}
+
+function renderTopicPie(topics) {
+  var el = $('chart-topic-dist'); if (!el) return;
+  var ctx = el.getContext('2d');
+  var colors = { crypto:'#f59e0b', macro:'#8b5cf6', regulation:'#3b82f6', ai:'#10b981', other:'#6b7280' };
+  var labels = Object.keys(topics);
+  if (_charts.topicDist) _charts.topicDist.destroy();
+  _charts.topicDist = new Chart(ctx, {
+    type: 'doughnut',
+    data: { labels: labels, datasets: [{ data: labels.map(function(l){return topics[l]}), backgroundColor: labels.map(function(l){return colors[l]||'#555'}), borderWidth: 0 }] },
+    options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom',labels:{color:'#8a8f9b',font:{size:11},padding:12}}} }
+  });
+}
+
+async function renderSentimentOverview() {
+  try {
+    var res = await fetch(API + '/sentiment-series?hours=24&bucket_hours=4');
+    var d = await res.json();
+    renderSentimentChart(d.series || [], 'chart-sentiment-overview', false);
+  } catch(e) { console.error('Sentiment overview error:', e); }
+}
+
+function renderFeedStatus(feeds) {
+  var el = $('feed-status');
+  if (!el) return;
+  if (!feeds || !Object.keys(feeds).length) {
+    el.innerHTML = '<p class="muted" style="font-size:.82rem;padding:.5rem 0">No feed state yet.</p>';
+    return;
+  }
+  var html = '';
+  for (var k in feeds) {
+    var v = feeds[k];
+    html += '<div class="feed-item"><span>' + esc(k.replace(/_/g,' ')) + '</span><span class="muted">' + (v.updated_at ? new Date(v.updated_at).toLocaleString() : 'n/a') + '</span></div>';
+  }
+  el.innerHTML = html;
+}
+
+// ── Clusters (ALWAYS date descending) ──────────────────────
+async function reloadClusters() {
+  var topic = $('cluster-topic').value;
+  var hours = $('cluster-hours').value;
+  try {
+    var res = await fetch(API + '/clusters?topic=' + encodeURIComponent(topic) + '&hours=' + hours + '&limit=50');
+    var d = await res.json();
+    if (d.error) throw new Error(d.error);
+    _clustersData = d.clusters || [];
+    // Data is already sorted by server (date DESC). Force-sort client-side as safety net.
+    _clustersData.sort(function(a,b) {
+      var ta = new Date(a.timestamp || 0).getTime();
+      var tb = new Date(b.timestamp || 0).getTime();
+      return tb - ta; // newest first
+    });
+    var ct = $('cluster-total'); if (ct) ct.textContent = _clustersData.length + ' / ' + d.total;
+    renderClusterTable(_clustersData);
+  } catch(e) {
+    console.error('Clusters error:', e);
+    var ct2 = $('cluster-table');
+    if (ct2) ct2.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
+  }
+}
+
+function filterClusters() {
+  var q = $('cluster-search').value.toLowerCase().trim();
+  var data = _clustersData;
+  if (q) {
+    data = _clustersData.filter(function(c) {
+      return (c.headline||'').toLowerCase().indexOf(q) !== -1 || (c.entities||[]).some(function(e) { return (e||'').toLowerCase().indexOf(q) !== -1; });
+    });
+    var ct = $('cluster-total'); if (ct) ct.textContent = data.length + ' / ' + _clustersData.length;
+  }
+  renderClusterTable(data);
+}
+
+function renderClusterTable(clusters) {
+  var el = $('cluster-table');
+  if (!el) return;
+  if (!clusters || !clusters.length) {
+    el.innerHTML = '<div class="loading" style="padding:2rem;text-align:center">No clusters in this window</div>';
+    return;
+  }
+  // Sorted by date descending — newest first
+  var html = '<thead><tr><th>\u23f1\ufe0f Time \u25bc</th><th>Headline</th><th>Topic</th><th>Sentiment</th><th>Imp.</th><th>Entities</th></tr></thead><tbody>';
+  for (var i = 0; i < clusters.length; i++) {
+    var c = clusters[i];
+    var ts = c.timestamp ? new Date(c.timestamp).toLocaleString() : '';
+    var sc = sentimentClass(c.sentiment || 'neutral');
+    var imp = c.importance || 0;
+    var chips = '';
+    var ents = c.entities || [];
+    for (var ei = 0; ei < Math.min(ents.length, 5); ei++) {
+      chips += '<span class="chip" title="' + esc(ents[ei]) + '">' + esc(String(ents[ei]||'').substring(0,22)) + '</span>';
+    }
+    html += '<tr style="cursor:pointer" onclick="openClusterModal(\'' + esc(c.cluster_id) + '\')">';
+    html += '<td style="white-space:nowrap;font-size:.78rem">' + ts + '</td>';
+    html += '<td><a href="#" onclick="event.stopPropagation();openClusterModal(\'' + esc(c.cluster_id) + '\')">' + esc(c.headline) + '</a></td>';
+    html += '<td>' + topicChip(c.topic) + '</td>';
+    html += '<td class="' + sc + '" style="font-weight:600;font-size:.82rem">' + esc(c.sentiment) + ' <span style="opacity:.7">(' + esc(String(c.sentimentScore||'')) + ')</span></td>';
+    html += '<td style="text-align:center"><span style="font-size:.75rem">' + imp + '</span></td>';
+    html += '<td style="max-width:200px">' + (chips || '\u2014') + '</td></tr>';
+  }
+  html += '</tbody>';
+  el.innerHTML = '<table>' + html + '</table>';
+}
+
+// ── Sentiment ─────────────────────────────────────────────
+async function reloadSentiment() {
+  var topic = $('sentiment-topic').value;
+  var hours = $('sentiment-hours').value;
+  var bucket = $('sentiment-bucket').value;
+  try {
+    var res = await fetch(API + '/sentiment-series?topic=' + encodeURIComponent(topic) + '&hours=' + hours + '&bucket_hours=' + bucket);
+    var d = await res.json();
+    renderSentimentChart(d.series || [], 'chart-sentiment', true);
+    renderSentimentStats(d.series || []);
+  } catch(e) {
+    console.error('Sentiment error:', e);
+    var el = $('chart-sentiment');
+    if (el) el.parentElement.innerHTML = '<div class="loading">Error: ' + esc(e.message) + '</div>';
+  }
+}
+
+function renderSentimentChart(series, canvasId, showCount) {
+  if (!series.length) {
+    var el = $(canvasId);
+    if (el) el.parentElement.innerHTML = '<div class="loading">No data</div>';
+    return;
+  }
+  if (_charts.sentiment) { _charts.sentiment.destroy(); _charts.sentiment = null; }
+  var ctx = $(canvasId).getContext('2d');
+  var labels = series.map(function(s) { var d=new Date(s.time); return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); });
+  var datasets = [{ label:'Avg Sentiment', data:series.map(function(s){return s.avg_sentiment}), borderColor:'#5b8af5', backgroundColor:'rgba(91,138,245,0.15)', tension:0.3, yAxisID:'y', pointRadius:3 }];
+  if (showCount) datasets.push({ label:'Count', data:series.map(function(s){return s.count}), borderColor:'#fbbf24', backgroundColor:'rgba(251,191,36,0.15)', yAxisID:'y1', pointRadius:2 });
+  _charts.sentiment = new Chart(ctx, {
+    type: 'line', data: { labels: labels, datasets: datasets },
+    options: {
+      responsive: true, maintainAspectRatio: false,
+      interaction: { mode: 'index', intersect: false },
+      scales: {
+        y:  { position:'left', title:{display:true,text:'Sentiment',color:'#8a8f9b'}, grid:{color:'rgba(42,46,58,.5)'}, ticks:{color:'#8a8f9b'} },
+        y1: { position:'right', title:{display:true,text:'Count',color:'#8a8f9b'}, grid:{drawOnChartArea:false}, ticks:{color:'#8a8f9b'} },
+        x:  { ticks:{color:'#8a8f9b',maxRotation:45}, grid:{color:'rgba(42,46,58,.3)'} }
+      },
+      plugins: { legend: { labels: { color: '#8a8f9b' } } }
+    }
+  });
+}
+
+function renderSentimentStats(series) {
+  var el = $('sentiment-stats'); if (!el) return;
+  if (!series.length) { el.innerHTML = ''; return; }
+  var avg = (series.reduce(function(s,v){return s+v.avg_sentiment},0)/series.length).toFixed(3);
+  var max = Math.max.apply(null, series.map(function(s){return s.avg_sentiment})).toFixed(3);
+  var min = Math.min.apply(null, series.map(function(s){return s.avg_sentiment})).toFixed(3);
+  var total = series.reduce(function(s,v){return s+v.count},0);
+  var pos = series.filter(function(s){return s.avg_sentiment>0.15}).length;
+  var neg = series.filter(function(s){return s.avg_sentiment<-0.15}).length;
+  el.innerHTML = '<span class="sentiment-stat">Avg: <b>'+avg+'</b></span>'
+    +'<span class="sentiment-stat">Peak +: <b style="color:var(--green)">'+max+'</b></span>'
+    +'<span class="sentiment-stat">Peak -: <b style="color:var(--red)">'+min+'</b></span>'
+    +'<span class="sentiment-stat">Clusters: <b>'+total+'</b></span>'
+    +'<span class="sentiment-stat">+ buckets: <b class="sentiment-pos">'+pos+'</b></span>'
+    +'<span class="sentiment-stat">- buckets: <b class="sentiment-neg">'+neg+'</b></span>';
+}
+
+// ── Entities ─────────────────────────────────────────────
+async function loadEntities() {
+  try {
+    var res = await fetch(API + '/entities?hours=144&limit=30');
+    var d = await res.json();
+    _entitiesData = d.entities || [];
+    renderEntityList();
+    renderEntityChart();
+  } catch(e) {
+    console.error('Entities error:', e);
+    var el = $('entity-list'); if (el) el.innerHTML = '<div class="loading">Error</div>';
+  }
+}
+
+function renderEntityList() {
+  var el = $('entity-list'); if (!el) return;
+  if (!_entitiesData.length) { el.innerHTML = '<div class="loading">No entities</div>'; return; }
+  var html = '';
+  for (var i = 0; i < _entitiesData.length; i++) {
+    var e = _entitiesData[i];
+    html += '<div class="entity-row" onclick="showEntityDetail(\''+esc(e.label)+'\')" style="cursor:pointer"><span class="ent-label">' + esc(e.canonical_label||e.label) + '</span><span class="ent-count">' + e.count + 'x</span></div>';
+  }
+  el.innerHTML = html;
+}
+
+function renderEntityChart() {
+  var top15 = _entitiesData.slice(0,15);
+  if (!top15.length) return;
+  if (_charts.entities) _charts.entities.destroy();
+  _charts.entities = new Chart($('chart-entities').getContext('2d'), {
+    type: 'bar',
+    data: { labels: top15.map(function(e){return (e.canonical_label||e.label).substring(0,24)}), datasets: [{ label:'Mentions', data:top15.map(function(e){return e.count}), backgroundColor:'rgba(91,138,245,0.3)', borderColor:'#5b8af5', borderWidth:1 }] },
+    options: { indexAxis:'y', responsive:true, plugins:{legend:{display:false}}, scales:{ x:{ticks:{color:'#8a8f9b'},grid:{color:'rgba(42,46,58,.5)'}}, y:{ticks:{color:'#8a8f9b'},grid:{display:false}} } }
+  });
+}
+
+// Show clusters that mention this entity, sorted by date DESC
+async function showEntityDetail(label) {
+  if (!label) return;
+  var el = $('entity-detail'); if (!el) return;
+  el.innerHTML = '<div class="loading">Fetching clusters mentioning ' + esc(label) + '...</div>';
+  try {
+    var res = await fetch(API + '/clusters?topic=all&hours=144&limit=100');
+    var d = await res.json();
+    var matched = (d.clusters || []).filter(function(c) {
+      return (c.entities||[]).some(function(e) { return (e||'').toLowerCase() === label.toLowerCase(); });
+    });
+    // Sort by timestamp descending — newest first
+    matched.sort(function(a,b) {
+      var ta = new Date(a.timestamp || 0).getTime();
+      var tb = new Date(b.timestamp || 0).getTime();
+      return tb - ta;
+    });
+    if (!matched.length) { el.innerHTML = '<p class="muted">No clusters mention "' + esc(label) + '" in the current window.</p>'; return; }
+    var html = '<h4 style="font-size:.85rem;margin-bottom:.5rem">Clusters mentioning ' + esc(label) + ' (' + matched.length + ')</h4>';
+    for (var i = 0; i < matched.length; i++) {
+      var c = matched[i];
+      html += '<div style="margin-bottom:.6rem;padding:.6rem;background:var(--surface2);border-radius:6px;font-size:.82rem;cursor:pointer" onclick="openClusterModal(\''+esc(c.cluster_id)+'\')">'+
+        '<b>'+esc(c.headline)+'</b><br><span class="muted">'+topicChip(c.topic)+' '+sentimentClass(c.sentiment)+' '+esc(String(c.sentimentScore||''))+' &middot; '+esc(String(c.timestamp||''))+'</span></div>';
+    }
+    el.innerHTML = html;
+  } catch(e) {
+    el.innerHTML = '<p class="muted">Error: ' + esc(e.message) + '</p>';
+  }
+}
+
+// ── Detail modal ─────────────────────────────────────────
+function openClusterModal(clusterId) {
+  if (!clusterId) return;
+  $('cluster-modal').classList.add('open');
+  $('modal-content').innerHTML = '<div class="loading">Loading...</div>';
+  fetch(API + '/cluster/' + encodeURIComponent(clusterId))
+    .then(function(r){ return r.json(); })
+    .then(function(d) {
+      var mc = $('modal-content');
+      if (!mc) return;
+      mc.innerHTML = d.error ? '<p class="muted">' + esc(d.error) + '</p>' : buildDetailHTML(d);
+    })
+    .catch(function() { var mc = $('modal-content'); if (mc) mc.innerHTML = '<p class="muted">Error loading detail.</p>'; });
+}
+
+function closeModal() { var m = $('cluster-modal'); if (m) m.classList.remove('open'); }
+
+function searchDetail() {
+  var q = $('detail-search').value.trim();
+  if (!q) return;
+  var el = $('detail-content'); if (el) el.innerHTML = '<div class="loading">Searching...</div>';
+  fetch(API + '/cluster/' + encodeURIComponent(q))
+    .then(function(r) { if (r.status===404) { var el2=$('detail-content'); if(el2) el2.innerHTML='<p class="muted">Not found.</p>'; return null; } return r.json(); })
+    .then(function(d) { if (d) { var el3=$('detail-content'); if(el3) el3.innerHTML = buildDetailHTML(d); } });
+}
+
+function buildDetailHTML(d) {
+  var h = '<div class="detail-section"><h4>Headline</h4><div class="detail-content">' + esc(d.headline||'') + '</div></div>';
+  h += '<div class="detail-section"><h4>Metadata</h4><div class="detail-content">';
+  h += topicChip(d.topic) + ' <span class="' + sentimentClass(d.sentiment) + '" style="font-weight:600">' + esc(d.sentiment) + ' (' + esc(String(d.sentimentScore||'')) + ')</span>';
+  h += ' <span style="color:var(--accent);margin-left:.5rem">Importance: ' + (d.importance||0) + '</span>';
+  h += '<br><span class="muted">First: ' + (d.first_seen||'n/a') + ' &middot; Updated: ' + (d.last_updated||'n/a') + '</span></div></div>';
+  if (d.summary_text) h += '<div class="detail-section"><h4>Summary</h4><div class="detail-content">' + esc(d.summary_text) + '</div></div>';
+  if (d.summary) h += '<div class="detail-section"><h4>LLM Summary</h4><div class="detail-content">' + esc(d.summary) + '</div></div>';
+  if (d.key_facts && d.key_facts.length) h += '<div class="detail-section"><h4>Key Facts</h4><div class="detail-content">' + d.key_facts.map(function(f){return '<div class="key-fact">&bull; ' + esc(f) + '</div>';}).join('') + '</div></div>';
+  if (d.entities && d.entities.length) h += '<div class="detail-section"><h4>Entities ('+d.entities.length+')</h4><div class="detail-content">' + d.entities.map(function(e){return '<span class="chip">' + esc(e) + '</span>';}).join('') + '</div></div>';
+  if (d.keywords && d.keywords.length) h += '<div class="detail-section"><h4>Keywords ('+d.keywords.length+')</h4><div class="detail-content">' + d.keywords.map(function(k){return '<span class="chip">' + esc(k) + '</span>';}).join('') + '</div></div>';
+  if (d.articles && d.articles.length) {
+    h += '<div class="detail-section"><h4>Articles ('+d.articles.length+')</h4>';
+    for (var ai = 0; ai < Math.min(d.articles.length, 8); ai++) {
+      var a = d.articles[ai];
+      h += '<div style="margin-bottom:.5rem;padding:.5rem;background:var(--surface2);border-radius:6px;font-size:.82rem"><a href="' + esc(a.url||'#') + '" target="_blank" rel="noopener">' + esc(a.title||'Untitled') + '</a><br><span class="muted">' + esc(a.source||'') + ' &middot; ' + (a.timestamp||'') + '</span></div>';
+    }
+    h += '</div>';
+  }
+  return h;
+}
+
+setInterval(function() {
+  fetch(API + '/health').then(function(r){return r.json()}).then(function(d) {
+    var nm = $('nav-meta'); if (nm) nm.textContent = 'Last refresh: ' + (d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleTimeString() : 'never');
+  }).catch(function(){});
+}, 30000);
+
+document.addEventListener('DOMContentLoaded', loadHealth);

+ 150 - 0
dashboard/index.html

@@ -0,0 +1,150 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>News MCP Dashboard</title>
+<link rel="stylesheet" href="/dashboard/style.css">
+<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.12"></script>
+<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
+</head>
+<body>
+
+<!-- TOP NAV -->
+<nav class="topnav">
+  <div class="nav-brand">📡 news-mcp dashboard</div>
+  <div class="nav-links">
+    <a href="#" onclick="switchView('health'); return false;" class="active" data-view="health">Health</a>
+    <a href="#" onclick="switchView('clusters'); return false;" data-view="clusters">Clusters</a>
+    <a href="#" onclick="switchView('sentiment'); return false;" data-view="sentiment">Sentiment</a>
+    <a href="#" onclick="switchView('entities'); return false;" data-view="entities">Entities</a>
+    <a href="#" onclick="switchView('detail'); return false;" data-view="detail">Detail</a>
+  </div>
+  <div class="nav-meta" id="nav-meta"></div>
+</nav>
+
+<!-- HEALTH VIEW -->
+<div id="view-health" class="view active">
+  <div class="card">
+    <h3>📊 System Status</h3>
+    <div id="health-stats" class="stat-grid">
+      <div class="stat-box"><div class="label">Total Clusters</div><div class="value blue" id="stat-clusters">—</div></div>
+      <div class="stat-box"><div class="label">Total Entities</div><div class="value blue" id="stat-entities">—</div></div>
+      <div class="stat-box"><div class="label">Data Fresh</div><div class="value" id="stat-fresh">—</div></div>
+      <div class="stat-box"><div class="label">Last Refresh</div><div class="value" style="font-size:1rem" id="stat-refresh">—</div></div>
+    </div>
+  </div>
+
+  <div class="grid grid-3" style="margin-top:1rem">
+    <div class="card">
+      <h3>📋 Topics Distribution</h3>
+      <div class="chart-wrap"><canvas id="chart-topic-dist"></canvas></div>
+    </div>
+    <div class="card">
+      <h3>📈 Sentiment Over Time (4h buckets)</h3>
+      <div class="chart-wrap"><canvas id="chart-sentiment-overview"></canvas></div>
+    </div>
+    <div class="card">
+      <h3>🔗 Feed Activity</h3>
+      <div id="feed-status"><div class="loading">Loading…</div></div>
+    </div>
+  </div>
+</div>
+
+<!-- CLUSTERS VIEW -->
+<div id="view-clusters" class="view">
+  <div class="card">
+    <div class="toolbar">
+      <div class="filters">
+        <select id="cluster-topic" onchange="reloadClusters()">
+          <option value="all">All Topics</option>
+          <option value="crypto">Crypto</option>
+          <option value="macro">Macro</option>
+          <option value="regulation">Regulation</option>
+          <option value="ai">AI</option>
+        </select>
+        <select id="cluster-hours" onchange="reloadClusters()">
+          <option value="1">Last 1h</option>
+          <option value="6">Last 6h</option>
+          <option value="144" selected>Last 144h</option>
+          <option value="72">Last 72h</option>
+        </select>
+        <input type="text" id="cluster-search" placeholder="Search headlines…" onkeyup="filterClusters()" />
+        <span class="badge" id="cluster-total">—</span>
+      </div>
+    </div>
+    <div id="cluster-table"></div>
+  </div>
+</div>
+
+<!-- SENTIMENT VIEW -->
+<div id="view-sentiment" class="view">
+  <div class="card">
+    <div class="toolbar">
+      <div class="filters">
+        <select id="sentiment-topic" onchange="reloadSentiment()">
+          <option value="all">All Topics</option>
+          <option value="crypto">Crypto</option>
+          <option value="macro">Macro</option>
+          <option value="regulation">Regulation</option>
+          <option value="ai">AI</option>
+        </select>
+        <select id="sentiment-hours" onchange="reloadSentiment()">
+          <option value="6">6h</option>
+          <option value="144" selected>144h</option>
+          <option value="72">72h</option>
+          <option value="168">7d</option>
+        </select>
+        <select id="sentiment-bucket" onchange="reloadSentiment()">
+          <option value="1">1h buckets</option>
+          <option value="4" selected>4h buckets</option>
+          <option value="12">12h buckets</option>
+        </select>
+      </div>
+    </div>
+    <div class="chart-wrap"><canvas id="chart-sentiment"></canvas></div>
+    <div id="sentiment-stats" class="sentiment-stats"></div>
+  </div>
+</div>
+
+<!-- ENTITIES VIEW -->
+<div id="view-entities" class="view">
+  <div class="grid grid-3">
+    <div class="card card-wide">
+      <h3>🔗 Top Entities <small class="muted">(24h mentions)</small></h3>
+      <div id="entity-list"><div class="loading">Loading…</div></div>
+    </div>
+    <div class="card">
+      <h3>📊 Entity Frequency</h3>
+      <div class="chart-wrap"><canvas id="chart-entities"></canvas></div>
+    </div>
+    <div class="card">
+      <h3>ℹ️ Entity Detail</h3>
+      <div id="entity-detail"><p class="muted">Click an entity in the list to see details.</p></div>
+    </div>
+  </div>
+</div>
+
+<!-- DETAIL VIEW -->
+<div id="view-detail" class="view">
+  <div class="card">
+    <div class="toolbar">
+      <input type="text" id="detail-search" placeholder="Paste cluster_id or search…" />
+      <button onclick="searchDetail()">🔍 Search</button>
+    </div>
+    <div id="detail-content"><p class="muted">Select a cluster from <a href="#" onclick="switchView('clusters'); return false;">Clusters</a> or search by cluster ID.</p></div>
+  </div>
+</div>
+
+<!-- DETAIL MODAL (cluster drill-down) -->
+<div id="cluster-modal" class="modal-overlay" onclick="closeModal()">
+  <div class="modal" onclick="event.stopPropagation()">
+    <button class="modal-close" onclick="closeModal()">✕</button>
+    <div id="modal-content"><div class="loading">Loading…</div></div>
+  </div>
+</div>
+
+<div id="toast" class="toast"></div>
+<script src="/dashboard/dashboard.js"></script>
+</body>
+</html>

+ 197 - 0
dashboard/style.css

@@ -0,0 +1,197 @@
+/* ── Reset & Base ─────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+:root {
+  --bg: #0f1117;
+  --surface: #181b23;
+  --surface2: #1e222d;
+  --border: #2a2e3a;
+  --text: #e1e4eb;
+  --text-dim: #8a8f9b;
+  --accent: #5b8af5;
+  --accent-dim: #2d4a8a;
+  --green: #34d399;
+  --green-bg: rgba(52,211,153,.12);
+  --red: #f87171;
+  --red-bg: rgba(248,113,113,.12);
+  --yellow: #fbbf24;
+  --yellow-bg: rgba(251,191,36,.12);
+  --orange: #fb923c;
+  --blue: #60a5fa;
+  --radius: 8px;
+  --shadow: 0 2px 8px rgba(0,0,0,.3);
+}
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  line-height: 1.5;
+  min-height: 100vh;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Top Nav ──────────────────────────────────────── */
+.topnav {
+  display: flex;
+  align-items: center;
+  gap: 1.5rem;
+  padding: 0 1.5rem;
+  height: 56px;
+  background: var(--surface);
+  border-bottom: 1px solid var(--border);
+  position: sticky; top: 0; z-index: 100;
+}
+.nav-brand {
+  font-weight: 700; font-size: 1.1rem;
+  color: var(--accent);
+  white-space: nowrap;
+}
+.nav-links { display: flex; gap: .25rem; flex: 1; }
+.nav-links a {
+  padding: .5rem .85rem; border-radius: var(--radius);
+  font-size: .85rem; color: var(--text-dim); transition: .15s;
+}
+.nav-links a:hover { color: var(--text); background: var(--surface2); }
+.nav-links a.active { color: var(--accent); background: var(--accent-dim); }
+.nav-meta { font-size: .75rem; color: var(--text-dim); white-space: nowrap; }
+
+/* ── Layout ───────────────────────────────────────── */
+.view { display: none; padding: 1.25rem; max-width: 1400px; margin: 0 auto; }
+.view.active { display: block; }
+.grid { display: grid; gap: 1rem; }
+.grid-2 { grid-template-columns: 1fr 1fr; }
+.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
+.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
+@media (max-width: 900px) {
+  .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
+}
+
+/* ── Cards ────────────────────────────────────────── */
+.card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  padding: 1.15rem;
+  box-shadow: var(--shadow);
+}
+.card-wide { grid-column: 1 / -1; }
+.card h3 { font-size: .9rem; color: var(--text-dim); margin-bottom: .75rem; font-weight: 600; }
+.card h3 small { font-weight: 400; }
+
+/* ── Stat Box ─────────────────────────────────────── */
+.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
+.stat-box {
+  background: var(--surface2); border-radius: var(--radius); padding: 1rem;
+  text-align: center; border: 1px solid var(--border);
+}
+.stat-box .label { font-size: .7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
+.stat-box .value { font-size: 1.6rem; font-weight: 700; margin-top: .25rem; }
+.stat-box .value.green { color: var(--green); }
+.stat-box .value.red { color: var(--red); }
+.stat-box .value.yellow { color: var(--yellow); }
+.stat-box .value.blue { color: var(--accent); }
+
+/* ── Toolbar & Filters ────────────────────────────── */
+.toolbar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; margin-bottom: 1rem; }
+.filters { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
+.filters select, .filters input[type=text] {
+  background: var(--surface2); border: 1px solid var(--border);
+  color: var(--text); padding: .4rem .6rem; border-radius: 6px; font-size: .85rem;
+}
+.filters input[type=text] { min-width: 200px; }
+button {
+  background: var(--accent); color: #fff; border: none; padding: .4rem .8rem;
+  border-radius: 6px; cursor: pointer; font-size: .85rem;
+}
+button:hover { opacity: .85; }
+.badge {
+  background: var(--accent-dim); color: var(--accent);
+  padding: .15rem .55rem; border-radius: 99px; font-size: .75rem;
+  font-weight: 600;
+}
+
+/* ── Tables ───────────────────────────────────────── */
+table { width: 100%; border-collapse: collapse; font-size: .82rem; }
+th {
+  text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--border);
+  color: var(--text-dim); font-weight: 600; font-size: .75rem;
+  text-transform: uppercase; letter-spacing: .03em; cursor: pointer;
+}
+td { padding: .55rem .6rem; border-bottom: 1px solid rgba(42,46,58,.5); vertical-align: top; }
+tr:hover td { background: rgba(91,138,245,.05); }
+.chip {
+  display: inline-block; font-size: .7rem; padding: .1rem .45rem;
+  border-radius: 4px; margin: .1rem .15rem;
+  background: var(--surface2); border: 1px solid var(--border);
+}
+.chip.topic-crypto { border-color: #f59e0b; color: #f59e0b; }
+.chip.topic-macro { border-color: #8b5cf6; color: #8b5cf6; }
+.chip.topic-regulation { border-color: #3b82f6; color: #3b82f6; }
+.chip.topic-ai { border-color: #10b981; color: #10b981; }
+.chip.topic-other { border-color: #6b7280; color: #6b7280; }
+
+.sentiment-pos { color: var(--green); }
+.sentiment-neg { color: var(--red); }
+.sentiment-neu { color: var(--yellow); }
+
+/* ── Sentiment Stats ──────────────────────────────── */
+.sentiment-stats {
+  display: flex; gap: 1rem; margin-top: 1rem; flex-wrap: wrap;
+  padding-top: .75rem; border-top: 1px solid var(--border);
+}
+.sentiment-stat { font-size: .8rem; color: var(--text-dim); }
+.sentiment-stat b { color: var(--text); }
+
+/* ── Entity List ──────────────────────────────────── */
+.entity-row {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: .5rem .6rem; border-bottom: 1px solid rgba(42,46,58,.5);
+  cursor: pointer; font-size: .85rem;
+}
+.entity-row:hover { background: rgba(91,138,245,.07); }
+.entity-row .ent-label { font-weight: 500; }
+.entity-row .ent-count {
+  background: var(--accent-dim); color: var(--accent);
+  padding: .1rem .5rem; border-radius: 99px; font-size: .75rem;
+  font-weight: 600;
+}
+
+/* ── Detail Panel ─────────────────────────────────── */
+.detail-section { margin-bottom: 1rem; }
+.detail-section h4 { font-size: .8rem; color: var(--text-dim); margin-bottom: .35rem; text-transform: uppercase; }
+.detail-content { background: var(--surface2); padding: .75rem; border-radius: 6px; font-size: .85rem; line-height: 1.6; }
+.detail-content p { margin-bottom: .5rem; }
+.key-fact { padding-left: 1rem; margin-bottom: .3rem; }
+
+/* ── Loading / Misc ───────────────────────────────── */
+.loading { text-align: center; padding: 2rem; color: var(--text-dim); font-style: italic; }
+.muted { color: var(--text-dim); font-size: .85rem; }
+.fresh { color: var(--green); }
+.stale { color: var(--red); }
+
+/* ── Modal ────────────────────────────────────────── */
+.modal-overlay {
+  display: none; position: fixed; inset: 0;
+  background: rgba(0,0,0,.7); z-index: 200;
+  justify-content: center; align-items: center;
+  backdrop-filter: blur(4px);
+}
+.modal-overlay.open { display: flex; }
+.modal {
+  background: var(--surface); border: 1px solid var(--border);
+  border-radius: 12px; padding: 1.5rem; max-width: 700px; width: 90vw;
+  max-height: 85vh; overflow-y: auto; position: relative;
+  box-shadow: 0 8px 32px rgba(0,0,0,.5);
+}
+.modal-close {
+  position: absolute; top: .75rem; right: .75rem;
+  background: none; border: none; color: var(--text-dim);
+  font-size: 1.2rem; cursor: pointer;
+}
+
+/* ── Chart containers ─────────────────────────────── */
+.chart-wrap { position: relative; height: 280px; }
+
+/* ── Feed status ──────────────────────────────────── */
+.feed-item { display: flex; justify-content: space-between; padding: .4rem 0; font-size: .82rem; border-bottom: 1px solid rgba(42,46,58,.4); }
+.feed-item:last-child { border: none; }

+ 0 - 0
news_mcp/dashboard/__init__.py


+ 238 - 0
news_mcp/dashboard/dashboard_store.py

@@ -0,0 +1,238 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from news_mcp.config import (
+    NEWS_PRUNE_INTERVAL_HOURS,
+    NEWS_PRUNING_ENABLED,
+    NEWS_REFRESH_INTERVAL_SECONDS,
+    NEWS_RETENTION_DAYS,
+)
+from news_mcp.storage.sqlite_store import SQLiteClusterStore
+
+
+class DashboardStore:
+    """Read-only query layer for the dashboard."""
+
+    def __init__(self, store=None):
+        if store is not None:
+            self._store = store
+        else:
+            from news_mcp.config import DB_PATH
+            self._store = SQLiteClusterStore(DB_PATH)
+
+    # ── Health & Stats ──────────────────────────────────────────────
+
+    def get_dashboard_stats(self) -> dict[str, Any]:
+        with self._store._conn() as conn:
+            total_clusters = conn.execute("SELECT COUNT(*) FROM clusters").fetchone()[0]
+            total_entities = conn.execute("SELECT COUNT(*) FROM entity_metadata").fetchone()[0]
+            topic_counts = dict(conn.execute(
+                "SELECT topic, COUNT(*) FROM clusters GROUP BY topic"
+            ).fetchall())
+
+        last_refresh = self._store.get_meta("last_refresh_at")
+        last_prune = self._store.get_meta("last_prune_at")
+
+        # Freshness: did a refresh happen recently? (within 2x the configured interval)
+        fresh = False
+        if last_refresh:
+            try:
+                dt = datetime.fromisoformat(last_refresh.replace("Z", "+00:00"))
+                if dt.tzinfo is None:
+                    dt = dt.replace(tzinfo=timezone.utc)
+                age_hours = (datetime.now(timezone.utc) - dt).total_seconds() / 3600
+                fresh = age_hours < max(1.0, NEWS_REFRESH_INTERVAL_SECONDS / 3600) * 2
+            except Exception:
+                pass
+
+        feeds = {}
+        with self._store._conn() as conn:
+            for row in conn.execute("SELECT feed_key, last_hash, updated_at FROM feed_state"):
+                feeds[row[0]] = {"last_hash": row[1], "updated_at": row[2]}
+
+        return {
+            "total_clusters": total_clusters,
+            "total_entities": total_entities,
+            "clusters_by_topic": topic_counts,
+            "last_refresh_at": last_refresh,
+            "last_prune_at": last_prune,
+            "data_fresh": fresh,
+            "feeds": feeds,
+            "pruning": {
+                "enabled": NEWS_PRUNING_ENABLED,
+                "retention_days": NEWS_RETENTION_DAYS,
+                "interval_hours": NEWS_PRUNE_INTERVAL_HOURS,
+                "last_prune_at": last_prune,
+            },
+        }
+
+    # ── Clusters ────────────────────────────────────────────────────
+
+    def get_clusters_page(
+        self,
+        topic: str | None = None,
+        hours: float = 24,
+        limit: int = 20,
+        offset: int = 0,
+    ) -> list[dict[str, Any]]:
+        cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+        query = "SELECT payload FROM clusters WHERE updated_at >= ?"
+        params: list = [cutoff]
+        if topic and topic != "all":
+            query += " AND topic = ?"
+            params.append(topic)
+        query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
+        params.extend([limit, offset])
+
+        with self._store._conn() as conn:
+            cur = conn.execute(query, params)
+            rows = cur.fetchall()
+
+        clusters: list[dict[str, Any]] = []
+        for (payload_text,) in rows:
+            c = json.loads(payload_text)
+            clusters.append({
+                "cluster_id": c.get("cluster_id", ""),
+                "headline": c.get("headline", ""),
+                "topic": c.get("topic", ""),
+                "sentiment": c.get("sentiment", "neutral"),
+                "sentimentScore": c.get("sentimentScore"),
+                "importance": c.get("importance", 0),
+                "entities": c.get("entities", []),
+                "sources": c.get("sources", []),
+                "timestamp": c.get("timestamp", ""),
+                "keywords": c.get("keywords", []),
+                "article_count": len(c.get("articles", [])),
+            })
+        return clusters
+
+    def get_cluster_detail(self, cluster_id: str) -> dict[str, Any] | None:
+        with self._store._conn() as conn:
+            cur = conn.execute(
+                "SELECT payload FROM clusters WHERE cluster_id = ?", (cluster_id,)
+            )
+            row = cur.fetchone()
+            if not row:
+                return None
+            c = json.loads(row[0])
+            summary = None
+            if c.get("summary_payload"):
+                try:
+                    summary = json.loads(c["summary_payload"])
+                except Exception:
+                    pass
+            return {
+                "cluster_id": c.get("cluster_id"),
+                "headline": c.get("headline", ""),
+                "summary": c.get("summary", ""),
+                "topic": c.get("topic", ""),
+                "sentiment": c.get("sentiment", "neutral"),
+                "sentimentScore": c.get("sentimentScore"),
+                "importance": c.get("importance", 0),
+                "entities": c.get("entities", []),
+                "entityResolutions": c.get("entityResolutions", []),
+                "keywords": c.get("keywords", []),
+                "sources": c.get("sources", []),
+                "timestamp": c.get("timestamp", ""),
+                "first_seen": c.get("first_seen", ""),
+                "last_updated": c.get("last_updated", ""),
+                "article_count": len(c.get("articles", [])),
+                "articles": c.get("articles", []),
+                "summary_text": summary.get("mergedSummary", "") if summary else "",
+                "key_facts": summary.get("keyFacts", []) if summary else [],
+            }
+
+    # ── Sentiment Series ────────────────────────────────────────────
+
+    def get_sentiment_series(
+        self,
+        topic: str | None = None,
+        hours: float = 24,
+        bucket_hours: float = 1,
+    ) -> list[dict[str, Any]]:
+        cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+        query = "SELECT payload FROM clusters WHERE updated_at >= ?"
+        params: list = [cutoff]
+        if topic and topic != "all":
+            query += " AND topic = ?"
+            params.append(topic)
+        query += " ORDER BY updated_at ASC"
+
+        with self._store._conn() as conn:
+            cur = conn.execute(query, params)
+            rows = cur.fetchall()
+
+        def _parse_ts(ts: Any) -> datetime | None:
+            if not ts:
+                return None
+            try:
+                dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
+                if dt.tzinfo is None:
+                    dt = dt.replace(tzinfo=timezone.utc)
+                return dt.astimezone(timezone.utc)
+            except Exception:
+                return None
+
+        step_hours = max(1, int(bucket_hours))
+        buckets: dict[datetime, list[float]] = {}
+        for (payload_text,) in rows:
+            c = json.loads(payload_text)
+            dt = _parse_ts(c.get("timestamp"))
+            score = c.get("sentimentScore")
+            if dt is None or score is None:
+                continue
+            bucket_key = dt.replace(minute=0, second=0, microsecond=0)
+            if step_hours > 1:
+                bucket_key = bucket_key.replace(
+                    hour=(bucket_key.hour // step_hours) * step_hours
+                )
+            buckets.setdefault(bucket_key, []).append(float(score))
+
+        series: list[dict[str, Any]] = []
+        for bucket_key in sorted(buckets):
+            scores = buckets[bucket_key]
+            series.append({
+                "time": bucket_key.isoformat(),
+                "avg_sentiment": round(sum(scores) / len(scores), 3),
+                "count": len(scores),
+                "min": round(min(scores), 3),
+                "max": round(max(scores), 3),
+            })
+        return series
+
+    # ── Entity Frequencies ──────────────────────────────────────────
+
+    def get_entity_frequencies(
+        self,
+        hours: float = 24,
+        limit: int = 30,
+    ) -> list[dict[str, Any]]:
+        cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
+        with self._store._conn() as conn:
+            cur = conn.execute(
+                "SELECT payload FROM clusters WHERE updated_at >= ? "
+                "ORDER BY updated_at DESC LIMIT 500",
+                (cutoff,),
+            )
+            rows = cur.fetchall()
+
+        counter: dict[str, int] = {}
+        for (payload_text,) in rows:
+            c = json.loads(payload_text)
+            for ent in c.get("entities", []):
+                counter[ent] = counter.get(ent, 0) + 1
+
+        sorted_entities = sorted(counter.items(), key=lambda x: -x[1])[:limit]
+        result: list[dict[str, Any]] = []
+        for label, count in sorted_entities:
+            meta = self._store.get_entity_metadata(label)
+            result.append({
+                "label": label,
+                "count": count,
+                "canonical_label": meta["canonical_label"] if meta else label,
+                "mid": meta["mid"] if meta else None,
+            })
+        return result