|
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
|
+}
|
|
|
+
|
|
|
+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||''))+' · '+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') + ' · 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">• ' + 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||'') + ' · ' + (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);
|