Add news feed panel to dashboard: exec summary pinned + scrolling headlines + 15min auto-refresh
Some checks failed
build-and-deploy / build (push) Has been cancelled

- New News tab consuming GET /api/news (headlines: source domain badge,
  relative timestamp, link-out target=_blank) and GET /api/news/summaries
  (latest executive summary pinned in a glowing briefing card).
- Four states handled: loading, empty (summarizer idle w/o GEMINI key),
  error, success; HTML-escaped titles/domains (newsEsc).
- Lightweight markdown renderer for summary bodies (bold/headers/lists).
- Auto-refresh every 15min (NEWS_REFRESH_MS) + lazy load on tab open + manual
  refresh button.
- Stretch hook: if articles ever carry lat/lon, headline pins plot on the
  GIBS map (renderNewsPins) + geotag indicator in the feed.
- Dark neon styling matching existing dashboard theme.
This commit is contained in:
Sirius DevOps 2026-08-24 18:01:19 -04:00
parent aec3ecae9a
commit acbf5cc56d

View file

@ -138,6 +138,53 @@
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
.cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap; width: 34%; }
.cam-pop a { color: var(--accent); word-break: break-all; }
/* ── News panel (scraper feed + exec summaries) ─────────────── */
.news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
.news-refresh { display: flex; align-items: center; gap: 0.6rem; }
.news-updated { font-size: 0.72rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.news-summary {
position: relative; background: linear-gradient(160deg, rgba(56,189,248,0.10), rgba(30,41,59,0.6) 45%);
border: 1px solid rgba(56,189,248,0.35); border-radius: 12px; padding: 1rem 1.25rem 1.1rem;
box-shadow: 0 0 24px rgba(56,189,248,0.14), inset 0 0 32px rgba(56,189,248,0.05);
margin-bottom: 1.2rem; overflow: hidden;
}
.news-summary::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(120px 60px at 85% -10%, rgba(56,189,248,0.22), transparent 70%);
}
.ns-kicker { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 12px rgba(56,189,248,0.6); display: flex; align-items: center; gap: 0.5rem; }
.ns-badge { font-size: 0.6rem; font-weight: 700; background: rgba(56,189,248,0.16); border: 1px solid rgba(56,189,248,0.4); border-radius: 9999px; padding: 0.12rem 0.5rem; color: var(--accent); letter-spacing: 0.08em; }
.ns-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; margin: 0.35rem 0 0.6rem; }
.ns-body { font-size: 0.92rem; line-height: 1.62; color: var(--text); white-space: pre-wrap; position: relative; z-index: 1; }
.ns-body h1, .ns-body h2, .ns-body h3, .ns-body h4 { font-size: 1rem; margin: 0.5rem 0 0.25rem; color: var(--accent); }
.ns-body ul, .ns-body ol { margin: 0.35rem 0 0.35rem 1.2rem; }
.ns-body li { margin-bottom: 0.2rem; }
.ns-body code { background: rgba(15,23,42,0.6); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; }
.ns-empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
.ns-loading { color: var(--muted); font-size: 0.85rem; }
.ns-error { color: var(--red); font-size: 0.85rem; }
.news-list-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.4rem; }
.news-count { font-size: 0.7rem; color: var(--accent); font-family: ui-monospace, monospace; }
.news-list { max-height: 46vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(15,23,42,0.35); }
.news-list::-webkit-scrollbar { width: 8px; }
.news-list::-webkit-scrollbar-track { background: rgba(30,41,59,0.4); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb { background: rgba(56,189,248,0.35); border-radius: 8px; }
.news-list::-webkit-scrollbar-thumb:hover { background: rgba(56,189,248,0.55); }
.news-item {
display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem;
border-bottom: 1px solid rgba(51,65,85,0.5); text-decoration: none; color: var(--text); transition: background .15s, box-shadow .15s;
}
.news-item:last-child { border-bottom: none; }
.news-item:hover { background: rgba(56,189,248,0.07); box-shadow: inset 2px 0 0 var(--accent); }
.news-dom { font-size: 0.66rem; font-family: ui-monospace, monospace; color: var(--accent); background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.25); border-radius: 9999px; padding: 0.12rem 0.5rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
.news-title { flex: 1; font-size: 0.87rem; line-height: 1.35; color: var(--text); word-break: break-word; }
.news-item:hover .news-title { color: var(--accent); }
.news-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; flex-shrink: 0; }
.news-geo { color: var(--green); font-size: 0.8rem; flex-shrink: 0; text-shadow: 0 0 8px rgba(74,222,128,0.6); }
.news-loading, .news-error { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; }
.news-error { color: var(--red); }
.news-empty { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; font-style: italic; }
</style>
</head>
<body>
@ -185,6 +232,7 @@
<button class="btn" onclick="showTab('ingest')">Ingest</button>
<button class="btn" onclick="showTab('keys')">Keys</button>
<button class="btn" onclick="showTab('map')">Map</button>
<button class="btn" onclick="showTab('news')">News</button>
</div>
<!-- Recent Events -->
@ -323,6 +371,38 @@
</div>
</div>
<!-- News (scraper feed + executive summaries) -->
<div class="section" id="tab-news" style="display:none">
<div class="news-head">
<div>
<h2>Global News Feed</h2>
<p class="sub">Scraped hourly from ~257 RSS sources · latest executive summary pinned above</p>
</div>
<div class="news-refresh">
<span class="news-updated" id="news-updated"></span>
<button class="btn" id="news-refresh-btn" onclick="loadNews(true)">Refresh</button>
</div>
</div>
<!-- Executive summary (pinned) -->
<div class="news-summary" id="news-summary-wrap">
<div class="ns-kicker">Executive Summary <span class="ns-badge" id="ns-badge">LATEST</span></div>
<div class="ns-time" id="ns-time"></div>
<div class="ns-body" id="ns-body">
<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>
</div>
</div>
<!-- Scrolling headline list -->
<div class="news-list-head">
<span>Recent Headlines</span>
<span class="news-count" id="news-count">0 articles</span>
</div>
<div class="news-list" id="news-list">
<div class="news-loading">Loading feed…</div>
</div>
</div>
<!-- Search Results -->
<div class="section" id="search-results" style="display:none">
<h2>Search Results (<span id="search-total">0</span>)</h2>
@ -496,16 +576,17 @@ async function clearKey(name) {
}
function showTab(name) {
['recent','alerts','entities','ingest','keys','map'].forEach(t => {
['recent','alerts','entities','ingest','keys','map','news'].forEach(t => {
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
});
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map','news'][i]===name);
});
if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities();
if (name==='keys') loadKeys();
if (name==='map') initMap();
if (name==='news') loadNews();
}
async function ingestRSS() {
@ -549,6 +630,122 @@ async function checkHealth() {
}
}
// ── News feed (scraper pipe + exec summaries) ─────────────────────────────
let newsInterval = null;
const NEWS_REFRESH_MS = 900000; // 15 min
let newsGeo = [];
let newsPinsLayer = null;
function newsEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function newsTimeAgo(ts) {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
return `${Math.floor(diff/86400000)}d ago`;
}
function newsSimpleMD(text) {
// Lightweight markdown→HTML for summary bodies (bold, headers, lists, paragraphs)
if (!text) return '';
let h = newsEsc(String(text));
// code blocks (already escaped so match escaped backticks)
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
// bold
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// headers (start of line # through ##)
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>');
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>');
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// unordered lists
h = h.replace(/^[\*\-]\s(.+)$/gm, '<li>$1</li>');
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
// double-newline = paragraph
h = h.replace(/\n{2,}/g, '</p><p>');
// single newline = br
h = h.replace(/\n/g, '<br>');
return '<p>'+h+'</p>';
}
async function loadNews(force) {
document.getElementById('news-updated').textContent = 'updating…';
let articles = [];
let summaries = [];
try {
const [rArticles, rSumm] = await Promise.all([
fetch(`${API}/api/news?limit=100`),
fetch(`${API}/api/news/summaries?limit=1`)
]);
if (rArticles.ok) articles = await rArticles.json();
if (rSumm.ok) summaries = await rSumm.json();
} catch(e) {
document.getElementById('news-list').innerHTML = '<div class="news-error">Failed to load news feed: ' + newsEsc(e.message || e) + '</div>';
document.getElementById('ns-body').innerHTML = '<div class="ns-error">Failed to load summary: ' + newsEsc(e.message || e) + '</div>';
document.getElementById('news-updated').textContent = 'error';
console.error('News load failed', e);
return;
}
renderNewsSummary(summaries);
renderNewsList(articles);
// Geo-pin stretch: if articles carry lat/lon (future backend), collect + plot on GIBS map
newsGeo = articles.filter(a => typeof a.lat === 'number' && typeof a.lon === 'number');
if (newsGeo.length && map) {
renderNewsPins();
}
document.getElementById('news-updated').textContent = 'updated ' + new Date().toLocaleTimeString();
if (!newsInterval) {
newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
}
}
function renderNewsSummary(summaries) {
const bodyEl = document.getElementById('ns-body');
const timeEl = document.getElementById('ns-time');
const badgeEl = document.getElementById('ns-badge');
if (!summaries || !summaries.length) {
bodyEl.innerHTML = '<div class="ns-empty">No executive summary yet — the Gemini summarizer is idle until <code>GEMINI_API_KEY</code> is set on the Pi.</div>';
timeEl.textContent = '—';
badgeEl.textContent = 'WAITING';
return;
}
const s = summaries[0];
badgeEl.textContent = 'LATEST';
timeEl.textContent = s.batch_timestamp ? 'Batch: ' + new Date(s.batch_timestamp).toLocaleString() : '—';
bodyEl.innerHTML = newsSimpleMD(s.summary_text || '');
}
function renderNewsList(articles) {
const listEl = document.getElementById('news-list');
document.getElementById('news-count').textContent = articles.length + ' articles';
if (!articles || !articles.length) {
listEl.innerHTML = '<div class="news-empty">No articles scraped yet — the scraper runs hourly at :00. Check news-scraper logs.</div>';
return;
}
listEl.innerHTML = articles.map(a => {
const geoTag = a.lat != null && a.lon != null ? '<span class="news-geo" title="geotagged">📍</span>' : '';
return '<a class="news-item" href="' + newsEsc(a.url || '#') + '" target="_blank" rel="noopener">' +
'<span class="news-dom">' + newsEsc(a.domain || '?') + '</span>' +
'<span class="news-title">' + newsEsc((a.title||'').trim()) + '</span>' +
geoTag +
'<span class="news-time">' + newsTimeAgo(a.timestamp) + '</span>' +
'</a>';
}).join('');
}
function renderNewsPins() {
if (!map || !newsGeo.length) return;
if (newsPinsLayer) { map.removeLayer(newsPinsLayer); newsPinsLayer = null; }
newsPinsLayer = L.layerGroup(newsGeo.map(a => {
const icon = L.divIcon({
className: '',
html:'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f0abfc;box-shadow:0 0 10px #f0abfc,0 0 2px #0f172a;border:1px solid #0f172a;"></span>',
iconSize: [12, 12], iconAnchor: [6, 6],
});
return L.marker([a.lat, a.lon], { icon })
.bindPopup(`<div class="cam-pop"><b>${newsEsc(a.title||'Headline')}</b><br><a href="${newsEsc(a.url||'#')}" target="_blank" rel="noopener">read article →</a><br><span class="k">${newsEsc(a.domain||'')}</span></div>`);
}));
newsPinsLayer.addTo(map);
}
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
let map = null, mapLayer = null, mapInitStarted = false;
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;