dashboard.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. // News MCP Dashboard — pure JS + Chart.js
  2. // Clusters are ALWAYS ordered by date descending (freshest first)
  3. var API = '/api/v1';
  4. var _charts = {};
  5. var _clustersData = [];
  6. var _entitiesData = [];
  7. var _feedsData = [];
  8. var _healthLoaded = false;
  9. function switchView(name) {
  10. var views = ['health','feeds','clusters','sentiment','entities','keywords','config'];
  11. if (views.indexOf(name) === -1) return;
  12. document.querySelectorAll('.view').forEach(function(v) { v.classList.toggle('active', v.id === 'view-' + name); });
  13. document.querySelectorAll('.nav-links a').forEach(function(a) { a.classList.toggle('active', a.dataset.view === name); });
  14. if (name === 'health') loadHealth();
  15. if (name === 'feeds') loadFeeds();
  16. if (name === 'clusters') reloadClusters();
  17. if (name === 'sentiment') reloadSentiment();
  18. if (name === 'entities') loadEntities();
  19. if (name === 'keywords') loadKeywords();
  20. if (name === 'config') loadConfig();
  21. }
  22. function $(id) {
  23. if (!id) return null;
  24. return document.getElementById(id) || document.querySelector('#' + id);
  25. }
  26. function esc(s) {
  27. if (s == null || s === undefined) return '';
  28. return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  29. }
  30. function topicChip(topic) {
  31. var t = (topic || 'other').toString().replace(/[^a-z0-9]/g,'');
  32. return '<span class="chip topic-' + t + '">' + esc(topic) + '</span>';
  33. }
  34. function sentimentClass(s) {
  35. if (s === 'positive') return 'sentiment-pos';
  36. if (s === 'negative') return 'sentiment-neg';
  37. return 'sentiment-neu';
  38. }
  39. // ── Health ──────────────────────────────────────────────
  40. async function loadHealth() {
  41. try {
  42. var res = await fetch(API + '/health');
  43. var d = await res.json();
  44. var sc = $('stat-clusters'); if (sc) sc.textContent = d.total_clusters;
  45. var se = $('stat-entities'); if (se) se.textContent = d.total_entities;
  46. var sce = $('stat-cluster-entities'); if (sce) sce.textContent = d.cluster_entities;
  47. var sf = $('stat-fresh');
  48. if (sf) {
  49. if (d.data_fresh) { sf.textContent = 'Fresh'; sf.className = 'value green'; }
  50. else { sf.textContent = 'Stale'; sf.className = 'value red'; }
  51. }
  52. var sr = $('stat-refresh'); if (sr) sr.textContent = d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleString() : 'never';
  53. var nm = $('nav-meta'); if (nm) nm.textContent = 'Refresh: ' + (d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleTimeString() : 'never');
  54. renderTopicPie(d.clusters_by_topic || {});
  55. await renderSentimentOverview();
  56. renderFeedStatus(d.feeds || {});
  57. _healthLoaded = true;
  58. } catch(e) {
  59. console.error('Health load error:', e);
  60. var hs = $('health-stats');
  61. if (hs) hs.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
  62. }
  63. }
  64. function renderTopicPie(topics) {
  65. var el = $('chart-topic-dist'); if (!el) return;
  66. var ctx = el.getContext('2d');
  67. var colors = { crypto:'#f59e0b', macro:'#8b5cf6', regulation:'#3b82f6', ai:'#10b981', other:'#6b7280' };
  68. var labels = Object.keys(topics);
  69. if (_charts.topicDist) _charts.topicDist.destroy();
  70. _charts.topicDist = new Chart(ctx, {
  71. type: 'doughnut',
  72. data: { labels: labels, datasets: [{ data: labels.map(function(l){return topics[l]}), backgroundColor: labels.map(function(l){return colors[l]||'#555'}), borderWidth: 0 }] },
  73. options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom',labels:{color:'#8a8f9b',font:{size:11},padding:12}}} }
  74. });
  75. }
  76. async function renderSentimentOverview() {
  77. try {
  78. var res = await fetch(API + '/sentiment-series?hours=24&bucket_hours=4');
  79. var d = await res.json();
  80. renderSentimentChart(d.series || [], 'chart-sentiment-overview', false);
  81. } catch(e) { console.error('Sentiment overview error:', e); }
  82. }
  83. function renderFeedStatus(feeds) {
  84. var el = $('feed-status');
  85. if (!el) return;
  86. if (!feeds || !Object.keys(feeds).length) {
  87. el.innerHTML = '<p class="muted" style="font-size:.82rem;padding:.5rem 0">No feed state yet.</p>';
  88. return;
  89. }
  90. var html = '';
  91. for (var k in feeds) {
  92. var v = feeds[k];
  93. var label = k.replace(/^https?:\/\//, '').replace(/\/$/, '');
  94. var count = (v.last_item_count == null) ? 'n/a' : String(v.last_item_count);
  95. var isEnabled = v.enabled !== false;
  96. var statusClass = isEnabled ? 'enabled' : 'disabled';
  97. var statusLabel = isEnabled ? 'ACTIVE' : 'OFF';
  98. var favicon = isEnabled ? '🟢' : '⚫';
  99. html += '<div class="feed-item">' +
  100. '<div class="feed-label">' +
  101. '<span style="font-size:.75rem;margin-right:.3rem">'+favicon+'</span>' +
  102. '<span class="feed-domain">' + esc(label) + '</span>' +
  103. '<span class="feed-status ' + statusClass + '">' + statusLabel + '</span>' +
  104. '</div>' +
  105. '<span class="badge">' + esc(count) + ' items</span>' +
  106. '</div>';
  107. }
  108. el.innerHTML = html;
  109. }
  110. // ── Feeds Management ──────────────────────────────────
  111. async function loadFeeds() {
  112. try {
  113. var res = await fetch(API + '/feeds');
  114. var d = await res.json();
  115. _feedsData = d.feeds || [];
  116. renderFeedsList();
  117. } catch(e) {
  118. console.error('Feeds load error:', e);
  119. var el = $('feeds-list');
  120. if (el) el.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
  121. }
  122. }
  123. function renderFeedsList() {
  124. var el = $('feeds-list');
  125. if (!el) return;
  126. if (!_feedsData.length) {
  127. el.innerHTML = '<p class="muted" style="padding:2rem;text-align:center">No feeds registered yet. Add URLs to .env and trigger a refresh.</p>';
  128. return;
  129. }
  130. var enabledCount = 0;
  131. for (var i = 0; i < _feedsData.length; i++) {
  132. if (_feedsData[i].enabled !== false) enabledCount++;
  133. }
  134. var html = '<div style="margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center">' +
  135. '<span style="font-size:.82rem;color:var(--text-dim)">' + enabledCount + ' / ' + _feedsData.length + ' feeds enabled</span>' +
  136. '<button onclick="loadFeeds()" style="font-size:.75rem;padding:.25rem .6rem;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer">↻ Refresh</button>' +
  137. '</div>';
  138. html += '<div class="feed-toggle-list">';
  139. // Table header
  140. html += '<div class="feed-toggle-header">' +
  141. '<div class="feed-col-check">Enable</div>' +
  142. '<div class="feed-col-check">Re-enrich</div>' +
  143. '<div class="feed-col-url">Feed URL</div>' +
  144. '<div class="feed-col-hint">Last seen</div>' +
  145. '</div>';
  146. for (var i = 0; i < _feedsData.length; i++) {
  147. var f = _feedsData[i];
  148. var domain = f.feed_key.replace(/^https?:\/\//, '').replace(/\/$/, '');
  149. var lastItems = f.last_item_count != null ? f.last_item_count + ' items' : '—';
  150. var lastSeen = f.updated_at ? ' · ' + new Date(f.updated_at).toLocaleString() : '';
  151. var isEnabled = f.enabled !== false;
  152. var isReEnrich = f.re_enrich === true;
  153. html += '<div class="feed-toggle-row">' +
  154. '<div class="feed-col-check">' +
  155. '<input type="checkbox" id="feed-' + esc(String(i)) + '"' + (isEnabled ? ' checked' : '') +
  156. ' onchange="toggleFeed(\'' + esc(f.feed_key) + '\', this.checked)" />' +
  157. '</div>' +
  158. '<div class="feed-col-check">' +
  159. '<input type="checkbox" id="re-enrich-' + esc(String(i)) + '"' + (isReEnrich ? ' checked' : '') +
  160. ' onchange="toggleReEnrich(\'' + esc(f.feed_key) + '\', this.checked)" title="Re-enrich on content change" />' +
  161. '</div>' +
  162. '<div class="feed-col-url">' + esc(domain) + '</div>' +
  163. '<div class="feed-col-hint">' + lastItems + lastSeen + '</div>' +
  164. '</div>';
  165. }
  166. html += '</div>';
  167. el.innerHTML = html;
  168. }
  169. async function toggleFeed(feedUrl, enabled) {
  170. try {
  171. var res = await fetch(API + '/feeds/toggle', {
  172. method: 'POST',
  173. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  174. body: 'feed_url=' + encodeURIComponent(feedUrl) + '&enabled=' + (enabled ? 'true' : 'false')
  175. });
  176. var d = await res.json();
  177. if (!d.ok) throw new Error(d.error || 'Toggle failed');
  178. // Update local state
  179. for (var i = 0; i < _feedsData.length; i++) {
  180. if (_feedsData[i].feed_key === feedUrl) { _feedsData[i].enabled = enabled; break; }
  181. }
  182. // Re-render
  183. renderFeedsList();
  184. // Refresh health feed status if loaded
  185. if (_healthLoaded) loadHealth();
  186. showToast('Feed ' + (enabled ? 'enabled' : 'disabled') + ': ' + feedUrl.replace(/^https?:\/\//, ''));
  187. } catch(e) {
  188. console.error('Toggle error:', e);
  189. showToast('Error toggling feed: ' + e.message, true);
  190. // Revert checkbox
  191. var cb = document.getElementById('feed-' + _feedsData.findIndex(function(f){return f.feed_key === feedUrl}));
  192. if (cb) cb.checked = !enabled;
  193. }
  194. }
  195. async function toggleReEnrich(feedUrl, reEnrich) {
  196. try {
  197. var res = await fetch(API + '/feeds/set-re-enrich', {
  198. method: 'POST',
  199. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  200. body: 'feed_url=' + encodeURIComponent(feedUrl) + '&re_enrich=' + (reEnrich ? 'true' : 'false')
  201. });
  202. var d = await res.json();
  203. if (!d.ok) throw new Error(d.error || 'Toggle failed');
  204. // Update local state
  205. for (var i = 0; i < _feedsData.length; i++) {
  206. if (_feedsData[i].feed_key === feedUrl) { _feedsData[i].re_enrich = reEnrich; break; }
  207. }
  208. renderFeedsList();
  209. showToast('Re-enrich ' + (reEnrich ? 'enabled' : 'disabled') + ': ' + feedUrl.replace(/^https?:\/\//, ''));
  210. } catch(e) {
  211. console.error('Re-enrich toggle error:', e);
  212. showToast('Error toggling re-enrich: ' + e.message, true);
  213. var cb = document.getElementById('re-enrich-' + _feedsData.findIndex(function(f){return f.feed_key === feedUrl}));
  214. if (cb) cb.checked = !reEnrich;
  215. }
  216. }
  217. // ── Clusters (ALWAYS date descending) ──────────────────────
  218. async function reloadClusters() {
  219. var topic = $('cluster-topic').value;
  220. var hours = $('cluster-hours').value;
  221. try {
  222. var res = await fetch(API + '/clusters?topic=' + encodeURIComponent(topic) + '&hours=' + hours + '&limit=50');
  223. var d = await res.json();
  224. if (d.error) throw new Error(d.error);
  225. _clustersData = d.clusters || [];
  226. // Data is already sorted by server (date DESC). Force-sort client-side as safety net.
  227. _clustersData.sort(function(a,b) {
  228. var ta = new Date(a.timestamp || 0).getTime();
  229. var tb = new Date(b.timestamp || 0).getTime();
  230. return tb - ta; // newest first
  231. });
  232. var ct = $('cluster-total'); if (ct) ct.textContent = _clustersData.length + ' / ' + d.total;
  233. renderClusterTable(_clustersData);
  234. } catch(e) {
  235. console.error('Clusters error:', e);
  236. var ct2 = $('cluster-table');
  237. if (ct2) ct2.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
  238. }
  239. }
  240. function filterClusters() {
  241. var q = $('cluster-search').value.toLowerCase().trim();
  242. var data = _clustersData;
  243. if (q) {
  244. data = _clustersData.filter(function(c) {
  245. return (c.headline||'').toLowerCase().indexOf(q) !== -1 || (c.entities||[]).some(function(e) { return (e||'').toLowerCase().indexOf(q) !== -1; });
  246. });
  247. var ct = $('cluster-total'); if (ct) ct.textContent = data.length + ' / ' + _clustersData.length;
  248. }
  249. renderClusterTable(data);
  250. }
  251. function renderClusterTable(clusters) {
  252. var el = $('cluster-table');
  253. if (!el) return;
  254. if (!clusters || !clusters.length) {
  255. el.innerHTML = '<div class="loading" style="padding:2rem;text-align:center">No clusters in this window</div>';
  256. return;
  257. }
  258. // Sorted by date descending — newest first
  259. 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>' +
  260. '<tr class="spacer"><td colspan="6" style="height:.5rem;padding:0;border:none"></td></tr>';
  261. for (var i = 0; i < clusters.length; i++) {
  262. var c = clusters[i];
  263. var ts = c.timestamp ? new Date(c.timestamp).toLocaleString() : '';
  264. var sc = sentimentClass(c.sentiment || 'neutral');
  265. var imp = c.importance || 0;
  266. var chips = '';
  267. var ents = c.entities || [];
  268. for (var ei = 0; ei < Math.min(ents.length, 5); ei++) {
  269. chips += '<span class="chip" title="' + esc(ents[ei]) + '">' + esc(String(ents[ei]||'').substring(0,22)) + '</span>';
  270. }
  271. html += '<tr style="cursor:pointer" onclick="openClusterModal(\'' + esc(c.cluster_id) + '\')">' +
  272. '<td style="white-space:nowrap;font-size:.78rem">' + ts + '</td>' +
  273. '<td><a href="#" onclick="event.stopPropagation();openClusterModal(\'' + esc(c.cluster_id) + '\')">' + esc(c.headline) + '</a></td>' +
  274. '<td>' + topicChip(c.topic) + '</td>' +
  275. '<td class="' + sc + '" style="font-weight:600;font-size:.82rem">' + esc(c.sentiment) + ' <span style="opacity:.7">(' + esc(String(c.sentimentScore||'')) + ')</span></td>' +
  276. '<td style="text-align:center"><span style="font-size:.75rem">' + imp + '</span></td>' +
  277. '<td style="max-width:200px">' + (chips || '\u2014') + '</td></tr>';
  278. }
  279. html += '</tbody>';
  280. el.innerHTML = '<table>' + html + '</table>';
  281. }
  282. // ── Sentiment ─────────────────────────────────────────────
  283. async function reloadSentiment() {
  284. var topic = $('sentiment-topic').value;
  285. var hours = $('sentiment-hours').value;
  286. var bucket = $('sentiment-bucket').value;
  287. // Use a wrapper div so we don't depend on the canvas element surviving innerHTML replacement
  288. var wrap = $('chart-sentiment') ? $('chart-sentiment').parentElement : null;
  289. var statsEl = $('sentiment-stats');
  290. if (statsEl) statsEl.innerHTML = '';
  291. if (wrap) wrap.innerHTML = '<div class="loading">Loading sentiment data…</div>';
  292. try {
  293. var res = await fetch(API + '/sentiment-series?topic=' + encodeURIComponent(topic) + '&hours=' + hours + '&bucket_hours=' + bucket);
  294. var d = await res.json();
  295. if (wrap && !$('chart-sentiment')) {
  296. wrap.innerHTML = '<canvas id="chart-sentiment"></canvas>';
  297. }
  298. renderSentimentChart(d.series || [], 'chart-sentiment', true);
  299. renderSentimentStats(d.series || []);
  300. } catch(e) {
  301. console.error('Sentiment error:', e);
  302. if (wrap) wrap.innerHTML = '<div class="loading">Error: ' + esc(e.message) + '</div>';
  303. }
  304. }
  305. function renderSentimentChart(series, canvasId, showCount) {
  306. if (!series.length) {
  307. var el = $(canvasId);
  308. if (el) el.parentElement.innerHTML = '<div class="loading">No data</div>';
  309. return;
  310. }
  311. if (_charts.sentiment) { _charts.sentiment.destroy(); _charts.sentiment = null; }
  312. var ctx = $(canvasId).getContext('2d');
  313. var labels = series.map(function(s) { var d=new Date(s.time); return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); });
  314. 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 }];
  315. 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 });
  316. _charts.sentiment = new Chart(ctx, {
  317. type: 'line', data: { labels: labels, datasets: datasets },
  318. options: {
  319. responsive: true, maintainAspectRatio: false,
  320. interaction: { mode: 'index', intersect: false },
  321. scales: {
  322. y: { position:'left', title:{display:true,text:'Sentiment',color:'#8a8f9b'}, grid:{color:'rgba(42,46,58,.5)'}, ticks:{color:'#8a8f9b'} },
  323. y1: { position:'right', title:{display:true,text:'Count',color:'#8a8f9b'}, grid:{drawOnChartArea:false}, ticks:{color:'#8a8f9b'} },
  324. x: { ticks:{color:'#8a8f9b',maxRotation:45}, grid:{color:'rgba(42,46,58,.3)'} }
  325. },
  326. plugins: { legend: { labels: { color: '#8a8f9b' } } }
  327. }
  328. });
  329. }
  330. function renderSentimentStats(series) {
  331. var el = $('sentiment-stats'); if (!el) return;
  332. if (!series.length) { el.innerHTML = ''; return; }
  333. var avg = (series.reduce(function(s,v){return s+v.avg_sentiment},0)/series.length).toFixed(3);
  334. var max = Math.max.apply(null, series.map(function(s){return s.avg_sentiment})).toFixed(3);
  335. var min = Math.min.apply(null, series.map(function(s){return s.avg_sentiment})).toFixed(3);
  336. var total = series.reduce(function(s,v){return s+v.count},0);
  337. var pos = series.filter(function(s){return s.avg_sentiment>0.15}).length;
  338. var neg = series.filter(function(s){return s.avg_sentiment<-0.15}).length;
  339. el.innerHTML = '<span class="sentiment-stat">Avg: <b>'+avg+'</b></span>'
  340. +'<span class="sentiment-stat">Peak +: <b style="color:var(--green)">'+max+'</b></span>'
  341. +'<span class="sentiment-stat">Peak -: <b style="color:var(--red)">'+min+'</b></span>'
  342. +'<span class="sentiment-stat">Clusters: <b>'+total+'</b></span>'
  343. +'<span class="sentiment-stat">+ buckets: <b class="sentiment-pos">'+pos+'</b></span>'
  344. +'<span class="sentiment-stat">- buckets: <b class="sentiment-neg">'+neg+'</b></span>';
  345. }
  346. // ── Entities ─────────────────────────────────────────────
  347. async function loadEntities() {
  348. var hours = ($('entity-hours') || {}).value || 24;
  349. try {
  350. var res = await fetch(API + '/entities?hours=' + hours + '&limit=30');
  351. var d = await res.json();
  352. _entitiesData = d.entities || [];
  353. renderEntityList();
  354. renderEntityChart();
  355. } catch(e) {
  356. console.error('Entities error:', e);
  357. var el = $('entity-list'); if (el) el.innerHTML = '<div class="loading">Error</div>';
  358. }
  359. }
  360. function renderEntityList() {
  361. var el = $('entity-list'); if (!el) return;
  362. if (!_entitiesData.length) { el.innerHTML = '<div class="loading">No entities</div>'; return; }
  363. var html = '';
  364. for (var i = 0; i < _entitiesData.length; i++) {
  365. var e = _entitiesData[i];
  366. 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>';
  367. }
  368. el.innerHTML = html;
  369. }
  370. function renderEntityChart() {
  371. var top15 = _entitiesData.slice(0,15);
  372. if (!top15.length) return;
  373. if (_charts.entities) _charts.entities.destroy();
  374. _charts.entities = new Chart($('chart-entities').getContext('2d'), {
  375. type: 'bar',
  376. 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 }] },
  377. options: { indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{ticks:{color:'#8a8f9b'},grid:{color:'rgba(42,46,58,.5)'}}, y:{ticks:{color:'#8a8f9b'},grid:{display:false}} } }
  378. });
  379. }
  380. // Show clusters that mention this entity, sorted by date DESC
  381. async function showEntityDetail(label) {
  382. if (!label) return;
  383. var el = $('entity-detail'); if (!el) return;
  384. el.innerHTML = '<div class=\"loading\">Fetching clusters mentioning ' + esc(label) + '...</div>';
  385. var hours = ($('entity-hours') || {}).value || 24;
  386. try {
  387. var res = await fetch(API + '/clusters/by-entity?entity=' + encodeURIComponent(label) + '&hours=' + hours + '&limit=200');
  388. var d = await res.json();
  389. var matched = d.clusters || [];
  390. if (!matched.length) { el.innerHTML = '<p class=\"muted\">No clusters mention \"' + esc(label) + '\" in the current window.</p>'; return; }
  391. var html = '<h4 style=\"font-size:.85rem;margin-bottom:.5rem">Clusters mentioning ' + esc(label) + ' (' + (d.total || matched.length) + ')</h4>';
  392. for (var i = 0; i < matched.length; i++) {
  393. var c = matched[i];
  394. 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)+'\')">'+
  395. '<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>';
  396. }
  397. el.innerHTML = html;
  398. } catch(e) {
  399. el.innerHTML = '<p class="muted">Error: ' + esc(e.message) + '</p>';
  400. }
  401. }
  402. // ── Keywords ──────────────────────────────────────────────
  403. var _keywordsData = [];
  404. async function loadKeywords() {
  405. var hours = ($('keyword-hours') || {}).value || 24;
  406. try {
  407. var res = await fetch(API + '/keywords?hours=' + hours + '&limit=30');
  408. var d = await res.json();
  409. _keywordsData = d.keywords || [];
  410. renderKeywordList();
  411. renderKeywordChart();
  412. } catch(e) {
  413. console.error('Keywords error:', e);
  414. var el = $('keyword-list'); if (el) el.innerHTML = '<div class="loading">Error</div>';
  415. }
  416. }
  417. function renderKeywordList() {
  418. var el = $('keyword-list'); if (!el) return;
  419. if (!_keywordsData.length) { el.innerHTML = '<div class="loading">No keywords</div>'; return; }
  420. var html = '';
  421. for (var i = 0; i < _keywordsData.length; i++) {
  422. var k = _keywordsData[i];
  423. html += '<div class="entity-row" onclick="showKeywordDetail(\''+esc(k.label)+'\')" style="cursor:pointer"><span class="ent-label">' + esc(k.label) + '</span><span class="ent-count">' + k.count + 'x</span></div>';
  424. }
  425. el.innerHTML = html;
  426. }
  427. function renderKeywordChart() {
  428. var top15 = _keywordsData.slice(0,15);
  429. if (!top15.length) return;
  430. if (_charts.keywords) _charts.keywords.destroy();
  431. _charts.keywords = new Chart($('chart-keywords').getContext('2d'), {
  432. type: 'bar',
  433. 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 }] },
  434. options: { indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{ticks:{color:'#8a8f9b'},grid:{color:'rgba(42,46,58,.5)'}}, y:{ticks:{color:'#8a8f9b'},grid:{display:false}} } }
  435. });
  436. }
  437. // Show clusters containing this keyword, sorted by date DESC
  438. async function showKeywordDetail(label) {
  439. if (!label) return;
  440. var el = $('keyword-detail'); if (!el) return;
  441. el.innerHTML = '<div class="loading">Fetching clusters with keyword ' + esc(label) + '…</div>';
  442. var hours = ($('keyword-hours') || {}).value || 24;
  443. try {
  444. var res = await fetch(API + '/clusters/by-keyword?keyword=' + encodeURIComponent(label) + '&hours=' + hours + '&limit=200');
  445. var d = await res.json();
  446. var matched = d.clusters || [];
  447. if (!matched.length) { el.innerHTML = '<p class="muted">No clusters have keyword "' + esc(label) + '" in the current window.</p>'; return; }
  448. var html = '<h4 style="font-size:.85rem;margin-bottom:.5rem">Clusters with keyword ' + esc(label) + ' (' + (d.total || matched.length) + ')</h4>';
  449. for (var i = 0; i < matched.length; i++) {
  450. var c = matched[i];
  451. 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)+'\')">'+
  452. '<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>';
  453. }
  454. el.innerHTML = html;
  455. } catch(e) {
  456. el.innerHTML = '<p class="muted">Error: ' + esc(e.message) + '</p>';
  457. }
  458. }
  459. // ── Detail modal ─────────────────────────────────────────
  460. function openClusterModal(clusterId) {
  461. if (!clusterId) return;
  462. $('cluster-modal').classList.add('open');
  463. $('modal-content').innerHTML = '<div class="loading">Loading...</div>';
  464. fetch(API + '/cluster/' + encodeURIComponent(clusterId))
  465. .then(function(r){ return r.json(); })
  466. .then(function(d) {
  467. var mc = $('modal-content');
  468. if (!mc) return;
  469. mc.innerHTML = d.error ? '<p class="muted">' + esc(d.error) + '</p>' : buildDetailHTML(d);
  470. })
  471. .catch(function() { var mc = $('modal-content'); if (mc) mc.innerHTML = '<p class="muted">Error loading detail.</p>'; });
  472. }
  473. function closeModal() { var m = $('cluster-modal'); if (m) m.classList.remove('open'); }
  474. function buildDetailHTML(d) {
  475. var h = '<div class="detail-section"><h4>Headline</h4><div class="detail-content">' + esc(d.headline||'') + '</div></div>';
  476. h += '<div class="detail-section"><h4>Metadata</h4><div class="detail-content">';
  477. h += topicChip(d.topic) + ' <span class="' + sentimentClass(d.sentiment) + '" style="font-weight:600">' + esc(d.sentiment) + ' (' + esc(String(d.sentimentScore||'')) + ')</span>';
  478. h += ' <span style="color:var(--accent);margin-left:.5rem">Importance: ' + (d.importance||0) + '</span>';
  479. h += '<br><span class="muted">First: ' + (d.first_seen||'n/a') + ' &middot; Updated: ' + (d.last_updated||'n/a') + '</span></div></div>';
  480. if (d.summary_text) h += '<div class="detail-section"><h4>Summary</h4><div class="detail-content">' + esc(d.summary_text) + '</div></div>';
  481. if (d.summary) h += '<div class="detail-section"><h4>LLM Summary</h4><div class="detail-content">' + esc(d.summary) + '</div></div>';
  482. 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>';
  483. 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>';
  484. 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>';
  485. if (d.articles && d.articles.length) {
  486. h += '<div class="detail-section"><h4>Articles ('+d.articles.length+')</h4>';
  487. for (var ai = 0; ai < Math.min(d.articles.length, 8); ai++) {
  488. var a = d.articles[ai];
  489. 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>';
  490. }
  491. h += '</div>';
  492. }
  493. return h;
  494. }
  495. // ── Config ──────────────────────────────────────────────
  496. async function loadConfig() {
  497. var el = $('config-list');
  498. if (!el) return;
  499. el.innerHTML = '<div class="loading">Loading…</div>';
  500. try {
  501. var res = await fetch(API + '/config');
  502. var d = await res.json();
  503. renderConfig(d.config || []);
  504. } catch(e) {
  505. console.error('Config load error:', e);
  506. el.innerHTML = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
  507. }
  508. }
  509. function renderConfig(rows) {
  510. var el = $('config-list');
  511. if (!el) return;
  512. if (!rows.length) {
  513. el.innerHTML = '<p class="muted">No config rows found.</p>';
  514. return;
  515. }
  516. // Group by category
  517. var cats = {};
  518. for (var i = 0; i < rows.length; i++) {
  519. var c = rows[i].category || 'other';
  520. if (!cats[c]) cats[c] = [];
  521. cats[c].push(rows[i]);
  522. }
  523. var catOrder = ['clustering', 'enrichment', 'retention'];
  524. var catLabels = { clustering: '🔗 Clustering', enrichment: '🧠 Enrichment / LLM', retention: '🗄️ Retention / Polling' };
  525. var html = '';
  526. for (var ci = 0; ci < catOrder.length; ci++) {
  527. var cat = catOrder[ci];
  528. if (!cats[cat]) continue;
  529. html += '<div class="config-section">';
  530. html += '<h4 style="margin:.75rem .5rem .5rem;font-size:.85rem;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim)">' + (catLabels[cat] || esc(cat)) + '</h4>';
  531. html += '<div class="config-grid">';
  532. for (var j = 0; j < cats[cat].length; j++) {
  533. var r = cats[cat][j];
  534. var srcBadge = r.source === 'env'
  535. ? '<span class="badge" style="background:#1e3a5f;color:#93c5fd;font-size:.65rem;margin-left:.3rem">env</span>'
  536. : (r.source === 'api'
  537. ? '<span class="badge" style="background:#3a1e5f;color:#c4b5fd;font-size:.65rem;margin-left:.3rem">api</span>'
  538. : '<span class="badge" style="background:#1a2e1a;color:#86efac;font-size:.65rem;margin-left:.3rem">default</span>');
  539. var id = 'cfg-' + esc(r.key);
  540. var desc = esc(r.description || '');
  541. html += '<div class="config-row" title="' + desc + '">';
  542. html += '<div class="config-key">' + esc(r.key) + srcBadge + '</div>';
  543. html += '<div class="config-desc">' + desc + '</div>';
  544. if (r.type === 'bool') {
  545. html += '<select id="' + id + '" data-key="' + esc(r.key) + '">';
  546. html += '<option value="true"' + (r.value === 'true' ? ' selected' : '') + '>true</option>';
  547. html += '<option value="false"' + (r.value === 'false' ? ' selected' : '') + '>false</option>';
  548. html += '</select>';
  549. } else {
  550. html += '<input type="' + (r.type === 'int' || r.type === 'float' ? 'number' : 'text') + '" id="' + id + '" value="' + esc(r.value) + '" data-key="' + esc(r.key) + '"' + (r.type === 'float' ? ' step="0.001"' : '') + ' />';
  551. }
  552. html += '</div>';
  553. }
  554. html += '</div></div>';
  555. }
  556. el.innerHTML = html;
  557. // Attach event listeners
  558. el.querySelectorAll('[data-key]').forEach(function(el) {
  559. el.addEventListener('change', function() {
  560. updateConfig(this.dataset.key, this.value);
  561. });
  562. });
  563. }
  564. async function updateConfig(key, value) {
  565. try {
  566. var form = new FormData();
  567. form.append('key', key);
  568. form.append('value', value);
  569. var res = await fetch(API + '/config/update', { method: 'POST', body: form });
  570. var d = await res.json();
  571. if (d.ok) {
  572. showToast('Updated ' + key);
  573. } else {
  574. showToast(d.error || 'Error updating ' + key, true);
  575. }
  576. } catch(e) {
  577. showToast('Error: ' + e.message, true);
  578. }
  579. }
  580. async function resetConfig() {
  581. if (!confirm('Reset all config values to .env/defaults?')) return;
  582. try {
  583. var res = await fetch(API + '/config/reset', { method: 'POST' });
  584. var d = await res.json();
  585. if (d.ok) {
  586. showToast('Config reset to defaults');
  587. loadConfig();
  588. } else {
  589. showToast(d.error || 'Reset failed', true);
  590. }
  591. } catch(e) {
  592. showToast('Error: ' + e.message, true);
  593. }
  594. }
  595. // ── Toast ──────────────────────────────────────────────────
  596. function showToast(msg, isError) {
  597. var t = $('toast');
  598. if (!t) return;
  599. t.textContent = msg;
  600. t.className = 'toast' + (isError ? ' toast-error' : '');
  601. t.style.opacity = '1';
  602. clearTimeout(t._timer);
  603. t._timer = setTimeout(function() { t.style.opacity = '0'; }, 2500);
  604. }
  605. // ── Periodic refresh ──────────────────────────────────────
  606. setInterval(function() {
  607. fetch(API + '/health').then(function(r){return r.json()}).then(function(d) {
  608. var nm = $('nav-meta'); if (nm) nm.textContent = 'Last refresh: ' + (d.last_refresh_at ? new Date(d.last_refresh_at).toLocaleTimeString() : 'never');
  609. // If the feeds tab is visible, refresh feed data too
  610. if ($('view-feeds').classList.contains('active')) {
  611. loadFeeds();
  612. }
  613. }).catch(function(){});
  614. }, 30000);
  615. document.addEventListener('DOMContentLoaded', loadHealth);