Bladeren bron

feat: dashboard config page, dedup article identity module

Dashboard:
- Replace obsolete Detail view with Config page
- Config page: grouped by category (Clustering, Enrichment, Retention)
- Inline editing with change detection, source badges (env/api/default)
- Reset to defaults button
- Modal preserved for cluster drill-down from keyword/entity panels

Code quality:
- news_mcp/article_identity.py: single source of truth for article_key()
  and article_content_hash(). Eliminates 3 duplicate definitions across
  cluster.py, sqlite_store.py, and backfill script.
Lukas Goldschmidt 6 dagen geleden
bovenliggende
commit
5339e4c514
3 gewijzigde bestanden met toevoegingen van 149 en 35 verwijderingen
  1. 103 28
      dashboard/dashboard.js
  2. 11 7
      dashboard/index.html
  3. 35 0
      dashboard/style.css

+ 103 - 28
dashboard/dashboard.js

@@ -8,7 +8,7 @@ var _feedsData = [];
 var _healthLoaded = false;
 
 function switchView(name) {
-  var views = ['health','feeds','clusters','sentiment','entities','keywords','detail'];
+  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); });
@@ -18,6 +18,7 @@ function switchView(name) {
   if (name === 'sentiment') reloadSentiment();
   if (name === 'entities') loadEntities();
   if (name === 'keywords') loadKeywords();
+  if (name === 'config') loadConfig();
 }
 
 function $(id) {
@@ -466,35 +467,109 @@ function openClusterModal(clusterId) {
 
 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') + ' &middot; 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">&bull; ' + 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||'') + ' &middot; ' + (a.timestamp||'') + '</span></div>';
+// ── Config ──────────────────────────────────────────────
+
+async function loadConfig() {
+  var el = $('config-list');
+  if (!el) return;
+  el.innerHTML = '<div class="loading">Loading…</div>';
+  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 = '<div class="loading" style="color:var(--red)">Error: ' + esc(e.message) + '</div>';
+  }
+}
+
+function renderConfig(rows) {
+  var el = $('config-list');
+  if (!el) return;
+  if (!rows.length) {
+    el.innerHTML = '<p class="muted">No config rows found.</p>';
+    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 += '<div class="config-section">';
+    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>';
+    html += '<div class="config-grid">';
+    for (var j = 0; j < cats[cat].length; j++) {
+      var r = cats[cat][j];
+      var srcBadge = r.source === 'env'
+        ? '<span class="badge" style="background:#1e3a5f;color:#93c5fd;font-size:.65rem;margin-left:.3rem">env</span>'
+        : (r.source === 'api'
+          ? '<span class="badge" style="background:#3a1e5f;color:#c4b5fd;font-size:.65rem;margin-left:.3rem">api</span>'
+          : '<span class="badge" style="background:#1a2e1a;color:#86efac;font-size:.65rem;margin-left:.3rem">default</span>');
+      var id = 'cfg-' + esc(r.key);
+      var desc = esc(r.description || '');
+      html += '<div class="config-row" title="' + desc + '">';
+      html += '<div class="config-key">' + esc(r.key) + srcBadge + '</div>';
+      html += '<div class="config-desc">' + desc + '</div>';
+      if (r.type === 'bool') {
+        html += '<select id="' + id + '" data-key="' + esc(r.key) + '">';
+        html += '<option value="true"' + (r.value === 'true' ? ' selected' : '') + '>true</option>';
+        html += '<option value="false"' + (r.value === 'false' ? ' selected' : '') + '>false</option>';
+        html += '</select>';
+      } else {
+        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"' : '') + ' />';
+      }
+      html += '</div>';
+    }
+    html += '</div></div>';
+  }
+  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);
     }
-    h += '</div>';
+  } 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);
   }
-  return h;
 }
 
 // ── Toast ──────────────────────────────────────────────────

+ 11 - 7
dashboard/index.html

@@ -20,7 +20,7 @@
     <a href="#" onclick="switchView('sentiment'); return false;" data-view="sentiment">Sentiment</a>
     <a href="#" onclick="switchView('entities'); return false;" data-view="entities">Entities</a>
     <a href="#" onclick="switchView('keywords'); return false;" data-view="keywords">Keywords</a>
-    <a href="#" onclick="switchView('detail'); return false;" data-view="detail">Detail</a>
+    <a href="#" onclick="switchView('config'); return false;" data-view="config">Config</a>
   </div>
   <div class="nav-meta" id="nav-meta"></div>
 </nav>
@@ -183,14 +183,18 @@
   </div>
 </div>
 
-<!-- DETAIL VIEW -->
-<div id="view-detail" class="view">
+<!-- CONFIG VIEW -->
+<div id="view-config" class="view">
   <div class="card">
-    <div class="toolbar">
-      <input type="text" id="detail-search" placeholder="Paste cluster_id or search…" />
-      <button onclick="searchDetail()">🔍 Search</button>
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
+      <h3>⚙️ Site Configuration</h3>
+      <div>
+        <button onclick="loadConfig()" style="font-size:.75rem;padding:.25rem .6rem;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer;margin-right:.5rem">↻ Reload</button>
+        <button onclick="resetConfig()" style="font-size:.75rem;padding:.25rem .6rem;background:var(--red);border:1px solid var(--red);border-radius:6px;color:#fff;cursor:pointer">Reset to Defaults</button>
+      </div>
     </div>
-    <div id="detail-content"><p class="muted">Select a cluster from <a href="#" onclick="switchView('clusters'); return false;">Clusters</a> or search by cluster ID.</p></div>
+    <p class="muted" style="margin-bottom:.75rem">All parameters are stored in the database. Changes take effect on the next poll cycle. Source shows whether the value came from <code>.env</code> or the built-in default.</p>
+    <div id="config-list"><div class="loading">Loading…</div></div>
   </div>
 </div>
 

+ 35 - 0
dashboard/style.css

@@ -223,3 +223,38 @@ tr:hover td { background: rgba(91,138,245,.05); }
 
 /* ── Feed toggle list (feeds view) ────────────────── */
 .feed-toggle-list { display: flex; flex-direction: column; gap: .15rem; }
+
+/* ── Config view ──────────────────────────────────── */
+.config-section { margin-bottom: 1.25rem; }
+.config-section:last-child { margin-bottom: 0; }
+.config-grid { display: flex; flex-direction: column; gap: .35rem; }
+.config-row {
+  display: grid;
+  grid-template-columns: 220px 1fr 160px;
+  gap: .75rem;
+  align-items: center;
+  padding: .5rem .6rem;
+  background: var(--surface2);
+  border-radius: 6px;
+  border: 1px solid rgba(42,46,58,.4);
+}
+.config-row:hover { border-color: var(--border); }
+.config-key { font-size: .8rem; font-weight: 600; color: var(--text); word-break: break-all; }
+.config-desc { font-size: .72rem; color: var(--text-dim); line-height: 1.4; }
+.config-row input[type=text],
+.config-row input[type=number],
+.config-row select {
+  background: var(--bg);
+  border: 1px solid var(--border);
+  color: var(--text);
+  padding: .35rem .5rem;
+  border-radius: 4px;
+  font-size: .8rem;
+  width: 100%;
+}
+.config-row input[type=text]:focus,
+.config-row input[type=number]:focus,
+.config-row select:focus { border-color: var(--accent); outline: none; }
+@media (max-width: 700px) {
+  .config-row { grid-template-columns: 1fr; gap: .35rem; }
+}