Explorar o código

feat: add feed toggle checkboxes and API wiring in dashboard

- New 'Feeds' nav tab with checkbox toggle for each RSS feed
- Updated Health view feed status to show enabled/disabled badges
- Toggle calls POST /api/v1/feeds/toggle, refreshes on success
- Auto-refreshes feeds tab periodically (30s interval)
- Toast notifications on toggle success/failure
- All 32 tests pass
Lukas Goldschmidt hai 1 semana
pai
achega
b58fc19d18
Modificáronse 3 ficheiros con 154 adicións e 12 borrados
  1. 116 11
      dashboard/dashboard.js
  2. 10 0
      dashboard/index.html
  3. 28 1
      dashboard/style.css

+ 116 - 11
dashboard/dashboard.js

@@ -4,14 +4,16 @@ var API = '/api/v1';
 var _charts = {};
 var _clustersData = [];
 var _entitiesData = [];
+var _feedsData = [];
 var _healthLoaded = false;
 
 function switchView(name) {
-  var views = ['health','clusters','sentiment','entities','detail'];
+  var views = ['health','feeds','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 === 'feeds') loadFeeds();
   if (name === 'clusters') reloadClusters();
   if (name === 'sentiment') reloadSentiment();
   if (name === 'entities') loadEntities();
@@ -97,11 +99,97 @@ function renderFeedStatus(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);
-    html += '<div class="feed-item"><span>' + esc(label) + '</span><span class="badge">' + esc(count) + ' items</span></div>';
+    var isEnabled = v.enabled !== false;
+    var statusClass = isEnabled ? 'enabled' : 'disabled';
+    var statusLabel = isEnabled ? 'ACTIVE' : 'OFF';
+    var favicon = isEnabled ? '🟢' : '⚫';
+    html += '<div class="feed-item">' +
+      '<div class="feed-label">' +
+        '<span style="font-size:.75rem;margin-right:.3rem">'+favicon+'</span>' +
+        '<span class="feed-domain">' + esc(label) + '</span>' +
+        '<span class="feed-status ' + statusClass + '">' + statusLabel + '</span>' +
+      '</div>' +
+      '<span class="badge">' + esc(count) + ' items</span>' +
+    '</div>';
   }
   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 = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
+  }
+}
+
+function renderFeedsList() {
+  var el = $('feeds-list');
+  if (!el) return;
+  if (!_feedsData.length) {
+    el.innerHTML = '<p class="muted" style="padding:2rem;text-align:center">No feeds registered yet. Add URLs to .env and trigger a refresh.</p>';
+    return;
+  }
+  var enabledCount = 0;
+  for (var i = 0; i < _feedsData.length; i++) {
+    if (_feedsData[i].enabled !== false) enabledCount++;
+  }
+  var html = '<div style="margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center">' +
+    '<span style="font-size:.82rem;color:var(--text-dim)">' + enabledCount + ' / ' + _feedsData.length + ' feeds enabled</span>' +
+    '<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>' +
+  '</div>';
+  html += '<div class="feed-toggle-list">';
+  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 += '<div class="feed-toggle-row">' +
+      '<input type="checkbox" id="feed-' + esc(String(i)) + '"' + (isEnabled ? ' checked' : '') +
+      ' onchange="toggleFeed(\'' + esc(f.feed_key) + '\', this.checked)" />' +
+      '<div class="feed-url">' + esc(domain) + '</div>' +
+      '<span class="feed-toggle-hint">' + lastItems + lastSeen + '</span>' +
+      '</div>';
+  }
+  html += '</div>';
+  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;
@@ -146,7 +234,8 @@ function renderClusterTable(clusters) {
     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>';
+  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>' +
+  '<tr class="spacer"><td colspan="6" style="height:.5rem;padding:0;border:none"></td></tr>';
   for (var i = 0; i < clusters.length; i++) {
     var c = clusters[i];
     var ts = c.timestamp ? new Date(c.timestamp).toLocaleString() : '';
@@ -157,13 +246,13 @@ function renderClusterTable(clusters) {
     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 += '<tr style="cursor:pointer" onclick="openClusterModal(\'' + esc(c.cluster_id) + '\')">' +
+    '<td style="white-space:nowrap;font-size:.78rem">' + ts + '</td>' +
+    '<td><a href="#" onclick="event.stopPropagation();openClusterModal(\'' + esc(c.cluster_id) + '\')">' + esc(c.headline) + '</a></td>' +
+    '<td>' + topicChip(c.topic) + '</td>' +
+    '<td class="' + sc + '" style="font-weight:600;font-size:.82rem">' + esc(c.sentiment) + ' <span style="opacity:.7">(' + esc(String(c.sentimentScore||'')) + ')</span></td>' +
+    '<td style="text-align:center"><span style="font-size:.75rem">' + imp + '</span></td>' +
+    '<td style="max-width:200px">' + (chips || '\u2014') + '</td></tr>';
   }
   html += '</tbody>';
   el.innerHTML = '<table>' + html + '</table>';
@@ -343,10 +432,26 @@ function buildDetailHTML(d) {
   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);
+document.addEventListener('DOMContentLoaded', loadHealth);

+ 10 - 0
dashboard/index.html

@@ -15,6 +15,7 @@
   <div class="nav-brand">📡 news-mcp dashboard</div>
   <div class="nav-links">
     <a href="#" onclick="switchView('health'); return false;" class="active" data-view="health">Health</a>
+    <a href="#" onclick="switchView('feeds'); return false;" data-view="feeds">Feeds</a>
     <a href="#" onclick="switchView('clusters'); return false;" data-view="clusters">Clusters</a>
     <a href="#" onclick="switchView('sentiment'); return false;" data-view="sentiment">Sentiment</a>
     <a href="#" onclick="switchView('entities'); return false;" data-view="entities">Entities</a>
@@ -52,6 +53,15 @@
   </div>
 </div>
 
+<!-- FEEDS VIEW -->
+<div id="view-feeds" class="view">
+  <div class="card">
+    <h3>📡 RSS Feed Management</h3>
+    <p class="muted" style="margin-bottom:.75rem">Toggle feeds on/off. Changes take effect on the next refresh cycle.</p>
+    <div id="feeds-list"><div class="loading">Loading…</div></div>
+  </div>
+</div>
+
 <!-- CLUSTERS VIEW -->
 <div id="view-clusters" class="view">
   <div class="card">

+ 28 - 1
dashboard/style.css

@@ -193,5 +193,32 @@ tr:hover td { background: rgba(91,138,245,.05); }
 .chart-wrap { position: relative; height: 280px; }
 
 /* ── Feed status ──────────────────────────────────── */
-.feed-item { display: flex; justify-content: space-between; padding: .4rem 0; font-size: .82rem; border-bottom: 1px solid rgba(42,46,58,.4); }
+.feed-item { display: flex; justify-content: space-between; align-items: center; padding: .4rem 0; font-size: .82rem; border-bottom: 1px solid rgba(42,46,58,.4); }
 .feed-item:last-child { border: none; }
+.feed-item .feed-label { flex: 1; display: flex; align-items: center; gap: .5rem; }
+.feed-item .feed-label input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
+.feed-item .feed-label .feed-domain { font-weight: 500; }
+.feed-item .feed-label .feed-status { font-size: .72rem; padding: .1rem .4rem; border-radius: 99px; font-weight: 600; }
+.feed-item .feed-label .feed-status.enabled { background: var(--green-bg); color: var(--green); }
+.feed-item .feed-label .feed-status.disabled { background: var(--red-bg); color: var(--red); }
+.feed-item .feed-count { color: var(--text-dim); font-size: .78rem; min-width: 50px; text-align: right; }
+
+/* ── Feeds management view ───────────────────────── */
+.feed-toggle-row { display: flex; align-items: center; padding: .55rem .6rem; border-bottom: 1px solid rgba(42,46,58,.4); }
+.feed-toggle-row:last-child { border: none; }
+.feed-toggle-row input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; margin-right: .6rem; }
+.feed-toggle-row .feed-url { flex: 1; font-size: .82rem; font-weight: 500; word-break: break-all; }
+.feed-toggle-row .feed-toggle-hint { font-size: .72rem; color: var(--text-dim); margin-left: .5rem; white-space: nowrap; }
+
+/* ── Toast ────────────────────────────────────────── */
+.toast {
+  position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
+  background: var(--surface); border: 1px solid var(--border); color: var(--text);
+  padding: .6rem 1.2rem; border-radius: 8px; font-size: .82rem;
+  box-shadow: var(--shadow); z-index: 300; transition: opacity .3s;
+  pointer-events: none;
+}
+.toast-error { border-color: var(--red); color: var(--red); }
+
+/* ── Feed toggle list (feeds view) ────────────────── */
+.feed-toggle-list { display: flex; flex-direction: column; gap: .15rem; }