|
@@ -4,14 +4,16 @@ var API = '/api/v1';
|
|
|
var _charts = {};
|
|
var _charts = {};
|
|
|
var _clustersData = [];
|
|
var _clustersData = [];
|
|
|
var _entitiesData = [];
|
|
var _entitiesData = [];
|
|
|
|
|
+var _feedsData = [];
|
|
|
var _healthLoaded = false;
|
|
var _healthLoaded = false;
|
|
|
|
|
|
|
|
function switchView(name) {
|
|
function switchView(name) {
|
|
|
- var views = ['health','clusters','sentiment','entities','detail'];
|
|
|
|
|
|
|
+ var views = ['health','feeds','clusters','sentiment','entities','detail'];
|
|
|
if (views.indexOf(name) === -1) return;
|
|
if (views.indexOf(name) === -1) return;
|
|
|
document.querySelectorAll('.view').forEach(function(v) { v.classList.toggle('active', v.id === 'view-' + name); });
|
|
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); });
|
|
document.querySelectorAll('.nav-links a').forEach(function(a) { a.classList.toggle('active', a.dataset.view === name); });
|
|
|
if (name === 'health') loadHealth();
|
|
if (name === 'health') loadHealth();
|
|
|
|
|
+ if (name === 'feeds') loadFeeds();
|
|
|
if (name === 'clusters') reloadClusters();
|
|
if (name === 'clusters') reloadClusters();
|
|
|
if (name === 'sentiment') reloadSentiment();
|
|
if (name === 'sentiment') reloadSentiment();
|
|
|
if (name === 'entities') loadEntities();
|
|
if (name === 'entities') loadEntities();
|
|
@@ -97,11 +99,97 @@ function renderFeedStatus(feeds) {
|
|
|
var v = feeds[k];
|
|
var v = feeds[k];
|
|
|
var label = k.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
var label = k.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
|
var count = (v.last_item_count == null) ? 'n/a' : String(v.last_item_count);
|
|
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;
|
|
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) โโโโโโโโโโโโโโโโโโโโโโ
|
|
// โโ Clusters (ALWAYS date descending) โโโโโโโโโโโโโโโโโโโโโโ
|
|
|
async function reloadClusters() {
|
|
async function reloadClusters() {
|
|
|
var topic = $('cluster-topic').value;
|
|
var topic = $('cluster-topic').value;
|
|
@@ -146,7 +234,8 @@ function renderClusterTable(clusters) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
// Sorted by date descending โ newest first
|
|
// 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++) {
|
|
for (var i = 0; i < clusters.length; i++) {
|
|
|
var c = clusters[i];
|
|
var c = clusters[i];
|
|
|
var ts = c.timestamp ? new Date(c.timestamp).toLocaleString() : '';
|
|
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++) {
|
|
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>';
|
|
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>';
|
|
html += '</tbody>';
|
|
|
el.innerHTML = '<table>' + html + '</table>';
|
|
el.innerHTML = '<table>' + html + '</table>';
|
|
@@ -343,10 +432,26 @@ function buildDetailHTML(d) {
|
|
|
return h;
|
|
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() {
|
|
setInterval(function() {
|
|
|
fetch(API + '/health').then(function(r){return r.json()}).then(function(d) {
|
|
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');
|
|
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(){});
|
|
}).catch(function(){});
|
|
|
}, 30000);
|
|
}, 30000);
|
|
|
|
|
|
|
|
-document.addEventListener('DOMContentLoaded', loadHealth);
|
|
|
|
|
|
|
+document.addEventListener('DOMContentLoaded', loadHealth);
|