// 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 _feedsData = []; var _healthLoaded = false; function switchView(name) { var views = ['health','feeds','clusters','sentiment','entities','keywords','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 === 'feeds') loadFeeds(); if (name === 'clusters') reloadClusters(); if (name === 'sentiment') reloadSentiment(); if (name === 'entities') loadEntities(); if (name === 'keywords') loadKeywords(); } 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,'&').replace(//g,'>').replace(/"/g,'"'); } function topicChip(topic) { var t = (topic || 'other').toString().replace(/[^a-z0-9]/g,''); return '' + esc(topic) + ''; } 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 sce = $('stat-cluster-entities'); if (sce) sce.textContent = d.cluster_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 = '
Error: ' + esc(e.message) + '
'; } } 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 = '

No feed state yet.

'; return; } var html = ''; for (var k in feeds) { var v = feeds[k]; var label = k.replace(/^https?:\/\//, '').replace(/\/$/, ''); var count = (v.last_item_count == null) ? 'n/a' : String(v.last_item_count); var isEnabled = v.enabled !== false; var statusClass = isEnabled ? 'enabled' : 'disabled'; var statusLabel = isEnabled ? 'ACTIVE' : 'OFF'; var favicon = isEnabled ? '🟢' : '⚫'; html += '
' + '
' + ''+favicon+'' + '' + esc(label) + '' + '' + statusLabel + '' + '
' + '' + esc(count) + ' items' + '
'; } el.innerHTML = html; } // ── Feeds Management ────────────────────────────────── async function loadFeeds() { try { var res = await fetch(API + '/feeds'); var d = await res.json(); _feedsData = d.feeds || []; renderFeedsList(); } catch(e) { console.error('Feeds load error:', e); var el = $('feeds-list'); if (el) el.innerHTML = '
Error: ' + esc(e.message) + '
'; } } function renderFeedsList() { var el = $('feeds-list'); if (!el) return; if (!_feedsData.length) { el.innerHTML = '

No feeds registered yet. Add URLs to .env and trigger a refresh.

'; return; } var enabledCount = 0; for (var i = 0; i < _feedsData.length; i++) { if (_feedsData[i].enabled !== false) enabledCount++; } var html = '
' + '' + enabledCount + ' / ' + _feedsData.length + ' feeds enabled' + '' + '
'; html += '
'; for (var i = 0; i < _feedsData.length; i++) { var f = _feedsData[i]; var domain = f.feed_key.replace(/^https?:\/\//, '').replace(/\/$/, ''); var lastItems = f.last_item_count != null ? f.last_item_count + ' items' : '—'; var lastSeen = f.updated_at ? ' · ' + new Date(f.updated_at).toLocaleString() : ''; var isEnabled = f.enabled !== false; html += '
' + '' + '
' + esc(domain) + '
' + '' + lastItems + lastSeen + '' + '
'; } html += '
'; el.innerHTML = html; } async function toggleFeed(feedUrl, enabled) { try { var res = await fetch(API + '/feeds/toggle', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'feed_url=' + encodeURIComponent(feedUrl) + '&enabled=' + (enabled ? 'true' : 'false') }); var d = await res.json(); if (!d.ok) throw new Error(d.error || 'Toggle failed'); // Update local state for (var i = 0; i < _feedsData.length; i++) { if (_feedsData[i].feed_key === feedUrl) { _feedsData[i].enabled = enabled; break; } } // Re-render renderFeedsList(); // Refresh health feed status if loaded if (_healthLoaded) loadHealth(); showToast('Feed ' + (enabled ? 'enabled' : 'disabled') + ': ' + feedUrl.replace(/^https?:\/\//, '')); } catch(e) { console.error('Toggle error:', e); showToast('Error toggling feed: ' + e.message, true); // Revert checkbox var cb = document.getElementById('feed-' + _feedsData.findIndex(function(f){return f.feed_key === feedUrl})); if (cb) cb.checked = !enabled; } } // ── 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 = '
Error: ' + esc(e.message) + '
'; } } 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 = '
No clusters in this window
'; return; } // Sorted by date descending — newest first var html = '\u23f1\ufe0f Time \u25bcHeadlineTopicSentimentImp.Entities' + ''; 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 += '' + esc(String(ents[ei]||'').substring(0,22)) + ''; } html += '' + '' + ts + '' + '' + esc(c.headline) + '' + '' + topicChip(c.topic) + '' + '' + esc(c.sentiment) + ' (' + esc(String(c.sentimentScore||'')) + ')' + '' + imp + '' + '' + (chips || '\u2014') + ''; } html += ''; el.innerHTML = '' + html + '
'; } // ── Sentiment ───────────────────────────────────────────── async function reloadSentiment() { var topic = $('sentiment-topic').value; var hours = $('sentiment-hours').value; var bucket = $('sentiment-bucket').value; // Use a wrapper div so we don't depend on the canvas element surviving innerHTML replacement var wrap = $('chart-sentiment') ? $('chart-sentiment').parentElement : null; var statsEl = $('sentiment-stats'); if (statsEl) statsEl.innerHTML = ''; if (wrap) wrap.innerHTML = '
Loading sentiment data…
'; try { var res = await fetch(API + '/sentiment-series?topic=' + encodeURIComponent(topic) + '&hours=' + hours + '&bucket_hours=' + bucket); var d = await res.json(); if (wrap && !$('chart-sentiment')) { wrap.innerHTML = ''; } renderSentimentChart(d.series || [], 'chart-sentiment', true); renderSentimentStats(d.series || []); } catch(e) { console.error('Sentiment error:', e); if (wrap) wrap.innerHTML = '
Error: ' + esc(e.message) + '
'; } } function renderSentimentChart(series, canvasId, showCount) { if (!series.length) { var el = $(canvasId); if (el) el.parentElement.innerHTML = '
No data
'; 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 = 'Avg: '+avg+'' +'Peak +: '+max+'' +'Peak -: '+min+'' +'Clusters: '+total+'' +'+ buckets: '+pos+'' +'- buckets: '+neg+''; } // ── 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 = '
Error
'; } } function renderEntityList() { var el = $('entity-list'); if (!el) return; if (!_entitiesData.length) { el.innerHTML = '
No entities
'; return; } var html = ''; for (var i = 0; i < _entitiesData.length; i++) { var e = _entitiesData[i]; html += '
' + esc(e.canonical_label||e.label) + '' + e.count + 'x
'; } 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 = '
Fetching clusters mentioning ' + esc(label) + '...
'; 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 = '

No clusters mention "' + esc(label) + '" in the current window.

'; return; } var html = '

Clusters mentioning ' + esc(label) + ' (' + matched.length + ')

'; for (var i = 0; i < matched.length; i++) { var c = matched[i]; html += '
'+ ''+esc(c.headline)+'
'+topicChip(c.topic)+' '+sentimentClass(c.sentiment)+' '+esc(String(c.sentimentScore||''))+' · '+esc(String(c.timestamp||''))+'
'; } el.innerHTML = html; } catch(e) { el.innerHTML = '

Error: ' + esc(e.message) + '

'; } } // ── Keywords ────────────────────────────────────────────── var _keywordsData = []; async function loadKeywords() { try { var res = await fetch(API + '/keywords?hours=144&limit=30'); var d = await res.json(); _keywordsData = d.keywords || []; renderKeywordList(); renderKeywordChart(); } catch(e) { console.error('Keywords error:', e); var el = $('keyword-list'); if (el) el.innerHTML = '
Error
'; } } function renderKeywordList() { var el = $('keyword-list'); if (!el) return; if (!_keywordsData.length) { el.innerHTML = '
No keywords
'; return; } var html = ''; for (var i = 0; i < _keywordsData.length; i++) { var k = _keywordsData[i]; html += '
' + esc(k.label) + '' + k.count + 'x
'; } el.innerHTML = html; } function renderKeywordChart() { var top15 = _keywordsData.slice(0,15); if (!top15.length) return; if (_charts.keywords) _charts.keywords.destroy(); _charts.keywords = new Chart($('chart-keywords').getContext('2d'), { type: 'bar', data: { labels: top15.map(function(k){return k.label.substring(0,24)}), datasets: [{ label:'Occurrences', data:top15.map(function(k){return k.count}), backgroundColor:'rgba(71,207,125,0.3)', borderColor:'#47cf7d', 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 containing this keyword, sorted by date DESC async function showKeywordDetail(label) { if (!label) return; var el = $('keyword-detail'); if (!el) return; el.innerHTML = '
Fetching clusters with keyword ' + esc(label) + '…
'; try { var res = await fetch(API + '/clusters?topic=all&hours=144&limit=200'); var d = await res.json(); var matched = (d.clusters || []).filter(function(c) { return (c.keywords||[]).some(function(k) { return (k||'').toLowerCase() === label.toLowerCase(); }); }); 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 = '

No clusters have keyword "' + esc(label) + '" in the current window.

'; return; } var html = '

Clusters with keyword ' + esc(label) + ' (' + matched.length + ')

'; for (var i = 0; i < matched.length; i++) { var c = matched[i]; html += '
'+ ''+esc(c.headline)+'
'+topicChip(c.topic)+' '+sentimentClass(c.sentiment)+' '+esc(String(c.sentimentScore||''))+' · '+esc(String(c.timestamp||''))+'
'; } el.innerHTML = html; } catch(e) { el.innerHTML = '

Error: ' + esc(e.message) + '

'; } } // ── Detail modal ───────────────────────────────────────── function openClusterModal(clusterId) { if (!clusterId) return; $('cluster-modal').classList.add('open'); $('modal-content').innerHTML = '
Loading...
'; 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 ? '

' + esc(d.error) + '

' : buildDetailHTML(d); }) .catch(function() { var mc = $('modal-content'); if (mc) mc.innerHTML = '

Error loading detail.

'; }); } 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 = '
Searching...
'; fetch(API + '/cluster/' + encodeURIComponent(q)) .then(function(r) { if (r.status===404) { var el2=$('detail-content'); if(el2) el2.innerHTML='

Not found.

'; 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 = '

Headline

' + esc(d.headline||'') + '
'; h += '

Metadata

'; h += topicChip(d.topic) + ' ' + esc(d.sentiment) + ' (' + esc(String(d.sentimentScore||'')) + ')'; h += ' Importance: ' + (d.importance||0) + ''; h += '
First: ' + (d.first_seen||'n/a') + ' · Updated: ' + (d.last_updated||'n/a') + '
'; if (d.summary_text) h += '

Summary

' + esc(d.summary_text) + '
'; if (d.summary) h += '

LLM Summary

' + esc(d.summary) + '
'; if (d.key_facts && d.key_facts.length) h += '

Key Facts

' + d.key_facts.map(function(f){return '
• ' + esc(f) + '
';}).join('') + '
'; if (d.entities && d.entities.length) h += '

Entities ('+d.entities.length+')

' + d.entities.map(function(e){return '' + esc(e) + '';}).join('') + '
'; if (d.keywords && d.keywords.length) h += '

Keywords ('+d.keywords.length+')

' + d.keywords.map(function(k){return '' + esc(k) + '';}).join('') + '
'; if (d.articles && d.articles.length) { h += '

Articles ('+d.articles.length+')

'; for (var ai = 0; ai < Math.min(d.articles.length, 8); ai++) { var a = d.articles[ai]; h += '
' + esc(a.title||'Untitled') + '
' + esc(a.source||'') + ' · ' + (a.timestamp||'') + '
'; } h += '
'; } return h; } // ── Toast ────────────────────────────────────────────────── function showToast(msg, isError) { var t = $('toast'); if (!t) return; t.textContent = msg; t.className = 'toast' + (isError ? ' toast-error' : ''); t.style.opacity = '1'; clearTimeout(t._timer); t._timer = setTimeout(function() { t.style.opacity = '0'; }, 2500); } // ── Periodic refresh ────────────────────────────────────── 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'); // If the feeds tab is visible, refresh feed data too if ($('view-feeds').classList.contains('active')) { loadFeeds(); } }).catch(function(){}); }, 30000); document.addEventListener('DOMContentLoaded', loadHealth);