// 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','config'];
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();
if (name === 'config') loadConfig();
}
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 += '
';
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;
var isReEnrich = f.re_enrich === true;
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;
}
}
async function toggleReEnrich(feedUrl, reEnrich) {
try {
var res = await fetch(API + '/feeds/set-re-enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'feed_url=' + encodeURIComponent(feedUrl) + '&re_enrich=' + (reEnrich ? '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].re_enrich = reEnrich; break; }
}
renderFeedsList();
showToast('Re-enrich ' + (reEnrich ? 'enabled' : 'disabled') + ': ' + feedUrl.replace(/^https?:\/\//, ''));
} catch(e) {
console.error('Re-enrich toggle error:', e);
showToast('Error toggling re-enrich: ' + e.message, true);
var cb = document.getElementById('re-enrich-' + _feedsData.findIndex(function(f){return f.feed_key === feedUrl}));
if (cb) cb.checked = !reEnrich;
}
}
// ── 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 \u25bc
Headline
Topic
Sentiment
Imp.
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 += '
';
}
// ── 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() {
var hours = ($('entity-hours') || {}).value || 24;
try {
var res = await fetch(API + '/entities?hours=' + hours + '&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 += '
';
}
return h;
}
// ── Config ──────────────────────────────────────────────
async function loadConfig() {
var el = $('config-list');
if (!el) return;
el.innerHTML = '
Loading…
';
try {
var res = await fetch(API + '/config');
var d = await res.json();
renderConfig(d.config || []);
} catch(e) {
console.error('Config load error:', e);
el.innerHTML = '
Error: ' + esc(e.message) + '
';
}
}
function renderConfig(rows) {
var el = $('config-list');
if (!el) return;
if (!rows.length) {
el.innerHTML = '
No config rows found.
';
return;
}
// Group by category
var cats = {};
for (var i = 0; i < rows.length; i++) {
var c = rows[i].category || 'other';
if (!cats[c]) cats[c] = [];
cats[c].push(rows[i]);
}
var catOrder = ['clustering', 'enrichment', 'retention'];
var catLabels = { clustering: '🔗 Clustering', enrichment: '🧠 Enrichment / LLM', retention: '🗄️ Retention / Polling' };
var html = '';
for (var ci = 0; ci < catOrder.length; ci++) {
var cat = catOrder[ci];
if (!cats[cat]) continue;
html += '
';
html += '
' + (catLabels[cat] || esc(cat)) + '
';
html += '
';
for (var j = 0; j < cats[cat].length; j++) {
var r = cats[cat][j];
var srcBadge = r.source === 'env'
? 'env'
: (r.source === 'api'
? 'api'
: 'default');
var id = 'cfg-' + esc(r.key);
var desc = esc(r.description || '');
html += '
';
html += '
' + esc(r.key) + srcBadge + '
';
html += '
' + desc + '
';
if (r.type === 'bool') {
html += '';
} else {
html += '';
}
html += '
';
}
html += '
';
}
el.innerHTML = html;
// Attach event listeners
el.querySelectorAll('[data-key]').forEach(function(el) {
el.addEventListener('change', function() {
updateConfig(this.dataset.key, this.value);
});
});
}
async function updateConfig(key, value) {
try {
var form = new FormData();
form.append('key', key);
form.append('value', value);
var res = await fetch(API + '/config/update', { method: 'POST', body: form });
var d = await res.json();
if (d.ok) {
showToast('Updated ' + key);
} else {
showToast(d.error || 'Error updating ' + key, true);
}
} catch(e) {
showToast('Error: ' + e.message, true);
}
}
async function resetConfig() {
if (!confirm('Reset all config values to .env/defaults?')) return;
try {
var res = await fetch(API + '/config/reset', { method: 'POST' });
var d = await res.json();
if (d.ok) {
showToast('Config reset to defaults');
loadConfig();
} else {
showToast(d.error || 'Reset failed', true);
}
} catch(e) {
showToast('Error: ' + e.message, true);
}
}
// ── 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);