diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html
index 78f26fd..c9c5f45 100644
--- a/packages/dashboard/index.html
+++ b/packages/dashboard/index.html
@@ -378,25 +378,27 @@
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
/* === TOOLTIPS === */
- .tip { position: relative; cursor: help; }
- .tip::after {
- content: attr(data-tip);
- position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
+ /* Smart tooltip β positioned by JS, see initSmartTooltips() */
+ .tip { cursor: help; }
+ #smart-tip {
+ position: fixed; z-index: 9999; pointer-events: none;
background: var(--surface-dark); color: #e0e0e0;
- border: 1px solid rgba(255,255,255,0.1);
- padding: 0.6rem 0.85rem; border-radius: var(--radius-md);
+ border: 1px solid rgba(255,255,255,0.12);
+ padding: 0.55rem 0.85rem; border-radius: var(--radius-md);
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
white-space: normal; width: max-content; max-width: 300px;
- opacity: 0; pointer-events: none; transition: opacity 0.2s;
- z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35);
+ opacity: 0; transition: opacity 0.15s;
}
- .tip::before {
- content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%);
- border: 6px solid transparent; border-top-color: var(--surface-dark);
- opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501;
+ #smart-tip.visible { opacity: 1; }
+ #smart-tip-arrow {
+ position: fixed; z-index: 9998; pointer-events: none;
+ width: 0; height: 0;
+ opacity: 0; transition: opacity 0.15s;
}
- .tip:hover::after, .tip:hover::before { opacity: 1; }
- th.tip::after { left: 0; transform: none; }
+ #smart-tip-arrow.visible { opacity: 1; }
+ #smart-tip-arrow.up { border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--surface-dark); }
+ #smart-tip-arrow.down { border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--surface-dark); }
/* === DETAIL PANEL === */
.panel {
@@ -766,6 +768,7 @@
Finder
Blog Engine
Procurement Intelligence
+ π· Crawler Intelligence
@@ -1071,6 +1074,75 @@
+
+
+
π· Crawler Intelligence
+
+
+
+
+
π¦
+
Transceivers in DB
+
β
+
+
+
πΆ
+
Price Records
+
β
+
+
+
πͺ
+
Vendors Tracked
+
β
+
+
+
π°
+
News Articles
+
β
+
+
+
+
+
π§
+
KB Entries
+
β
+
+
+
+
β
+
Active Scrapers
+
β
+
+
+
π
+
Last Price Update
+
β
+
+
+
+
+
+
+
+
+
+
π₯ LLM Hot Topics
+
+
+
+
π Knowledge Base (Learned)
+
+
+
+
+
@@ -1410,6 +1482,7 @@ function goToTab(tabName) {
if (tabName === 'news') loadNews();
if (tabName === 'blog') loadBlogDrafts();
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
+ if (tabName === 'crawlers') loadCrawlerStatus();
if (tabName === 'procurement') loadProcurement();
}
@@ -3451,6 +3524,175 @@ async function loadProcLifecycle() {
// INIT
loadOverview();
loadChangelog();
+
+// ββ CRAWLER INTELLIGENCE ββββββββββββββββββββββββββββββββββββββββββββ
+async function loadCrawlerStatus() {
+ var status = null;
+ var insights = null;
+ try {
+ var r = await fetch('/api/scrapers/status', { headers: { 'Authorization': 'Bearer ' + token } });
+ status = await r.json();
+ } catch(e) {}
+ try {
+ var r2 = await fetch('/api/scrapers/llm-insights', { headers: { 'Authorization': 'Bearer ' + token } });
+ insights = await r2.json();
+ } catch(e) {}
+
+ // DB summary cards
+ var db = (status && status.database) || {};
+ var sc = (status && status.scrapers) || {};
+ var pr = (status && status.pricing) || {};
+ document.getElementById('cr-transceivers').textContent = db.transceivers != null ? db.transceivers.toLocaleString() : 'β';
+ document.getElementById('cr-prices').textContent = pr.total_prices != null ? pr.total_prices.toLocaleString() : 'β';
+ document.getElementById('cr-vendors').textContent = db.vendors != null ? db.vendors.toLocaleString() : 'β';
+ document.getElementById('cr-news').textContent = db.news_articles != null ? db.news_articles.toLocaleString() : 'β';
+ document.getElementById('cr-kb').textContent = db.knowledge_base_entries != null ? db.knowledge_base_entries.toLocaleString() : 'β';
+ document.getElementById('cr-dbsize').textContent = db.size || 'β';
+ document.getElementById('cr-active').textContent = sc.active != null ? sc.active + ' / ' + sc.total : 'β';
+ document.getElementById('cr-lastprice').textContent = pr.last_update ? new Date(pr.last_update).toLocaleString('de-DE') : 'β';
+
+ // Scraper list
+ var list = (sc.list || []);
+ var categories = ['vendor','pricing','intelligence'];
+ var catLabel = { vendor: 'πͺ Vendor Scrapers', pricing: 'πΆ Pricing Scrapers', intelligence: 'π§ Intelligence Scrapers' };
+ var html = '';
+ for (var cat of categories) {
+ var items = list.filter(function(s) { return s.category === cat; });
+ if (!items.length) continue;
+ html += '' + catLabel[cat] + '
';
+ html += '
';
+ for (var s of items) {
+ var dot = s.status === 'active' ? '#22c55e' : '#64748b';
+ var lastRun = s.lastRun ? new Date(s.lastRun).toLocaleString('de-DE') : 'Never';
+ var firstSeen = s.firstSeen ? new Date(s.firstSeen).toLocaleDateString('de-DE') : 'β';
+ html += '
'
+ + '
'
+ + '
'
+ + '
' + esc(s.label) + '
'
+ + '
'
+ + (s.records ? '' + s.records.toLocaleString() + ' records Β· ' : '0 records Β· ')
+ + 'Last: ' + lastRun
+ + '
'
+ + '
First seen: ' + firstSeen + '
'
+ + '
';
+ }
+ html += '
';
+ }
+ document.getElementById('cr-scraper-list').innerHTML = html || 'No scraper data available.
';
+
+ // LLM Insights β Hot Topics
+ var topics = (insights && insights.hotTopics) || [];
+ var topicsHtml = topics.length ? topics.map(function(t) {
+ return ''
+ + '
'
+ + '
' + esc(t.title || '') + '
'
+ + (t.trend_score != null ? '
Score: ' + t.trend_score + '
' : '')
+ + '
'
+ + (t.summary ? '
' + esc(t.summary.substring(0,200)) + (t.summary.length > 200 ? 'β¦' : '') + '
' : '')
+ + '
' + esc(t.source || '') + (t.published_at ? ' Β· ' + new Date(t.published_at).toLocaleDateString('de-DE') : '') + '
'
+ + '
';
+ }).join('') : 'No LLM insights yet β run scrapers first.
';
+ document.getElementById('cr-topics').innerHTML = topicsHtml;
+
+ // Knowledge Base entries
+ var kb = (insights && insights.knowledgeBase) || [];
+ var kbHtml = kb.length ? ''
+ + '| Title | '
+ + 'Category | '
+ + 'Confidence | '
+ + '
'
+ + kb.map(function(k) {
+ return ''
+ + '| ' + esc(k.title || '') + ' | '
+ + '' + esc(k.category || 'β') + ' | '
+ + '' + (k.confidence_score != null ? k.confidence_score + '%' : 'β') + ' | '
+ + '
';
+ }).join('')
+ + '
'
+ : 'Knowledge base is empty β crawler LLM hasn\'t learned yet.
';
+ document.getElementById('cr-kb-entries').innerHTML = kbHtml;
+}
+
+
+/* ββ Smart Tooltips βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+function initSmartTooltips() {
+ var tip = document.createElement('div');
+ tip.id = 'smart-tip';
+ document.body.appendChild(tip);
+
+ var arrow = document.createElement('div');
+ arrow.id = 'smart-tip-arrow';
+ document.body.appendChild(arrow);
+
+ var hideTimer = null;
+
+ function showTip(el) {
+ var text = el.getAttribute('data-tip');
+ if (!text) return;
+ clearTimeout(hideTimer);
+
+ tip.textContent = text;
+ tip.classList.remove('visible');
+ arrow.classList.remove('visible', 'up', 'down');
+
+ // Measure after next frame so width/height are known
+ requestAnimationFrame(function() {
+ var rect = el.getBoundingClientRect();
+ var tw = tip.offsetWidth;
+ var th = tip.offsetHeight;
+ var vw = window.innerWidth;
+ var vh = window.innerHeight;
+ var GAP = 10;
+
+ // Prefer above, flip below if not enough space
+ var spaceAbove = rect.top;
+ var spaceBelow = vh - rect.bottom;
+ var showBelow = spaceAbove < th + GAP + 20 && spaceBelow > th + GAP + 20;
+
+ // Horizontal: center on element, clamp to viewport
+ var left = rect.left + rect.width / 2 - tw / 2;
+ left = Math.max(8, Math.min(left, vw - tw - 8));
+
+ var top;
+ if (showBelow) {
+ top = rect.bottom + GAP;
+ arrow.style.top = (rect.bottom + 2) + 'px';
+ arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px';
+ arrow.className = 'down visible';
+ } else {
+ top = rect.top - th - GAP;
+ arrow.style.top = (rect.top - GAP + 2) + 'px';
+ arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px';
+ arrow.className = 'up visible';
+ }
+
+ tip.style.left = left + 'px';
+ tip.style.top = top + 'px';
+ tip.classList.add('visible');
+ });
+ }
+
+ function hideTip() {
+ tip.classList.remove('visible');
+ arrow.classList.remove('visible');
+ }
+
+ // Delegate: attach once to body, handle all .tip elements
+ document.body.addEventListener('mouseenter', function(e) {
+ var el = e.target.closest ? e.target.closest('[data-tip]') : null;
+ if (el) showTip(el);
+ }, true);
+
+ document.body.addEventListener('mouseleave', function(e) {
+ var el = e.target.closest ? e.target.closest('[data-tip]') : null;
+ if (el) hideTimer = setTimeout(hideTip, 100);
+ }, true);
+
+ // Hide on scroll so tooltip doesn't drift
+ document.addEventListener('scroll', hideTip, true);
+}
+
+document.addEventListener('DOMContentLoaded', initSmartTooltips);