feat: Phase 8 — Dashboard frontend + static serving

Single-file dashboard with 6 tabs: Overview, Semantic Search,
Hype Cycle, Transceivers, News, Blog Engine. Dark theme, no
build step, served as static HTML from Express.

- Overview: health stats, vector collection counts, recent news
- Semantic Search: query across all 6 Qdrant collections
- Hype Cycle: Norton-Bass table with phase colors + position bars
- Transceivers: searchable table with form factor/speed/reach
- News: semantic news search with source links
- Blog: generate drafts from templates, view draft history

Live at: https://transceiver-db.context-x.org/dashboard/
This commit is contained in:
Rene Fichtmueller 2026-03-28 00:37:10 +13:00
parent f48a809e40
commit 1b0b602aa4
2 changed files with 581 additions and 2 deletions

View File

@ -2,6 +2,7 @@ import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { join } from "path";
import { cfg } from "./config";
import { transceiverRouter } from "./routes/transceivers";
import { switchRouter } from "./routes/switches";
@ -16,7 +17,7 @@ import { blogRouter } from "./routes/blog";
const app = express();
// Middleware
app.use(helmet());
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());
app.use(
@ -39,8 +40,16 @@ app.use("/api/search", searchRouter);
app.use("/api/documents", documentRouter);
app.use("/api/blog", blogRouter);
// Root
// Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
// Root — redirect to dashboard
app.get("/", (_req, res) => {
res.redirect("/dashboard/");
});
// API info
app.get("/api", (_req, res) => {
res.json({
name: "Transceiver Intelligence Platform",
version: "0.1.0",

View File

@ -0,0 +1,570 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TIP — Transceiver Intelligence Platform</title>
<style>
:root {
--bg: #0a0e17;
--surface: #111827;
--surface2: #1f2937;
--border: #374151;
--text: #e5e7eb;
--text-dim: #9ca3af;
--accent: #3b82f6;
--accent-glow: rgba(59,130,246,0.15);
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--purple: #8b5cf6;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #111827 0%, #1e1b4b 100%);
border-bottom: 1px solid var(--border);
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .status {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.85rem;
color: var(--text-dim);
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.status-dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.red { background: var(--red); }
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 2rem;
background: var(--surface);
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-size: 0.9rem;
transition: all 0.2s;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; }
.grid { display: grid; gap: 1rem; }
.grid-4 { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.card-value {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-blue { background: rgba(59,130,246,0.2); color: #60a5fa; }
.badge-green { background: rgba(16,185,129,0.2); color: #34d399; }
.badge-yellow { background: rgba(245,158,11,0.2); color: #fbbf24; }
.badge-red { background: rgba(239,68,68,0.2); color: #f87171; }
.badge-purple { background: rgba(139,92,246,0.2); color: #a78bfa; }
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-box input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
color: var(--text);
font-size: 0.95rem;
}
.search-box input:focus { outline: none; border-color: var(--accent); }
.search-box select {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text);
}
.search-box button {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
cursor: pointer;
font-weight: 600;
}
.search-box button:hover { opacity: 0.9; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
text-align: left;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
}
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(55,65,81,0.5);
}
tr:hover td { background: var(--accent-glow); }
.hype-bar {
height: 8px;
background: var(--surface2);
border-radius: 4px;
overflow: hidden;
width: 100%;
}
.hype-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s;
}
.result-item {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.result-item h4 { margin-bottom: 0.5rem; }
.result-score {
font-size: 0.8rem;
color: var(--accent);
font-weight: 600;
}
.result-meta { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.5rem; }
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin: 1.5rem 0 1rem;
}
.hidden { display: none !important; }
.loading { text-align: center; padding: 2rem; color: var(--text-dim); }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
.animate-pulse { animation: pulse 2s infinite; }
</style>
</head>
<body>
<div class="header">
<h1>TIP — Transceiver Intelligence Platform</h1>
<div class="status">
<span><span class="status-dot green" id="api-status"></span> API</span>
<span><span class="status-dot green" id="db-status"></span> Database</span>
<span><span class="status-dot green" id="qdrant-status"></span> Qdrant</span>
<span id="version-label"></span>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="search">Semantic Search</div>
<div class="tab" data-tab="hype">Hype Cycle</div>
<div class="tab" data-tab="transceivers">Transceivers</div>
<div class="tab" data-tab="news">News</div>
<div class="tab" data-tab="blog">Blog Engine</div>
</div>
<div class="main">
<!-- OVERVIEW TAB -->
<div id="tab-overview">
<div class="grid grid-4" id="stats-grid" style="margin-bottom:1.5rem">
<div class="card"><div class="card-title">Transceivers</div><div class="card-value" id="stat-transceivers"></div></div>
<div class="card"><div class="card-title">Vendors</div><div class="card-value" id="stat-vendors"></div></div>
<div class="card"><div class="card-title">Standards</div><div class="card-value" id="stat-standards"></div></div>
<div class="card"><div class="card-title">News Articles</div><div class="card-value" id="stat-news"></div></div>
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-title" style="margin-bottom:1rem">Vector Collections</div>
<div id="collections-list"></div>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:1rem">Recent News</div>
<div id="recent-news"></div>
</div>
</div>
<div class="card" style="margin-top:1rem">
<div class="card-title" style="margin-bottom:1rem">API Endpoints</div>
<div id="endpoints-list" style="font-family:monospace;font-size:0.85rem;color:var(--text-dim);line-height:1.8"></div>
</div>
</div>
<!-- SEARCH TAB -->
<div id="tab-search" class="hidden">
<div class="search-box">
<input type="text" id="search-input" placeholder="Search transceivers, datasheets, FAQ, troubleshooting...">
<select id="search-collection">
<option value="product_embeddings">Products</option>
<option value="faq_embeddings">FAQ</option>
<option value="troubleshooting_embeddings">Troubleshooting</option>
<option value="datasheet_chunks">Datasheets</option>
<option value="news_embeddings">News</option>
</select>
<button id="search-btn">Search</button>
</div>
<div id="search-results"></div>
</div>
<!-- HYPE CYCLE TAB -->
<div id="tab-hype" class="hidden">
<div class="card">
<div class="card-header">
<div class="card-title">Norton-Bass Hype Cycle — <span id="hype-year">2026</span></div>
</div>
<table>
<thead>
<tr><th>Technology</th><th>Phase</th><th>Position</th><th>Adoption</th><th>Peak Year</th><th>Years to Plateau</th></tr>
</thead>
<tbody id="hype-table"></tbody>
</table>
</div>
</div>
<!-- TRANSCEIVERS TAB -->
<div id="tab-transceivers" class="hidden">
<div class="search-box">
<input type="text" id="tx-search" placeholder="Search transceivers (e.g. 100G LR4, QSFP28, coherent)...">
<button id="tx-search-btn">Search</button>
</div>
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Form Factor</th><th>Speed</th><th>Reach</th><th>Fiber</th><th>Connector</th><th>WDM</th></tr>
</thead>
<tbody id="tx-table"></tbody>
</table>
</div>
</div>
<!-- NEWS TAB -->
<div id="tab-news" class="hidden">
<div id="news-list"></div>
</div>
<!-- BLOG TAB -->
<div id="tab-blog" class="hidden">
<div class="grid grid-3" style="margin-bottom:1.5rem">
<div class="card" style="cursor:pointer" id="gen-hype">
<div class="card-title">Generate</div>
<div style="font-weight:600;margin-top:0.5rem">Hype Cycle Analysis</div>
<div style="font-size:0.8rem;color:var(--text-dim);margin-top:0.25rem">800G technology position article</div>
</div>
<div class="card" style="cursor:pointer" id="gen-comparison">
<div class="card-title">Generate</div>
<div style="font-weight:600;margin-top:0.5rem">Product Comparison</div>
<div style="font-size:0.8rem;color:var(--text-dim);margin-top:0.25rem">400G transceiver comparison</div>
</div>
<div class="card" style="cursor:pointer" id="gen-tutorial">
<div class="card-title">Generate</div>
<div style="font-weight:600;margin-top:0.5rem">Tutorial</div>
<div style="font-size:0.8rem;color:var(--text-dim);margin-top:0.25rem">Transceiver troubleshooting guide</div>
</div>
</div>
<div class="section-title">Draft History</div>
<div id="blog-list"></div>
</div>
</div>
<script>
const API = window.location.hostname === 'localhost'
? 'http://localhost:3201'
: window.location.origin;
// Safe text escaping to prevent XSS
function esc(str) {
if (str == null) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
async function api(path) {
const r = await fetch(API + path);
return r.json();
}
function el(id) { return document.getElementById(id); }
function buildDOM(parent, html) {
// Using textContent where possible, innerHTML only with escaped data
parent.textContent = '';
const t = document.createElement('template');
t.innerHTML = html;
parent.appendChild(t.content.cloneNode(true));
}
// Tab navigation
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
document.querySelectorAll('[id^="tab-"]').forEach(function(p) { p.classList.add('hidden'); });
el('tab-' + tab.dataset.tab).classList.remove('hidden');
if (tab.dataset.tab === 'hype') loadHypeCycle();
if (tab.dataset.tab === 'transceivers') searchTransceivers();
if (tab.dataset.tab === 'news') loadNews();
if (tab.dataset.tab === 'blog') loadBlogDrafts();
});
});
// Overview
async function loadOverview() {
try {
var health = await api('/api/health');
el('stat-transceivers').textContent = health.database.stats.transceiver_count;
el('stat-vendors').textContent = health.database.stats.vendor_count;
el('stat-standards').textContent = health.database.stats.standard_count;
el('stat-news').textContent = health.database.stats.news_count;
el('version-label').textContent = 'v' + health.version;
el('api-status').className = 'status-dot ' + (health.success ? 'green' : 'red');
el('db-status').className = 'status-dot ' + (health.database.connected ? 'green' : 'red');
} catch(e) {
el('api-status').className = 'status-dot red';
}
try {
var stats = await api('/api/search/stats');
buildDOM(el('collections-list'), stats.collections.map(function(c) {
return '<div style="display:flex;justify-content:space-between;padding:0.5rem 0;border-bottom:1px solid var(--border)">'
+ '<span style="font-family:monospace;font-size:0.85rem">' + esc(c.collection) + '</span>'
+ '<span class="badge ' + (c.pointsCount > 0 ? 'badge-green' : 'badge-yellow') + '">' + esc(c.pointsCount) + ' points</span>'
+ '</div>';
}).join(''));
el('qdrant-status').className = 'status-dot green';
} catch(e) {
el('qdrant-status').className = 'status-dot red';
}
try {
var root = await api('/');
buildDOM(el('endpoints-list'), (root.endpoints || []).map(function(e) {
return '<div>' + esc(e) + '</div>';
}).join(''));
} catch(e) {}
try {
var news = await api('/api/search?q=transceiver+optics+data+center&collection=news_embeddings&limit=5');
buildDOM(el('recent-news'), (news.results || []).map(function(n) {
return '<div style="padding:0.5rem 0;border-bottom:1px solid var(--border)">'
+ '<div style="font-weight:500;font-size:0.9rem">' + esc(n.title) + '</div>'
+ '<div style="font-size:0.8rem;color:var(--text-dim)">' + esc(n.source) + ' &middot; ' + (n.published_at ? new Date(n.published_at).toLocaleDateString() : '') + '</div>'
+ '</div>';
}).join('') || '<div class="loading">No news yet</div>');
} catch(e) {}
}
// Semantic Search
function doSearch() {
var q = el('search-input').value;
var col = el('search-collection').value;
if (!q) return;
el('search-results').textContent = 'Searching...';
api('/api/search?q=' + encodeURIComponent(q) + '&collection=' + col + '&limit=10').then(function(data) {
buildDOM(el('search-results'), (data.results || []).map(function(r) {
var title = r.standard_name || r.title || r.question || r.symptom || (r.text ? r.text.slice(0,80) : 'Result');
var body = r.answer || r.solution || r.summary || (r.text ? r.text.slice(0,300) : '');
return '<div class="result-item">'
+ '<div style="display:flex;justify-content:space-between">'
+ '<h4>' + esc(title) + '</h4>'
+ '<span class="result-score">' + (r.score * 100).toFixed(1) + '%</span>'
+ '</div>'
+ '<div style="font-size:0.9rem;margin-top:0.5rem;color:var(--text-dim)">' + esc(body) + '</div>'
+ '<div class="result-meta">'
+ (r.form_factor ? '<span class="badge badge-blue">' + esc(r.form_factor) + '</span> ' : '')
+ (r.speed ? '<span class="badge badge-purple">' + esc(r.speed) + '</span> ' : '')
+ (r.category ? '<span class="badge badge-yellow">' + esc(r.category) + '</span> ' : '')
+ (r.severity ? '<span class="badge badge-red">' + esc(r.severity) + '</span> ' : '')
+ (r.vendor ? esc(r.vendor) : '')
+ '</div></div>';
}).join('') || '<div class="loading">No results</div>');
});
}
el('search-btn').addEventListener('click', doSearch);
el('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(); });
// Hype Cycle
async function loadHypeCycle() {
var data = await api('/api/hype-cycle');
var phaseColors = {
'Innovation Trigger': '#3b82f6',
'Peak of Inflated Expectations': '#f59e0b',
'Trough of Disillusionment': '#ef4444',
'Slope of Enlightenment': '#8b5cf6',
'Plateau of Productivity': '#10b981'
};
el('hype-year').textContent = data.year;
buildDOM(el('hype-table'), (data.technologies || []).map(function(t) {
var color = phaseColors[t.phase] || '#374151';
return '<tr>'
+ '<td style="font-weight:600">' + esc(t.technology) + '</td>'
+ '<td><span class="badge" style="background:' + color + '22;color:' + color + '">' + esc(t.phase) + '</span></td>'
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + esc(t.positionPct) + '%;background:' + color + '"></div></div></td>'
+ '<td>' + (t.adoptionPct * 100).toFixed(1) + '%</td>'
+ '<td>' + esc(t.peakYear || '—') + '</td>'
+ '<td>' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
+ '</tr>';
}).join(''));
}
// Transceivers
function searchTransceivers() {
var q = el('tx-search').value;
var url = q ? '/api/transceivers?q=' + encodeURIComponent(q) + '&limit=50' : '/api/transceivers?limit=50';
api(url).then(function(data) {
buildDOM(el('tx-table'), (data.transceivers || []).map(function(t) {
return '<tr>'
+ '<td style="font-weight:500">' + esc(t.standard_name || t.slug) + '</td>'
+ '<td><span class="badge badge-blue">' + esc(t.form_factor) + '</span></td>'
+ '<td>' + esc(t.speed) + '</td>'
+ '<td>' + esc(t.reach_label) + '</td>'
+ '<td>' + esc(t.fiber_type) + '</td>'
+ '<td>' + esc(t.connector) + '</td>'
+ '<td>' + esc(t.wdm_type) + '</td>'
+ '</tr>';
}).join(''));
});
}
el('tx-search-btn').addEventListener('click', searchTransceivers);
el('tx-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchTransceivers(); });
// News
async function loadNews() {
var data = await api('/api/search?q=optical+transceiver+data+center+networking&collection=news_embeddings&limit=20');
buildDOM(el('news-list'), (data.results || []).map(function(n) {
var urlSafe = (n.url && /^https?:\/\//.test(n.url)) ? n.url : '#';
return '<div class="result-item">'
+ '<h4>' + esc(n.title) + '</h4>'
+ '<div style="font-size:0.9rem;margin:0.5rem 0;color:var(--text-dim)">' + esc(n.summary) + '</div>'
+ '<div class="result-meta">'
+ '<span class="badge badge-blue">' + esc(n.source) + '</span> '
+ (n.published_at ? new Date(n.published_at).toLocaleDateString() : '')
+ (urlSafe !== '#' ? ' &middot; <a href="' + esc(urlSafe) + '" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Read &rarr;</a>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No news articles</div>');
}
// Blog
function generateBlog(topic, speed) {
el('blog-list').textContent = 'Generating blog draft...';
var body = { topic: topic };
if (speed) body.speed = speed;
fetch(API + '/api/blog/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
alert('Draft generated: "' + data.draft.title + '" (' + data.draft.word_count + ' words)');
}
loadBlogDrafts();
});
}
el('gen-hype').addEventListener('click', function() { generateBlog('hype_cycle', '800G'); });
el('gen-comparison').addEventListener('click', function() { generateBlog('comparison', '400G'); });
el('gen-tutorial').addEventListener('click', function() { generateBlog('tutorial'); });
async function loadBlogDrafts() {
var data = await api('/api/blog');
buildDOM(el('blog-list'), (data.drafts || []).map(function(d) {
var statusClass = d.status === 'published' ? 'badge-green' : d.status === 'review' ? 'badge-yellow' : 'badge-blue';
var keywords = (d.seo_keywords || []).map(function(k) { return '#' + esc(k); }).join(' ');
return '<div class="result-item">'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<h4>' + esc(d.title) + '</h4>'
+ '<span class="badge ' + statusClass + '">' + esc(d.status) + '</span>'
+ '</div>'
+ '<div class="result-meta">'
+ '<span class="badge badge-purple">' + esc(d.topic) + '</span> '
+ '<span class="badge badge-blue">' + esc(d.target_audience) + '</span> '
+ esc(d.word_count) + ' words &middot; ' + new Date(d.created_at).toLocaleDateString()
+ (keywords ? ' &middot; <span style="color:var(--text-dim)">' + keywords + '</span>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No drafts yet. Click a template above to generate.</div>');
}
// Init
loadOverview();
</script>
</body>
</html>