Rene Fichtmueller f162e03978 feat: Flexoptix internal demand intelligence + real forecast calibration
- Migration 099: flexoptix_internal_demand table with RLS + v_demand_by_speed view
- Import script: AES-256-CBC decrypt → parse 8585 SKUs → upsert with velocity class
- 279 SKUs cross-referenced to transceiver catalog; 1288 with real demand data
- New /api/internal/demand/* routes (by-speed, velocity, hype-weights, forecast-input)
  — protected by JWT auth + localhost/LAN IP restriction middleware
- Forecast engine calibrated with real Flexoptix run-rates (demand_calibrated flag)
- Dashboard: real Flexoptix Sales Velocity panel replaces DEMO DATA in Warehouse tab
  with momentum indicators, velocity class breakdown, trend arrows
- Security: data stays on private server; RLS enforces is_internal=TRUE at DB layer
2026-04-25 17:44:20 +02:00

7324 lines
422 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700;800&family=DM+Serif+Display&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f7;
--surface: #ffffff;
--surface2: #f0f0f0;
--surface3: #e5e5e5;
--surface-dark: #000000;
--border: #e0e0e0;
--border-hover: #FF8100;
--text: #333333;
--text-bright: #000000;
--text-dim: #888888;
--accent: #FF8100;
--accent-dark: #e07000;
--accent-glow: rgba(255,129,0,0.08);
--accent2: #1a1a1a;
--green: #2d6a4f;
--green-light: #d8f3dc;
--yellow: #FFa000;
--yellow-light: #fff3e0;
--red: #c1121f;
--red-light: #fde8e8;
--purple: #7c5cfc;
--purple-light: #f0ecff;
--orange: #FF8100;
--cyan: #1a1a1a;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-heading: 'DM Sans', system-ui, -apple-system, sans-serif;
--shadow-card: 0 1px 3px rgba(0,0,0,0.06);
--shadow-hover: 0 4px 12px rgba(0,0,0,0.1);
--shadow-glow: 0 2px 8px rgba(255,129,0,0.1);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
}
* { margin:0; padding:0; box-sizing:border-box; }
/* Scrollbar styling to match theme */
html { scrollbar-color: var(--border) var(--bg); }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #cccccc; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
overflow-x: hidden;
}
/* === LAYOUT === */
.app { position: relative; z-index: 1; }
/* === HEADER === */
.header {
background: var(--surface-dark);
padding: 0 clamp(1.5rem, 4vw, 5rem);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.header-left { display: flex; align-items: center; gap: 2rem; }
.logo { display: flex; align-items: center; gap: 0.6rem; }
.logo-mark {
width: 32px; height: 32px; border-radius: var(--radius-md);
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 0.85rem; color: #fff;
}
.logo-text {
font-family: var(--font-heading);
font-size: 1rem; color: #fff;
letter-spacing: -0.02em;
}
.logo-text span { color: rgba(255,255,255,0.5); font-family: var(--font-body); font-size: 0.85rem; margin-left: 0.25rem; }
.header-stats {
display: flex; gap: 1.5rem; font-size: 0.75rem;
font-family: var(--mono); color: rgba(255,255,255,0.5);
}
.header-stats span[data-goto] {
cursor: pointer; transition: color 0.2s;
}
.header-stats span[data-goto]:hover {
color: rgba(255,255,255,0.85);
}
.header-stats .val {
color: var(--accent); font-weight: 600;
}
.header .status {
display: flex; gap: 1rem; align-items: center; font-size: 0.75rem;
}
.status-pill {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.6rem; border-radius: 20px;
background: rgba(45,106,79,0.15);
border: 1px solid rgba(45,106,79,0.3);
color: #6ee7b7; font-weight: 500;
font-size: 0.7rem; font-family: var(--mono);
}
.status-pill.err { background: rgba(193,18,31,0.15); border-color: rgba(193,18,31,0.3); color: #fca5a5; }
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-ok { background: #6ee7b7; box-shadow: 0 0 6px rgba(110,231,183,0.4); animation: pulse-dot 3s infinite; }
.dot-err { background: #fca5a5; }
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.5} }
.version-tag {
font-family: var(--mono); font-size: 0.65rem; color: rgba(255,255,255,0.4);
padding: 0.15rem 0.5rem; border-radius: 4px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
}
/* === TABS === */
.tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 clamp(1.5rem, 4vw, 5rem);
background: var(--surface);
}
.tab {
padding: 0.75rem 1.25rem;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-size: 0.8rem; font-weight: 600;
transition: all 0.2s ease;
letter-spacing: 0.02em;
}
.tab:hover { color: var(--text-bright); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* === MAIN === */
.main { padding: 1.5rem clamp(2rem, 4vw, 5rem); max-width: 1600px; margin: 0 auto; }
.grid { display: grid; gap: 1rem; }
.g4 { grid-template-columns: repeat(4, 1fr); }
.g3 { grid-template-columns: repeat(3, 1fr); }
.g2 { grid-template-columns: 1fr 1fr; }
.g2-1 { grid-template-columns: 2fr 1fr; }
/* === CARDS === */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-card);
transition: box-shadow 0.2s, border-color 0.2s;
}
.card-header {
font-size: 1rem; font-weight: 700; color: var(--text-bright);
margin-bottom: 1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.card:hover {
box-shadow: var(--shadow-hover);
}
.card-label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); margin-bottom: 0.5rem;
}
.card-num {
font-size: 2rem; font-weight: 800;
font-family: var(--mono); letter-spacing: -0.03em;
color: var(--accent);
}
.card-num small {
font-size: 0.7rem; font-weight: 400; color: var(--text-dim);
}
/* === STAT CARDS === */
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
position: relative; overflow: hidden;
transition: all 0.2s;
box-shadow: var(--shadow-card);
cursor: pointer;
}
.stat-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.stat-icon {
width: 36px; height: 36px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; margin-bottom: 0.75rem;
}
.stat-icon.blue { background: rgba(196,112,75,0.08); color: var(--accent); }
.stat-icon.green { background: rgba(45,106,79,0.08); color: var(--green); }
.stat-icon.purple { background: rgba(124,92,252,0.08); color: var(--purple); }
.stat-icon.orange { background: rgba(231,111,81,0.08); color: var(--orange); }
.stat-icon.cyan { background: rgba(38,70,83,0.08); color: var(--cyan); }
.stat-label {
font-size: 0.72rem; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700;
}
.stat-val {
font-size: 1.75rem; font-weight: 800; font-family: var(--mono);
color: var(--text-bright); letter-spacing: -0.03em; margin-top: 0.15rem;
}
/* === BADGES === */
.b {
display: inline-block; padding: 2px 8px; border-radius: 100px;
font-size: 0.68rem; font-weight: 600; font-family: var(--mono);
letter-spacing: 0.02em;
}
.b-blue { background: rgba(196,112,75,0.08); color: var(--accent); border: 1px solid rgba(196,112,75,0.2); }
.b-green { background: rgba(45,106,79,0.08); color: var(--green); border: 1px solid rgba(45,106,79,0.2); }
.b-yellow { background: rgba(212,163,115,0.12); color: #b8860b; border: 1px solid rgba(212,163,115,0.3); }
.b-red { background: rgba(193,18,31,0.06); color: var(--red); border: 1px solid rgba(193,18,31,0.2); }
.b-purple { background: rgba(124,92,252,0.06); color: var(--purple); border: 1px solid rgba(124,92,252,0.2); }
.b-orange { background: rgba(231,111,81,0.06); color: var(--orange); border: 1px solid rgba(231,111,81,0.2); }
.b-cyan { background: rgba(38,70,83,0.06); color: var(--cyan); border: 1px solid rgba(38,70,83,0.2); }
.b-neutral { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }
/* === TABLES === */
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th {
text-align: left; padding: 0.6rem 0.75rem;
border-bottom: 2px solid var(--border);
color: var(--text-dim); font-weight: 700;
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
position: sticky; top: 0; background: var(--surface); z-index: 1;
cursor: pointer; user-select: none; transition: color 0.2s;
white-space: nowrap;
}
th:hover { color: var(--text-bright); }
th .sort-arrow { display: inline-block; margin-left: 4px; font-size: 0.6rem; opacity: 0.3; transition: opacity 0.2s; }
th.sort-asc .sort-arrow, th.sort-desc .sort-arrow { opacity: 1; color: var(--accent); }
th.sort-asc .sort-arrow::after { content: '▲'; }
th.sort-desc .sort-arrow::after { content: '▼'; }
th:not(.sort-asc):not(.sort-desc) .sort-arrow::after { content: '⇅'; }
td { padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text); }
tr.clickable { cursor: pointer; transition: background 0.15s; }
tr.clickable:hover td { background: var(--accent-glow); }
.table-wrap { max-height: 70vh; overflow-y: auto; border-radius: var(--radius-md); }
.table-wrap::-webkit-scrollbar { width: 6px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* === SEARCH === */
.search-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
.search-row input, .search-row select {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 0.6rem 1rem;
color: var(--text-bright); font-size: 0.85rem;
font-family: var(--font-body);
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-row input { flex: 1; }
.search-row input:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-row select { min-width: 140px; }
.btn {
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-md); padding: 0.6rem 1.25rem;
font-weight: 700; font-size: 0.8rem; cursor: pointer;
font-family: var(--font-body);
transition: background 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-glow);
}
.btn:hover { background: var(--accent-dark); box-shadow: var(--shadow-hover); }
.btn-ghost {
background: transparent; border: 1px solid var(--border);
color: var(--text-dim); cursor: pointer; border-radius: var(--radius-md);
padding: 0.5rem 0.85rem; font-size: 0.75rem; font-weight: 600;
font-family: var(--font-body);
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
/* === RESULT ITEMS === */
.ri {
border-bottom: 1px solid var(--border);
padding: 1rem 0; cursor: pointer;
transition: all 0.15s;
}
.ri:last-child { border-bottom: none; }
.ri:hover { background: var(--accent-glow); margin: 0 -1.25rem; padding: 1rem 1.25rem; border-radius: var(--radius-md); }
.ri-title { font-weight: 600; font-size: 0.85rem; color: var(--text-bright); }
.ri-body { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.3rem; line-height: 1.6; }
.ri-meta { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.4rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
/* === HYPE CYCLE === */
.hype-wrap {
background: #0a0a0f;
border: 1px solid rgba(255,129,0,0.15);
border-radius: 16px;
padding: 2rem 2rem 1.5rem;
overflow-x: auto;
box-shadow: 0 4px 40px rgba(0,0,0,0.3), 0 0 80px rgba(255,129,0,0.03);
position: relative;
}
.hype-wrap::before {
content: '';
position: absolute; inset: 0;
border-radius: 16px;
background: radial-gradient(ellipse 80% 50% at 20% 30%, rgba(255,129,0,0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 80% 70%, rgba(255,129,0,0.02) 0%, transparent 60%);
pointer-events: none;
}
.hype-wrap svg { display: block; width: 100%; height: auto; position: relative; z-index: 1; }
.hype-dot { cursor: pointer; transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1); }
.hype-dot:hover { filter: brightness(1.3) drop-shadow(0 0 12px currentColor); transform: scale(1.15); }
.hype-label { font-size: 11px; fill: #1a1a2e; pointer-events: none; font-family: 'DM Sans', sans-serif; letter-spacing: 0.02em; }
.hype-phase-label { font-size: 10px; fill: rgba(0,0,0,0.45); text-anchor: middle; font-family: 'DM Sans', sans-serif; text-transform: uppercase; letter-spacing: 0.08em; }
.hype-pulse { animation: hype-pulse-anim 2.5s ease-in-out infinite; }
@keyframes hype-pulse-anim { 0%,100% { opacity: 0.25; r: 16; } 50% { opacity: 0.08; r: 24; } }
.hype-connector { stroke: rgba(255,255,255,0.12); stroke-width: 1; stroke-dasharray: 2,3; }
/* Hype Cycle Hover Tooltip */
.hype-tooltip {
position: fixed; z-index: 1000; pointer-events: none;
background: rgba(10,11,16,0.95); border: 1px solid rgba(255,129,0,0.3);
border-radius: 8px; padding: 10px 14px; min-width: 180px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); backdrop-filter: blur(8px);
font-size: 12px; color: var(--text); opacity: 0; transition: opacity 0.15s;
}
.hype-tooltip.visible { opacity: 1; }
.hype-tooltip .tt-tech { font-weight: 700; font-size: 13px; margin-bottom: 4px; }
.hype-tooltip .tt-phase { font-size: 11px; opacity: 0.7; margin-bottom: 6px; }
.hype-tooltip .tt-row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
.hype-tooltip .tt-label { color: var(--text-dim); }
.hype-tooltip .tt-val { font-family: 'JetBrains Mono', monospace; font-weight: 600; }
/* Phase Legend */
.hype-legend {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
margin-top: 12px; padding: 8px 0;
}
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.hype-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1.5rem; position: relative; z-index: 1;
}
.hype-title {
font-family: var(--font-heading);
font-weight: 700; font-size: 1.3rem; color: #ffffff;
letter-spacing: -0.02em;
}
.hype-title .mono { color: #FF8100; font-weight: 600; }
.hype-sub { font-size: 0.72rem; color: rgba(255,255,255,0.4); margin-top: 0.35rem; letter-spacing: 0.01em; }
.hype-legend { display: flex; gap: 1.2rem; font-size: 0.68rem; color: rgba(255,255,255,0.5); align-items: center; }
.hype-legend-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
margin-right: 0.3rem; vertical-align: middle;
box-shadow: 0 0 6px currentColor;
}
.hype-bar { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; width: 100%; }
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
/* === TOOLTIPS === */
/* 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.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;
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
opacity: 0; transition: opacity 0.15s;
}
#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;
}
#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 {
position: fixed; top: 0; right: 0;
width: 560px; height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0,0,0,0.08);
z-index: 1000; overflow-y: auto;
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
padding: 1.5rem;
}
.panel.open { transform: translateX(0); }
.panel::-webkit-scrollbar { width: 4px; }
.panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.panel-close {
position: absolute; top: 0.75rem; right: 0.75rem;
background: #e8e8f0; border: 1.5px solid #c0c0cc;
color: #1a1a2e; width: 38px; height: 38px; border-radius: 8px;
cursor: pointer; font-size: 1.3rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.12); z-index: 10;
}
.panel-close:hover { background: var(--accent); color: #fff; border-color: var(--accent); box-shadow: 0 2px 12px rgba(255,102,0,0.4); }
.panel-title {
font-family: var(--font-heading);
font-size: 1.3rem; font-weight: 400; color: var(--text-bright);
margin-bottom: 0.2rem; letter-spacing: -0.02em;
padding-right: 2.5rem;
}
.panel-sub { font-size: 0.8rem; color: var(--text-dim); margin-bottom: 1.25rem; }
.panel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-bottom: 1.25rem; }
.panel-stat {
background: var(--surface2); border-radius: var(--radius-md); padding: 0.75rem;
border: 1px solid var(--border);
}
.panel-stat-label { font-size: 0.65rem; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.06em; font-weight: 700; }
.panel-stat-val {
font-size: 1.3rem; font-weight: 800; font-family: var(--mono);
margin-top: 0.2rem; color: var(--text-bright);
}
.panel-section {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-dim);
margin: 1.25rem 0 0.6rem;
display: flex; align-items: center; gap: 0.5rem;
}
.panel-section::after {
content: ''; flex: 1; height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
}
.panel-row {
display: flex; justify-content: space-between; align-items: center;
padding: 0.4rem 0; border-bottom: 1px solid var(--border);
font-size: 0.8rem;
}
.panel-row:last-child { border-bottom: none; }
.panel-row-label { color: var(--text-dim); }
.panel-row-val { font-weight: 600; font-family: var(--mono); color: var(--text-bright); }
/* Spec table — Flexoptix-style specification display */
.spec-table {
background: var(--surface2); border-radius: var(--radius-md);
border: 1px solid var(--border); overflow: hidden; margin-bottom: 0.5rem;
}
.spec-row {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border);
font-size: 0.78rem; gap: 1rem;
}
.spec-row:last-child { border-bottom: none; }
.spec-row:nth-child(even) { background: rgba(0,0,0,0.015); }
.spec-label {
color: var(--text-dim); font-weight: 600; text-transform: uppercase;
font-size: 0.68rem; letter-spacing: 0.03em; min-width: 120px; flex-shrink: 0;
}
.spec-val {
font-weight: 600; color: var(--text-bright); text-align: right;
font-family: var(--mono); font-size: 0.78rem; word-break: break-word;
}
.forecast-bar {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem;
}
.forecast-bar .yr { width: 40px; color: var(--text-dim); font-family: var(--mono); font-size: 0.75rem; }
.forecast-bar .track { flex: 1; height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; }
.forecast-bar .fill { height: 100%; border-radius: 3px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.forecast-bar .pct { width: 44px; text-align: right; font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); }
/* === USE CASE BADGE === */
.use-case-card {
display: flex; align-items: center; gap: 0.6rem;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 0.6rem 0.8rem;
margin-bottom: 0.4rem;
}
.use-case-icon { font-size: 1.1rem; }
.use-case-label { font-weight: 600; font-size: 0.8rem; color: var(--text-bright); }
.use-case-desc { font-size: 0.72rem; color: var(--text-dim); }
/* === TRANSCEIVER IMAGE === */
.tx-image-box {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
margin-bottom: 1rem; overflow: hidden;
position: relative;
}
.tx-image-box.has-photo { height: auto; min-height: 140px; max-height: 280px; background: #fff; }
.tx-image-box.has-svg { height: 140px; }
.tx-image-box img {
max-width: 100%; max-height: 280px; object-fit: contain;
padding: 0.75rem; transition: transform 0.3s;
}
.tx-image-box img:hover { transform: scale(1.05); }
.tx-image-box .img-badge {
position: absolute; top: 8px; right: 8px;
font-size: 0.6rem; font-family: var(--mono);
background: rgba(0,0,0,0.6); color: #fff;
padding: 2px 6px; border-radius: 4px;
}
.tx-image-placeholder {
color: var(--text-dim); font-size: 2.5rem; opacity: 0.3;
}
.img-link {
position: absolute; bottom: 8px; right: 8px;
font-size: 0.68rem; font-weight: 600;
color: var(--accent); text-decoration: none;
background: rgba(255,255,255,0.9); padding: 3px 8px;
border-radius: 4px; border: 1px solid var(--border);
transition: background 0.2s;
}
.img-link:hover { background: #fff; }
/* === BLOG CARDS === */
.gen-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1.25rem; cursor: pointer;
transition: all 0.2s; position: relative; overflow: hidden;
box-shadow: var(--shadow-card);
}
.gen-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow-hover); }
.gen-card-title {
font-family: var(--font-heading);
font-weight: 400; font-size: 1rem; color: var(--text-bright);
}
.gen-card-sub { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.3rem; }
/* === COPY BUTTON === */
.btn-copy {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-dim); cursor: pointer; border-radius: var(--radius-sm);
padding: 0.3rem 0.7rem; font-size: 0.72rem; font-weight: 600;
font-family: var(--font-body);
transition: all 0.2s; display: inline-flex; align-items: center; gap: 0.3rem;
}
.btn-copy:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
.btn-copy.copied { background: rgba(45,106,79,0.08); color: var(--green); border-color: var(--green); }
/* === COLLECTION ITEM === */
.col-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
}
.col-item:last-child { border-bottom: none; }
.col-name { font-family: var(--mono); font-size: 0.75rem; color: var(--text); }
/* === ENDPOINT LIST === */
.endpoint-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem;
font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim); line-height: 1.8;
}
.endpoint-item {
padding: 0.2rem 0.5rem; border-radius: 4px;
transition: all 0.15s;
}
.endpoint-item:hover { background: var(--accent-glow); color: var(--accent); }
/* === UTILITIES === */
.hidden { display: none !important; }
.loading { text-align: center; padding: 2.5rem; color: var(--text-dim); font-size: 0.85rem; }
.mono { font-family: var(--mono); }
.dim { color: var(--text-dim); }
.mt { margin-top: 1rem; }
.mb { margin-bottom: 1rem; }
/* === TOAST === */
.toast {
position: fixed; top: 1.25rem; right: 1.25rem; z-index: 9999;
background: var(--surface);
border: 1px solid var(--green);
border-left: 3px solid var(--green);
border-radius: var(--radius-md); padding: 0.85rem 1.5rem; max-width: 400px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
transform: translateX(120%); transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast.show { transform: translateX(0); }
.toast.error { border-color: var(--red); border-left-color: var(--red); }
.toast-title { font-weight: 700; font-size: 0.8rem; color: var(--text-bright); }
.toast-body { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.15rem; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.pulse { animation: pulse 1.5s infinite; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeInUp 0.4s ease-out; }
@media (max-width: 1100px) { .g4 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) {
.g2, .g2-1, .g3, .g4 { grid-template-columns: 1fr; }
.panel { width: 100%; }
.header-stats { display: none; }
.main { padding: 1rem; }
.header { padding: 0 1rem; }
.tabs { padding: 0 1rem; }
}
/* Compare overlay */
.compare-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); z-index: 500; overflow-y: auto;
padding: 2rem;
}
.compare-overlay.visible { display: flex; justify-content: center; align-items: flex-start; }
.compare-panel {
background: var(--surface); border-radius: var(--radius-lg);
padding: 1.5rem; max-width: 95vw; min-width: 600px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.compare-table { overflow-x: auto; }
.compare-table table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.compare-table th, .compare-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); text-align: left; }
.compare-table th { background: var(--surface2); font-weight: 600; position: sticky; top: 0; }
.compare-diff { background: var(--yellow-light); }
.compare-best { background: var(--green-light); font-weight: 600; }
.compare-cb { width: 16px; height: 16px; cursor: pointer; accent-color: var(--purple); }
/* === CHANGELOG === */
.cl-entry {
display: flex; gap: 0.6rem; align-items: baseline;
padding: 0.4rem 0; border-bottom: 1px solid var(--border);
font-size: 0.78rem;
}
.cl-entry:last-child { border-bottom: none; }
.cl-date { font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
.cl-type {
font-size: 0.62rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.07em; padding: 1px 6px; border-radius: 3px;
white-space: nowrap; flex-shrink: 0;
}
.cl-FEAT { background: rgba(255,129,0,0.12); color: var(--accent); }
.cl-FIX { background: rgba(193,18,31,0.1); color: #c1121f; }
.cl-UI { background: rgba(124,92,252,0.1); color: #7c5cfc; }
.cl-DATA { background: rgba(45,106,79,0.1); color: #2d6a4f; }
.cl-AI { background: rgba(26,26,46,0.1); color: #1a1a2e; }
.cl-INFRA { background: rgba(136,136,136,0.1);color: #666; }
.cl-msg { color: var(--text); line-height: 1.4; }
/* === PROCUREMENT TAB === */
.proc-btn {
background: var(--surface2); border: 1px solid var(--border);
padding: 5px 14px; border-radius: 6px; cursor: pointer;
font-size: 0.78rem; font-weight: 600; color: var(--text-dim);
transition: all 0.15s;
}
.proc-btn:hover { color: var(--text); border-color: var(--accent); }
.proc-btn-active { background: var(--accent); color: #fff !important; border-color: var(--accent) !important; }
.signal-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1rem;
box-shadow: var(--shadow-card); position: relative;
}
.signal-card:hover { box-shadow: var(--shadow-hover); }
.signal-buy { border-left: 3px solid #c1121f; }
.signal-wait { border-left: 3px solid var(--yellow); }
.signal-hold { border-left: 3px solid var(--green); }
.signal-monitor { border-left: 3px solid var(--purple); }
.sig-badge-buy { background:#fde8e8; color:#c1121f; }
.sig-badge-wait { background:var(--yellow-light); color:#a06000; }
.sig-badge-hold { background:var(--green-light); color:#1b4332; }
.sig-badge-monitor { background:var(--purple-light); color:#5a3fcf; }
.intel-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 1rem;
box-shadow: var(--shadow-card);
}
.intel-badge {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 0.5rem;
}
.intel-buy { background:#fde8e8; color:#c1121f; }
.intel-wait { background:var(--yellow-light); color:#a06000; }
.intel-hold { background:var(--green-light); color:#1b4332; }
.intel-monitor { background:var(--purple-light); color:#5a3fcf; }
.intel-none { background:var(--surface2); color:var(--text-dim); }
.abc-a { background:#fde8e8; color:#c1121f; font-weight:800; padding:2px 7px; border-radius:4px; }
.abc-b { background:var(--yellow-light); color:#a06000; font-weight:800; padding:2px 7px; border-radius:4px; }
.abc-c { background:var(--surface2); color:var(--text-dim); font-weight:800; padding:2px 7px; border-radius:4px; }
</style>
</head>
<body>
<!-- Auth guard — redirect to login if no valid token -->
<script>
// ── Token storage helpers — never store plaintext ──────────────────────────
(function() {
var _K = 'tip_v3_tk';
var _x = 'fx9z2mq8';
function _enc(s) {
var r = '';
for (var i = 0; i < s.length; i++) r += String.fromCharCode(s.charCodeAt(i) ^ _x.charCodeAt(i % _x.length));
return btoa(r);
}
function _dec(s) {
try {
var b = atob(s); var r = '';
for (var i = 0; i < b.length; i++) r += String.fromCharCode(b.charCodeAt(i) ^ _x.charCodeAt(i % _x.length));
return r;
} catch(e) { return ''; }
}
window.saveToken = function(t) { localStorage.setItem(_K, _enc(t)); localStorage.removeItem('tip_token'); };
window.loadToken = function() {
var v = localStorage.getItem(_K);
if (v) return _dec(v) || '';
// migrate legacy plaintext token
var old = localStorage.getItem('tip_token');
if (old) { window.saveToken(old); return old; }
return '';
};
window.clearToken = function() { localStorage.removeItem(_K); localStorage.removeItem('tip_token'); };
})();
(function() {
var token = window.loadToken();
if (!token) { window.location.replace('/dashboard/login.html'); return; }
fetch('/api/auth/verify', { headers: { Authorization: 'Bearer ' + token } })
.then(function(r) {
if (!r.ok) {
window.clearToken();
window.location.replace('/dashboard/login.html');
}
})
.catch(function() {
// Network error — stay on page (API might be loading)
});
})();
</script>
<div class="app">
<div id="toast" class="toast"><div class="toast-title"></div><div class="toast-body"></div></div>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo">
<div class="logo-mark">TIP</div>
<div class="logo-text">Transceiver Intelligence<span>Platform</span></div>
</div>
<div class="header-stats">
<span data-goto="transceivers"><span class="val" id="stat-transceivers">&mdash;</span> transceivers</span>
<span data-goto="vendors"><span class="val" id="stat-vendors">&mdash;</span> vendors</span>
<span data-goto="switches"><span class="val" id="stat-switches">&mdash;</span> switches</span>
<span data-goto="standards"><span class="val" id="stat-standards">&mdash;</span> standards</span>
<span data-goto="news"><span class="val" id="stat-news">&mdash;</span> articles</span>
<span data-goto="stock"><span class="val" id="stat-stock-obs">&mdash;</span> stock obs</span>
</div>
</div>
<div class="status">
<div class="status-pill" id="api-pill"><span class="dot dot-ok" id="api-status"></span>API</div>
<div class="status-pill" id="db-pill"><span class="dot dot-ok" id="db-status"></span>DB</div>
<div class="status-pill" id="qdrant-pill"><span class="dot dot-ok" id="qdrant-status"></span>Qdrant</div>
<span class="version-tag" id="version-label"></span>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="search">Search</div>
<div class="tab" data-tab="hype">Hype Cycle</div>
<div class="tab" data-tab="transceivers">Transceivers</div>
<div class="tab" data-tab="vendors">Vendors</div>
<div class="tab" data-tab="standards">Standards</div>
<div class="tab" data-tab="switches">Switches</div>
<div class="tab" data-tab="news">News</div>
<div class="tab" data-tab="finder">Finder</div>
<div class="tab" data-tab="blog">Blog Engine</div>
<div class="tab" data-tab="procurement">Procurement Intelligence</div>
<div class="tab" data-tab="crawlers">🕷 Crawler Intelligence</div>
<div class="tab" data-tab="selflearning">Selflearning</div>
<div class="tab" data-tab="network">&#127760; Network</div>
<div class="tab" data-tab="review" id="tab-review-nav">&#9998; Review <span id="review-pending-badge" style="display:none;background:#f97316;color:#fff;border-radius:10px;padding:1px 7px;font-size:0.68rem;margin-left:4px;font-weight:700"></span></div>
<div class="tab" data-tab="stock">🏭 Stock <span style="font-size:0.6rem;color:#f59e0b;vertical-align:middle">⚠DEMO</span></div>
<div class="tab" data-tab="prices">💲 Price Comparison</div>
</div>
<div class="main">
<!-- OVERVIEW -->
<div id="tab-overview" class="fade-in">
<div class="grid mb" style="grid-template-columns: repeat(5, 1fr);">
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon blue">&#9881;</div>
<div class="stat-label">Transceivers</div>
<div class="stat-val" id="ov-transceivers">&mdash;</div>
</div>
<div class="stat-card" data-goto="transceivers">
<div class="stat-icon green">&#9733;</div>
<div class="stat-label">Vendors</div>
<div class="stat-val" id="ov-vendors">&mdash;</div>
</div>
<div class="stat-card" data-goto="switches">
<div class="stat-icon orange">&#9881;</div>
<div class="stat-label">Switches</div>
<div class="stat-val" id="ov-switches">&mdash;</div>
</div>
<div class="stat-card" data-goto="hype">
<div class="stat-icon purple">&#9879;</div>
<div class="stat-label">Standards</div>
<div class="stat-val" id="ov-standards">&mdash;</div>
</div>
<div class="stat-card" data-goto="news">
<div class="stat-icon cyan">&#9998;</div>
<div class="stat-label">News Articles</div>
<div class="stat-val" id="ov-news">&mdash;</div>
</div>
</div>
<!-- VERIFICATION STATUS -->
<div class="card mb" id="verification-card">
<div class="card-label">Data Verification Status</div>
<div id="verification-overview" class="mt" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem"></div>
</div>
<!-- WAREHOUSE STOCK SUMMARY -->
<div class="card mb" id="ov-stock-card" style="display:none">
<div class="card-label" style="display:flex;justify-content:space-between;align-items:center">
<span>🏭 Warehouse Stock Summary</span>
<button class="btn-sm" onclick="goToTab('stock')" style="font-size:0.7rem;padding:0.2rem 0.6rem">View Detail →</button>
</div>
<div id="ov-stock-grid" class="mt" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0.75rem"></div>
</div>
<div class="grid g2 mb">
<div class="card">
<div class="card-label">Vector Collections</div>
<div id="collections-list" class="mt"></div>
</div>
<div class="card">
<div class="card-label">Recent Intelligence</div>
<div id="recent-news" class="mt"></div>
</div>
</div>
<div class="card">
<div class="card-label">API Endpoints</div>
<div id="endpoints-list" class="endpoint-grid mt"></div>
</div>
<!-- CHANGELOG -->
<div class="card mt" id="changelog-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<div class="card-label" style="margin-bottom:0">Changelog</div>
<div style="display:flex;gap:0.4rem;align-items:center">
<span id="changelog-total" style="font-size:0.7rem;color:var(--text-dim);font-family:var(--mono)"></span>
<button onclick="toggleChangelog()" id="changelog-toggle-btn" style="background:var(--surface2);border:1px solid var(--border);padding:2px 10px;border-radius:5px;cursor:pointer;font-size:0.72rem;color:var(--text-dim)">Show all</button>
</div>
</div>
<div id="changelog-list"></div>
</div>
</div>
<!-- SEARCH -->
<div id="tab-search" class="hidden">
<div class="search-row">
<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 class="btn" id="search-btn">Search</button>
</div>
<div class="card"><div id="search-results"></div></div>
</div>
<!-- HYPE CYCLE -->
<div id="tab-hype" class="hidden">
<div class="card" style="border-left:3px solid #FF8100;overflow-x:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;flex-wrap:wrap;gap:0.5rem">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">Optical Transceiver Hype Cycle <span style="color:#FF8100;font-size:0.85rem" id="hype-year">2026</span> <span style="font-size:0.62rem;font-weight:700;background:#6366f122;color:#6366f1;border:1px solid #6366f166;border-radius:3px;padding:1px 6px;letter-spacing:0.05em;vertical-align:middle">MODELL</span></div>
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Norton-Bass Multigenerational Diffusion Model &mdash; Adoption &amp; Composite Score sind mathematische Schätzungen, keine echten Marktdaten &mdash; click any technology for details</div>
</div>
<div style="display:flex;gap:1rem;align-items:center;font-size:0.68rem;color:var(--text-dim);flex-wrap:wrap">
<span id="hype-data-source" style="font-size:0.68rem;color:#34d399;font-weight:600"></span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#FF8100;margin-right:4px;vertical-align:middle"></span>Innovation</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#FFa030;margin-right:4px;vertical-align:middle"></span>Peak</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#c1121f;margin-right:4px;vertical-align:middle"></span>Trough</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#888888;margin-right:4px;vertical-align:middle"></span>Slope</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#444444;margin-right:4px;vertical-align:middle"></span>Plateau</span>
</div>
</div>
<div id="hype-svg-container"></div>
</div>
<div class="card mt">
<div class="table-wrap">
<table>
<thead><tr>
<th>Technology<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Cumulative market adoption based on Norton-Bass diffusion model. 0-100% of total addressable market. MODELL — keine echten Marktdaten.">Adoption <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
<th class="tip" data-tip="Estimated year of peak hype — Norton-Bass model estimate, not real forecast data.">Peak <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
<th class="tip" data-tip="Years until mainstream — Norton-Bass model estimate.">To Plateau <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
<th class="tip" data-tip="Current OEM ASP in USD — from Mouser/market data.">OEM ASP<span class="sort-arrow"></span></th>
<th class="tip" data-tip="Bass model goodness-of-fit (R²). Higher = more reliable forecast."><span class="sort-arrow"></span></th>
</tr></thead>
<tbody id="hype-table"></tbody>
</table>
</div>
</div>
<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim);display:flex;gap:1.5rem;flex-wrap:wrap">
<span><span style="color:#6366f1;font-weight:700">[M]</span> = MODELL — Norton-Bass mathematische Schätzung, keine echten Marktdaten</span>
<span>OEM ASP = real (Mouser/Marktdaten)</span>
</div>
<div class="card mt" style="border-left:3px solid var(--cyan)">
<div class="panel-section" style="margin-top:0">Methodology: How the Hype Cycle is Calculated</div>
<div style="font-size:0.82rem;color:var(--text-dim);line-height:1.65">
<p style="margin:0.5rem 0">This Hype Cycle uses the <strong>Norton-Bass Multigenerational Diffusion Model</strong> to calculate adoption curves for optical transceiver technologies. The model extends the classic Bass diffusion model to handle multiple generations of technology that compete and cannibalize each other.</p>
<p style="margin:0.5rem 0"><strong>Key Parameters:</strong></p>
<ul style="margin:0.3rem 0;padding-left:1.2rem">
<li><strong>p (innovation coefficient)</strong> &mdash; Rate of adoption driven by external influence (marketing, industry events). Typically 0.01&ndash;0.05 for B2B networking equipment.</li>
<li><strong>q (imitation coefficient)</strong> &mdash; Rate of adoption driven by word-of-mouth and peer influence. Typically 0.3&ndash;0.5 for optical networking.</li>
<li><strong>m (market potential)</strong> &mdash; Total addressable market size, estimated from port shipment forecasts (LightCounting, Dell&rsquo;Oro).</li>
<li><strong>τ (introduction time)</strong> &mdash; Year when the technology became commercially available.</li>
</ul>
<p style="margin:0.5rem 0"><strong>Phase Classification:</strong> Technologies are classified into five phases based on their position on the adoption curve: <span class="b" style="background:#FF810018;color:#FF8100;border:1px solid #FF810033">Innovation Trigger</span> (0&ndash;16%), <span class="b" style="background:#FFa03018;color:#FFa030;border:1px solid #FFa03033">Peak of Inflated Expectations</span> (16&ndash;35%), <span class="b" style="background:#c1121f18;color:#c1121f;border:1px solid #c1121f33">Trough of Disillusionment</span> (35&ndash;55%), <span class="b" style="background:#55555518;color:#555;border:1px solid #55555533">Slope of Enlightenment</span> (55&ndash;80%), and <span class="b" style="background:#00000018;color:#000;border:1px solid #00000033">Plateau of Productivity</span> (80&ndash;100%).</p>
<p style="margin:0.5rem 0"><strong>Composite Score:</strong> A weighted blend of adoption velocity (40%), market momentum from pricing data (30%), and ecosystem maturity from compatibility entries (30%). Score ranges from 0&ndash;100.</p>
<p style="margin:0.5rem 0"><strong>Data Sources:</strong> Adoption timing derived from IEEE/MSA standard ratification dates, market data from aggregated pricing across 60+ vendors, and compatibility data from 29,000+ verified switch-transceiver pairs.</p>
</div>
</div>
<!-- Sourcing Hype Cycle -->
<div class="card mt" style="border-left:3px solid #FF8100">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;flex-wrap:wrap;gap:0.5rem">
<div>
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">Sourcing Hype Cycle <span style="color:#FF8100;font-size:0.85rem">2026</span></div>
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Real procurement activity — price observation volume across 60+ vendors</div>
</div>
<div style="display:flex;gap:1rem;font-size:0.72rem;color:var(--text-dim);flex-wrap:wrap;align-items:center">
<span><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#4a4a5a;vertical-align:middle;margin-right:4px"></span>Discovery</span>
<span><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#1a7a4a;vertical-align:middle;margin-right:4px"></span>Ramp-Up</span>
<span><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#FF8100;vertical-align:middle;margin-right:4px"></span>Peak Demand</span>
<span><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#FFa030;vertical-align:middle;margin-right:4px"></span>Mature</span>
<span><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#cc3300;vertical-align:middle;margin-right:4px"></span>Commodity</span>
<span id="sourcing-hype-meta" style="color:var(--text-dim);font-size:0.7rem;opacity:0.7"></span>
</div>
</div>
<div id="sourcing-hype-chart" style="margin-top:0.75rem">
<div class="loading pulse" style="padding:2.5rem;text-align:center">Loading sourcing data…</div>
</div>
</div>
</div>
</div>
<!-- TRANSCEIVERS -->
<div id="tab-transceivers" class="hidden">
<div class="search-row">
<input type="text" id="tx-search" placeholder="Filter: 100G LR4, QSFP28, coherent, SMF...">
<select id="tx-ff-filter">
<option value="">All Form Factors</option>
<option value="SFP">SFP</option>
<option value="SFP+">SFP+</option>
<option value="SFP28">SFP28</option>
<option value="QSFP+">QSFP+</option>
<option value="QSFP28">QSFP28</option>
<option value="QSFP-DD">QSFP-DD</option>
<option value="OSFP">OSFP</option>
<option value="CFP">CFP</option>
<option value="CFP2">CFP2</option>
</select>
<select id="tx-vendor-filter">
<option value="">All Vendors</option>
</select>
<button class="btn" id="tx-search-btn">Search</button>
<button class="btn" id="tx-export-btn" style="background:var(--green);color:#fff" title="Export CSV">Export CSV</button>
<button class="btn" id="tx-compare-btn" style="background:var(--purple);color:#fff" title="Compare selected">Compare</button>
<input type="hidden" id="tx-verified-filter" value="">
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem">
<span id="tx-result-count" style="font-size:0.75rem;color:var(--text-dim)"></span>
<button id="tx-clear-filter" onclick="el('tx-search').value='';el('tx-ff-filter').value='';el('tx-vendor-filter').value='';el('tx-verified-filter').value='';searchTransceivers()" style="display:none;background:none;border:1px solid var(--border);padding:2px 10px;border-radius:6px;cursor:pointer;font-size:0.72rem;color:var(--text-dim)">✕ Clear filter</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price (USD)<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Verified</th></tr></thead>
<tbody id="tx-table"></tbody>
</table>
</div>
</div>
</div>
<!-- VENDORS -->
<div id="tab-vendors" class="hidden">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
<input type="text" id="vendor-search" placeholder="Search vendors..."
style="flex:1;min-width:200px;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem"
oninput="filterVendorCards()">
<select id="vendor-type-filter" onchange="filterVendorCards()"
style="padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="">All Types</option>
<option value="manufacturer">Manufacturer</option>
<option value="oem">OEM</option>
<option value="compatible">Compatible</option>
<option value="distributor">Distributor</option>
<option value="reseller">Reseller</option>
</select>
<span id="vendor-count" style="color:var(--text-dim);font-size:0.8rem"></span>
<button class="btn" onclick="openCreateVendorModal()" style="background:var(--accent);color:#fff;white-space:nowrap">+ New Vendor</button>
</div>
<div id="vendor-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:0.75rem">
<div class="loading pulse">Loading vendors…</div>
</div>
</div>
<!-- CREATE VENDOR MODAL -->
<div id="vendor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:2000;overflow-y:auto;padding:2rem 1rem">
<div style="background:var(--surface);border-radius:12px;max-width:640px;margin:0 auto;padding:2rem;border:1px solid var(--border);position:relative">
<button onclick="closeCreateVendorModal()" style="position:absolute;top:1rem;right:1rem;background:none;border:none;color:var(--text-dim);font-size:1.4rem;cursor:pointer;line-height:1">&times;</button>
<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright);margin-bottom:0.25rem">New Vendor — Set Card</div>
<div style="font-size:0.78rem;color:var(--text-dim);margin-bottom:1.5rem">After saving, auto-crawl will be queued for the vendor website.</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<!-- Name -->
<div style="grid-column:1/-1">
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Name <span style="color:var(--accent)">*</span></label>
<input id="cv-name" type="text" placeholder="e.g. InnoLight Technology" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Type -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Type <span style="color:var(--accent)">*</span></label>
<select id="cv-type" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="compatible">Compatible</option>
<option value="manufacturer">Manufacturer</option>
<option value="oem">OEM</option>
<option value="distributor">Distributor</option>
<option value="reseller">Reseller</option>
</select>
</div>
<!-- Founded Year -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Gründungsjahr</label>
<input id="cv-year" type="number" placeholder="e.g. 2005" min="1900" max="2030" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Website -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Website</label>
<input id="cv-website" type="url" placeholder="https://example.com" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Shop URL -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Shop URL</label>
<input id="cv-shopurl" type="url" placeholder="https://shop.example.com" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Headquarters -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Headquarters</label>
<input id="cv-hq" type="text" placeholder="e.g. Shenzhen, China" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Market Position / Schwerpunkt -->
<div style="grid-column:1/-1">
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Schwerpunkt (Market Position)</label>
<input id="cv-market" type="text" placeholder="e.g. High-speed coherent transceivers, 800G OSFP specialist" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Umsatz -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Umsatz (Revenue USD)</label>
<input id="cv-revenue" type="number" placeholder="e.g. 50000000" min="0" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Mitarbeiter -->
<div>
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Mitarbeiter (Employees)</label>
<input id="cv-employees" type="number" placeholder="e.g. 500" min="1" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Specialties -->
<div style="grid-column:1/-1">
<label style="font-size:0.75rem;font-weight:600;color:var(--text-dim);display:block;margin-bottom:4px">Specialties (comma-separated)</label>
<input id="cv-specialties" type="text" placeholder="e.g. 400G QSFP-DD, coherent, silicon photonics" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem;box-sizing:border-box">
</div>
<!-- Competitor toggle -->
<div style="grid-column:1/-1;display:flex;align-items:center;gap:0.6rem">
<input id="cv-competitor" type="checkbox" style="width:16px;height:16px;cursor:pointer">
<label for="cv-competitor" style="font-size:0.82rem;color:var(--text-dim);cursor:pointer">Mark as direct competitor to Flexoptix</label>
</div>
</div>
<div id="cv-crawl-notice" style="display:none;margin-top:1rem;padding:0.75rem 1rem;background:var(--accent)15;border:1px solid var(--accent)40;border-radius:8px;font-size:0.78rem;color:var(--accent)">
🕷 Auto-crawl wird beim Speichern gequeued — scrapers, LLM-Enrichment und Preismonitoring starten automatisch.
</div>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border)">
<button onclick="closeCreateVendorModal()" style="padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:none;color:var(--text-dim);cursor:pointer;font-size:0.85rem">Abbrechen</button>
<button onclick="submitCreateVendor()" id="cv-submit" style="padding:8px 24px;border:none;border-radius:8px;background:var(--accent);color:#fff;cursor:pointer;font-size:0.85rem;font-weight:600">Vendor anlegen</button>
</div>
</div>
</div>
<!-- STANDARDS -->
<div id="tab-standards" class="hidden">
<!-- Sourcing Activity Banner -->
<div id="sourcing-activity-banner" style="margin-bottom:1rem"></div>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
<input type="text" id="std-search" placeholder="Search standards (e.g. 400G, QSFP-DD, ZR)..."
style="flex:1;min-width:200px;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem"
oninput="filterStandardsTable()">
<select id="std-speed-filter" onchange="filterStandardsTable()"
style="padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="">All Speeds</option>
<option value="10">10G</option>
<option value="25">25G</option>
<option value="40">40G</option>
<option value="100">100G</option>
<option value="200">200G</option>
<option value="400">400G</option>
<option value="800">800G</option>
<option value="1600">1.6T</option>
</select>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr>
<th>Standard Name</th><th>Speed</th><th>Form Factor(s)</th>
<th>Max Reach</th><th>Fiber</th><th>Wavelength</th>
<th>IEEE Ref</th><th>Body · Year</th><th>Status</th><th>Transceivers</th>
</tr></thead>
<tbody id="std-table"><tr><td colspan="10" class="loading pulse">Loading…</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- SWITCHES -->
<div id="tab-switches" class="hidden">
<div class="search-row">
<input type="text" id="sw-search" placeholder="Filter: Nexus 9300, Arista 7060, QFX5130, 400G spine...">
<select id="sw-cat-filter">
<option value="">All Categories</option>
<option value="DataCenter">Data Center</option>
<option value="Campus">Campus</option>
<option value="Edge">Edge</option>
<option value="Core">Core</option>
<option value="SP">Service Provider</option>
<option value="Industrial">Industrial</option>
</select>
<button class="btn" id="sw-search-btn">Search</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead><tr><th style="width:52px;text-align:center">&#x1F5BC;</th><th>Model<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Series<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Ports<span class="sort-arrow"></span></th><th>Max Speed<span class="sort-arrow"></span></th><th>Capacity<span class="sort-arrow"></span></th><th>ASIC<span class="sort-arrow"></span></th><th>Status<span class="sort-arrow"></span></th></tr></thead>
<tbody id="sw-table"></tbody>
</table>
</div>
</div>
</div>
<!-- NEWS -->
<div id="tab-news" class="hidden">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
<select id="news-cat-filter" onchange="loadNews(1)"
style="padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="">All Categories</option>
</select>
<span id="news-meta" style="color:var(--text-dim);font-size:0.8rem"></span>
</div>
<div class="card"><div id="news-list"></div></div>
<div id="news-pagination" style="display:flex;justify-content:center;align-items:center;gap:0.5rem;margin-top:1rem;flex-wrap:wrap"></div>
</div>
<!-- FINDER -->
<div id="tab-finder" class="hidden">
<div style="margin-bottom:1.2rem">
<h3 style="font-size:1rem;font-weight:600;margin-bottom:0.3rem">Switch → Transceiver Finder <span style="font-size:0.7rem;color:var(--text-dim);font-weight:400">Find the right Flexoptix transceiver for your switch</span></h3>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<input id="finder-switch-input" type="text" placeholder="Enter switch model, e.g. N9K-C93180YC-FX3 or Nexus 93180..."
style="flex:1;min-width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.9rem"
onkeydown="if(event.key==='Enter') runFinder()">
<select id="finder-speed-filter" style="padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.85rem">
<option value="">All Speeds</option>
<option value="10">10G</option>
<option value="25">25G</option>
<option value="40">40G</option>
<option value="100">100G</option>
<option value="400">400G</option>
<option value="800">800G</option>
</select>
<button onclick="runFinder()" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:8px;cursor:pointer;font-weight:600;font-size:0.9rem">Find Transceivers</button>
</div>
<!-- Quick examples (use actual seeded models) -->
<div style="margin-top:0.5rem;display:flex;gap:0.4rem;flex-wrap:wrap">
<span style="font-size:0.7rem;color:var(--text-dim)">Quick:</span>
<button onclick="finderQuick('N9K-C9364C')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Cisco Nexus 9364C</button>
<button onclick="finderQuick('N9K-C93600CD-GX')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Nexus 93600CD-GX</button>
<button onclick="finderQuick('7060CX2-32S')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Arista 7060CX2</button>
<button onclick="finderQuick('QFX5130-32CD')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">Juniper QFX5130</button>
<button onclick="finderQuick('SN5600')" class="b b-dim" style="font-size:0.7rem;padding:2px 8px;border-radius:4px">NVIDIA SN5600</button>
</div>
</div>
<!-- Results area -->
<div id="finder-results"></div>
</div>
<!-- BLOG -->
<div id="tab-blog" class="hidden">
<!-- LLM ENGINE PANEL -->
<div class="card" style="margin-bottom:1.25rem;border-left:3px solid var(--accent)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.85rem">
<div style="display:flex;align-items:center;gap:0.6rem">
<span style="font-size:0.9rem;font-weight:700;color:var(--text-bright)">🤖 Blog Generation LLM</span>
<span id="blog-llm-status-badge" style="font-size:0.68rem;padding:2px 8px;border-radius:10px;background:var(--surface3);color:var(--text-dim);font-weight:600">loading…</span>
</div>
<button onclick="loadBlogLLMStatus()" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:3px 10px;border-radius:6px;cursor:pointer;font-size:0.72rem"></button>
</div>
<!-- Active model bar -->
<div style="background:var(--accent-glow);border:1px solid rgba(255,129,0,0.25);border-radius:8px;padding:0.55rem 0.85rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.75rem">
<span style="font-size:0.78rem;color:var(--text-dim);font-weight:600">AKTIV:</span>
<code id="blog-llm-active-model" style="font-size:0.82rem;color:var(--text-bright);font-weight:700"></code>
<span id="blog-llm-active-provider" style="font-size:0.68rem;padding:2px 7px;border-radius:4px;background:var(--accent);color:#fff;font-weight:700"></span>
<span id="blog-llm-queue" style="margin-left:auto;font-size:0.72rem;color:var(--text-dim)"></span>
</div>
<!-- Model cards -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.85rem">
<!-- Claude-Code (active — flat-rate via claude-bridge) -->
<div id="blog-model-card-cc" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🤖 claude-code</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">claude-bridge / Erik</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
<span style="font-size:0.62rem;padding:2px 6px;border-radius:3px;background:var(--accent);color:#fff;font-weight:700;white-space:nowrap">★ EMPFOHLEN</span>
<span id="blog-model-cc-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div>
</div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div>
<div style="color:#1a7a3a;font-weight:500">✓ Flat-rate (kein API-Billing)</div>
<div style="color:#1a7a3a;font-weight:500">✓ Claude Code Subscription</div>
</div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=claude-code<br>CLAUDE_BRIDGE_URL=http://localhost:3250</code>
</div>
<div id="blog-model-cc-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Claude API -->
<div id="blog-model-card-claude" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Anthropic API</div>
</div>
<span id="blog-model-claude-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Komplexe Multi-Constraint Prompts</div>
<div style="color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div>
<div style="color:#b45309;font-weight:500">⚠ API-Kosten pro Artikel</div>
</div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=anthropic<br>ANTHROPIC_MODEL=claude-sonnet-4-6</code>
</div>
<div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Fine-tuned local fo-blog-v5 -->
<div id="blog-model-card-fo" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v5</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div>
</div>
<span id="blog-model-fo-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★★</span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Fine-tuned auf TIP-Stil (v5)</div>
<div style="color:#1a7a3a;font-weight:500">✓ Lokal / keine API-Kosten</div>
<div style="color:#b45309;font-weight:500">⚠ Gelegentlicher Mode Collapse</div>
</div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=fo-blog-v5</code>
</div>
<div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Standard Qwen -->
<div id="blog-model-card-qwen" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">⚡ qwen2.5:14b</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div>
</div>
</div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★</span><span style="color:var(--text-dim)">★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Allzweck-Modell</div>
<div style="color:#1a7a3a;font-weight:500">✓ Keine API-Kosten</div>
<div style="color:#b45309;font-weight:500">⚠ Mode Collapse bei komplexen Prompts</div>
</div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=qwen2.5:14b</code>
</div>
<div id="blog-model-qwen-status" style="font-size:0.7rem;color:var(--text-dim)">local model</div>
</div>
</div><!-- end model grid -->
<!-- Config note -->
<div style="margin-top:0.85rem;padding:0.6rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.72rem;color:var(--text)">
<strong>Modell wechseln:</strong> SSH → Erik →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">nano /opt/tip/ecosystem.config.js</code>
→ BLOG_LLM_PROVIDER + CLAUDE_BRIDGE_URL / ANTHROPIC_API_KEY →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">pm2 restart tip-api --update-env</code>
</div>
</div><!-- end LLM panel -->
<div style="margin-bottom:0.8rem;display:flex;justify-content:space-between;align-items:center">
<h3 style="font-size:1rem;font-weight:600">Hot Topics <span id="hot-topics-subtitle" style="font-size:0.7rem;color:var(--text-dim);font-weight:400">auto-discovered from market data + conferences</span></h3>
<button onclick="loadHotTopics()" style="background:var(--accent);color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem">Refresh</button>
</div>
<div id="hot-topics-grid" class="grid g3 mb">
<div class="loading pulse">Loading topics...</div>
</div>
<div id="blog-pipeline-status"></div>
<!-- MANUAL GENERATION PANEL -->
<div class="card" style="margin-bottom:1.25rem;border:1px solid rgba(99,102,241,0.35);background:var(--surface2)">
<div style="font-size:0.85rem;font-weight:700;color:var(--text-bright);margin-bottom:0.75rem">✍️ Artikel manuell generieren</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-bottom:0.75rem">
<div>
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">Thema / Titel (optional)</label>
<input type="text" id="blog-custom-title" placeholder="z.B. Why 400G ZR+ Fails in Metro Deployments" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem;box-sizing:border-box">
<div style="font-size:0.65rem;color:var(--text-dim);margin-top:3px">Leer lassen = Thema aus Template. LLM generiert immer eine bessere Headline am Ende.</div>
</div>
<div>
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">Blog-Typ</label>
<select id="blog-manual-topic" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem">
<option value="technology_deep_dive">Technology Deep Dive</option>
<option value="tutorial">Troubleshooting Tutorial</option>
<option value="migration_guide">Migration Guide</option>
<option value="market_alert">Market Alert</option>
<option value="buying_guide">Buying Guide</option>
<option value="comparison">Product Comparison</option>
<option value="competitor_analysis">Competitor Analysis</option>
<option value="hype_cycle">Hype Cycle / Strategy</option>
</select>
</div>
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">
Kontext / Freitext <span style="color:#b45309;font-weight:600">(Hintergrundinfo — wird NIE wörtlich übernommen)</span>
</label>
<textarea id="blog-additional-context" rows="3" placeholder="Hintergrundinformationen, Kernaussage, konkrete Zahlen, Produkt-Highlights, Zielgruppe usw. &#10;Der LLM nutzt das als Leitfaden — kein Satz wird wörtlich kopiert. Headline wird aus dem fertigen Artikel generiert, nicht von hier." style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.8rem;resize:vertical;font-family:var(--body);line-height:1.5;box-sizing:border-box"></textarea>
</div>
<button onclick="generateBlogManual()" style="background:rgba(99,102,241,0.85);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:0.82rem;font-weight:600">⚙️ Artikel generieren</button>
</div><!-- end manual generation -->
<!-- SLL INSIGHTS WIDGET -->
<div class="card" style="margin-bottom:1rem;border:1px solid rgba(212,163,115,0.3);background:var(--surface2)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<div>
<span style="font-size:0.85rem;font-weight:600;color:var(--accent2)">🧠 Self-Learning Loop (SLL v1.0)</span>
<span id="sll-status-badge" style="margin-left:0.5rem;font-size:0.7rem;padding:2px 7px;border-radius:10px;background:rgba(100,100,100,0.3);color:var(--text-dim)">loading…</span>
</div>
<div style="display:flex;gap:0.5rem">
<button onclick="sllAnalyze()" id="sll-analyze-btn" style="background:rgba(212,163,115,0.2);color:var(--accent2);border:1px solid rgba(212,163,115,0.4);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem">⚡ Analyze Patterns</button>
<button onclick="loadSLLInsights()" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem"></button>
</div>
</div>
<div id="sll-insights-content" style="font-size:0.8rem;color:var(--text-dim)">Loading SLL data…</div>
<!-- Log Performance Modal Trigger -->
<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border)">
<span style="font-size:0.75rem;color:var(--text-dim)">Log LinkedIn engagement for a post:</span>
<button onclick="showSLLPerformanceForm()" style="margin-left:0.5rem;background:rgba(212,163,115,0.15);color:var(--accent2);border:1px solid rgba(212,163,115,0.3);padding:3px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem">+ Log Engagement</button>
</div>
<div id="sll-perf-form" style="display:none;margin-top:0.75rem">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0.5rem;margin-bottom:0.5rem">
<div>
<label style="font-size:0.7rem;color:var(--text-dim)">Comments</label>
<input type="number" id="sll-comments" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
</div>
<div>
<label style="font-size:0.7rem;color:var(--text-dim)">Shares</label>
<input type="number" id="sll-shares" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
</div>
<div>
<label style="font-size:0.7rem;color:var(--text-dim)">Saves</label>
<input type="number" id="sll-saves" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
</div>
<div>
<label style="font-size:0.7rem;color:var(--text-dim)">Impressions</label>
<input type="number" id="sll-impressions" min="0" placeholder="optional" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
</div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center">
<select id="sll-blog-select" style="flex:1;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.8rem">
<option value="">Select blog post…</option>
</select>
<button onclick="submitSLLPerformance()" style="background:var(--accent);color:white;border:none;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:0.8rem">Save</button>
<button onclick="document.getElementById('sll-perf-form').style.display='none'" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:5px 10px;border-radius:6px;cursor:pointer;font-size:0.8rem">Cancel</button>
</div>
</div>
</div>
<!-- POSTING TIME WIDGET -->
<div class="card" style="margin-bottom:1rem;border:1px solid rgba(99,102,241,0.3);background:var(--surface2)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<div>
<span style="font-size:0.85rem;font-weight:600;color:var(--accent)">📅 Beste Posting-Zeit</span>
<span id="posting-time-badge" style="margin-left:0.5rem;font-size:0.7rem;padding:2px 7px;border-radius:10px;background:rgba(100,100,100,0.3);color:var(--text-dim)">loading…</span>
</div>
<div style="display:flex;gap:0.5rem">
<button onclick="syncUmami()" id="umami-sync-btn" style="background:rgba(99,102,241,0.15);color:var(--accent);border:1px solid rgba(99,102,241,0.4);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem">↻ Umami</button>
<button onclick="loadPostingTime()" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem"></button>
</div>
</div>
<div id="posting-time-content" style="font-size:0.8rem;color:var(--text-dim)">Lade Posting-Zeit-Analyse…</div>
<!-- Best slot highlight — shown after blog generation -->
<div id="posting-time-highlight" style="display:none;margin-top:0.75rem;padding:0.75rem;border-radius:8px;background:rgba(99,102,241,0.12);border:1px solid rgba(99,102,241,0.35)">
<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.25rem">📝 Empfehlung für diesen Artikel</div>
<div id="posting-time-recommended" style="font-size:1.05rem;font-weight:700;color:var(--accent)"></div>
<div id="posting-time-reason" style="font-size:0.72rem;color:var(--text-dim);margin-top:3px"></div>
</div>
</div>
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
</div>
<!-- PROCUREMENT INTEL TAB -->
<div id="tab-procurement" class="hidden">
<!-- Sub-nav -->
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">
<button onclick="showProcSection('signals')" id="proc-btn-signals" class="proc-btn proc-btn-active">Reorder Signals</button>
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
<div style="flex:1"></div>
<button onclick="loadProcurement()" style="background:var(--surface2);border:1px solid var(--border);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem;color:var(--text)">↻ Refresh</button>
</div>
<!-- Reorder Signals section -->
<div id="proc-section-signals">
<div style="padding:0.5rem 0.75rem;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:6px;font-size:0.72rem;color:#f59e0b;margin-bottom:0.75rem"><strong>DEMO DATA</strong> — Reorder Signals basieren auf synthetischen Verkaufs- und Lagermengen. Signalstärke spiegelt keine echten Flexoptix-Bestände wider.</div>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button onclick="filterSignal('')" id="sig-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
<button onclick="filterSignal('buy_now')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🔴 Buy Now</button>
<button onclick="filterSignal('wait')" style="background:rgba(255,160,0,0.1);border:1px solid rgba(255,160,0,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🟡 Wait</button>
<button onclick="filterSignal('hold')" style="background:rgba(45,106,79,0.1);border:1px solid rgba(45,106,79,0.3);color:#2d6a4f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🟢 Hold</button>
<button onclick="filterSignal('monitor')" style="background:rgba(124,92,252,0.1);border:1px solid rgba(124,92,252,0.3);color:#7c5cfc;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🔵 Monitor</button>
</div>
<div id="proc-signals-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))">
<div class="loading pulse">Loading reorder signals...</div>
</div>
</div>
<!-- ABC Classification section -->
<div id="proc-section-abc" style="display:none">
<div style="padding:0.5rem 0.75rem;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:6px;font-size:0.72rem;color:#f59e0b;margin-bottom:0.75rem"><strong>DEMO DATA</strong> — ABC-Klassifizierung basiert auf synthetischen Verkaufszahlen aus dem Seed-Datensatz. Keine echten Flexoptix-Umsätze.</div>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button onclick="filterAbc('')" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
<button onclick="filterAbc('A')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">A — High Turnover</button>
<button onclick="filterAbc('B')" style="background:rgba(255,160,0,0.1);border:1px solid rgba(255,160,0,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">B — Medium</button>
<button onclick="filterAbc('C')" style="background:rgba(136,136,136,0.12);border:1px solid #ddd;color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">C — Low</button>
</div>
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.8rem" id="abc-table">
<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">
<th class="tip" data-tip="ABC inventory classification: A = high turnover / high value (top 20% products, ~80% of revenue). B = medium. C = low turnover / low value." style="text-align:left;padding:8px 6px">Class</th>
<th class="tip" data-tip="Transceiver product name, part number and vendor." style="text-align:left;padding:8px 6px">Product</th>
<th class="tip" data-tip="Physical form factor: SFP, SFP+, QSFP28, QSFP-DD, OSFP, CFP, etc. Determines physical slot compatibility in switches." style="text-align:left;padding:8px 6px">Form Factor</th>
<th class="tip" data-tip="Composite demand score (0100). Combines: price observation frequency, compatibility entry count, vendor count, hype cycle phase, and recent pricing activity." style="text-align:right;padding:8px 6px">Demand Score</th>
<th class="tip" data-tip="Number of compatibility entries — how many switch models support this transceiver. Higher = broader market reach and easier to sell." style="text-align:right;padding:8px 6px">Compat.</th>
<th class="tip" data-tip="Number of vendors offering this transceiver. More vendors = stronger competition = typically lower prices and better availability." style="text-align:right;padding:8px 6px">Vendors</th>
<th class="tip" data-tip="Supply chain risk level. High = single-source or constrained supply. Medium = some alternatives exist. Low = widely available from multiple sources." style="text-align:left;padding:8px 6px">Supply Risk</th>
<th class="tip" data-tip="Procurement recommendation: 🔴 Buy Now = stock up, prices rising or supply tightening. 🟡 Wait = prices expected to drop. 🟢 Hold = stable, no action needed. 🔵 Monitor = watch for changes." style="text-align:left;padding:8px 6px">Signal</th>
</tr></thead>
<tbody id="abc-tbody"><tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">Loading...</td></tr></tbody>
</table>
</div>
</div>
<!-- Market Intelligence section -->
<div id="proc-section-market" style="display:none">
<div id="proc-market-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(400px,1fr))">
<div class="loading pulse">Loading market intelligence...</div>
</div>
</div>
<!-- Lifecycle Events section -->
<div id="proc-section-lifecycle" style="display:none">
<div id="proc-lifecycle-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(400px,1fr))">
<div class="loading pulse">Loading lifecycle events...</div>
</div>
</div>
</div><!-- end tab-procurement -->
<!-- CRAWLER INTELLIGENCE -->
<div id="tab-crawlers" class="hidden fade-in">
<h2 style="margin-bottom:1.25rem;font-size:1.1rem;font-weight:700">🕷 Crawler Intelligence</h2>
<!-- Summary Cards -->
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-icon blue">📦</div>
<div class="stat-label">Transceivers in DB</div>
<div class="stat-val" id="cr-transceivers"></div>
</div>
<div class="stat-card">
<div class="stat-icon green">💶</div>
<div class="stat-label">Price Records</div>
<div class="stat-val" id="cr-prices"></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">🏪</div>
<div class="stat-label">Vendors Tracked</div>
<div class="stat-val" id="cr-vendors"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">📰</div>
<div class="stat-label">News Articles</div>
<div class="stat-val" id="cr-news"></div>
</div>
</div>
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:2rem">
<div class="stat-card">
<div class="stat-icon green">🧠</div>
<div class="stat-label">KB Entries</div>
<div class="stat-val" id="cr-kb"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">💾</div>
<div class="stat-label">DB Size</div>
<div class="stat-val" id="cr-dbsize"></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div class="stat-label">Active Scrapers</div>
<div class="stat-val" id="cr-active"></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">🕐</div>
<div class="stat-label">Last Price Update</div>
<div class="stat-val" style="font-size:0.8rem" id="cr-lastprice"></div>
</div>
</div>
<!-- Live Job Queue -->
<div style="margin-bottom:2rem">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<h3 style="font-size:0.9rem;font-weight:700;color:var(--text-bright)">⚡ Live Job Queue</h3>
<span id="cr-live-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 6px #22c55e;animation:pulse 2s infinite"></span>
<span id="cr-active-jobs-count" style="font-size:0.75rem;color:var(--text-dim)">Loading…</span>
<button onclick="loadCrawlerJobs()" style="margin-left:auto;font-size:0.72rem;padding:2px 10px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text-dim);cursor:pointer">↻ Refresh</button>
</div>
<div id="cr-live-jobs"><div style="color:var(--text-dim)">Loading job queue…</div></div>
<div style="margin-top:1rem">
<h4 style="font-size:0.8rem;font-weight:700;color:var(--text-dim);margin-bottom:0.6rem">Recent (last 2h)</h4>
<div id="cr-recent-jobs"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
</div>
<!-- Scraper Status List -->
<div style="margin-bottom:2rem">
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:1rem;color:var(--text-bright)">Scraper Status</h3>
<div id="cr-scraper-list"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
<!-- LLM Hot Topics -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div>
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:0.75rem;color:var(--text-bright)">🔥 LLM Hot Topics</h3>
<div id="cr-topics"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
<div>
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:0.75rem;color:var(--text-bright)">📚 Knowledge Base (Learned)</h3>
<div id="cr-kb-entries"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
</div>
</div><!-- end tab-crawlers -->
<!-- SELFLEARNING -->
<div id="tab-selflearning" class="hidden fade-in">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1.25rem">
<div>
<h2 style="margin:0 0 0.35rem;font-size:1.1rem;font-weight:800;color:var(--text-bright)">Selflearning Control Center</h2>
<div style="font-size:0.8rem;color:var(--text-dim)">Getrennte Trainingsketten fuer TIP_LLM und Blog_LLM: Pool bauen, deduplizieren, HF syncen, lokal oder RunPod starten.</div>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;justify-content:flex-end">
<button onclick="loadSelflearning()" class="b b-dim" style="padding:7px 12px;border-radius:7px;cursor:pointer">Refresh</button>
<button onclick="buildSelflearningPool()" style="background:var(--accent);color:#fff;border:none;padding:7px 12px;border-radius:7px;cursor:pointer;font-weight:700">Build Pool</button>
<button onclick="publishSelflearningHF()" style="background:#2563eb;color:#fff;border:none;padding:7px 12px;border-radius:7px;cursor:pointer;font-weight:700">Publish HF</button>
</div>
</div>
<div id="selflearning-status-banner" class="card" style="margin-bottom:1rem;border-left:3px solid var(--accent);font-size:0.82rem;color:var(--text-dim)">Loading selflearning status...</div>
<div class="grid mb" style="grid-template-columns:repeat(2,1fr);gap:1rem">
<div class="card">
<div style="display:flex;justify-content:space-between;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
<div><div style="font-size:0.88rem;font-weight:800;color:var(--text-bright)">TIP_LLM_Vx.x</div><div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Research, Crawler, Wettbewerbsdaten, Vendor-/Market-Intelligence</div></div>
<span id="sl-tip-state" class="b b-blue">unknown</span>
</div>
<div id="sl-tip-metrics" style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-bottom:0.75rem"></div>
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.5rem">Dataset: <code id="sl-tip-dataset">-</code></div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button onclick="startSelflearningTrain('tip_llm','runpod',true)" style="background:#0f766e;color:#fff;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">RunPod Seed</button>
<button onclick="startSelflearningTrain('tip_llm','runpod',false)" style="background:#b45309;color:#fff;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">RunPod Full</button>
<button onclick="startSelflearningTrain('tip_llm','local',true)" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">Local Train</button>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
<div><div style="font-size:0.88rem;font-weight:800;color:var(--text-bright)">Blog_LLM_Vx.x</div><div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">FO_BlogLLM, Founder Content, technische TIP-/Flexoptix-Artikel</div></div>
<span id="sl-blog-state" class="b b-blue">unknown</span>
</div>
<div id="sl-blog-metrics" style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-bottom:0.75rem"></div>
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.5rem">Dataset: <code id="sl-blog-dataset">-</code></div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button onclick="startSelflearningTrain('blog_llm','runpod',true)" style="background:#0f766e;color:#fff;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">RunPod Seed</button>
<button onclick="startSelflearningTrain('blog_llm','runpod',false)" style="background:#b45309;color:#fff;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">RunPod Full</button>
<button onclick="startSelflearningTrain('blog_llm','local',true)" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem;font-weight:700">Local Train</button>
</div>
</div>
</div>
<div class="card">
<div style="font-size:0.82rem;font-weight:800;color:var(--text-bright);margin-bottom:0.6rem">Training Log</div>
<pre id="selflearning-log" style="margin:0;max-height:260px;overflow:auto;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.8rem;font-size:0.72rem;color:var(--text);white-space:pre-wrap">Noch kein Lauf in dieser Dashboard-Session.</pre>
</div>
</div><!-- end tab-selflearning -->
<!-- NETWORK TAB -->
<div id="tab-network" class="hidden fade-in">
<h2 style="margin-bottom:1.25rem;font-size:1.1rem;font-weight:700">&#127760; TIP Proxy Network</h2>
<!-- Stats bar -->
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-icon green">&#128225;</div>
<div class="stat-label">Nodes Online</div>
<div class="stat-val" id="pn-online"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">&#127758;</div>
<div class="stat-label">Countries</div>
<div class="stat-val" id="pn-countries"></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">&#9889;</div>
<div class="stat-label">GB Proxied Today</div>
<div class="stat-val" id="pn-gb"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">&#128200;</div>
<div class="stat-label">Total Requests</div>
<div class="stat-val" id="pn-requests"></div>
</div>
</div>
<!-- Join the Network card -->
<div class="card mb" style="margin-bottom:1.5rem;border-left:3px solid var(--accent)">
<div class="card-header">Join the Network</div>
<p style="color:var(--text-dim);font-size:0.85rem;margin-bottom:1.25rem">
Run a node agent, donate bandwidth, get free TIP API access.
Your node routes scraper traffic through your residential IP — bypassing datacenter IP bans.
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
<div>
<div style="font-size:0.75rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.5rem">Install &amp; Start</div>
<div id="pn-install-box" style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-md);padding:0.75rem 1rem;font-family:var(--mono);font-size:0.8rem;color:var(--text-bright);margin-bottom:0.75rem;cursor:pointer;position:relative" onclick="copyInstallCmd(this)" title="Click to copy">
<span id="pn-install-cmd">npx @tip/proxy-agent start --token YOUR_TOKEN</span>
<span style="position:absolute;right:0.75rem;top:50%;transform:translateY(-50%);font-size:0.7rem;color:var(--text-dim)">copy</span>
</div>
<button id="pn-gen-token-btn" onclick="generateProxyToken()" style="background:var(--accent);color:#fff;border:none;border-radius:var(--radius-md);padding:0.5rem 1.25rem;font-size:0.8rem;font-weight:600;cursor:pointer;transition:background 0.2s" onmouseover="this.style.background='var(--accent-dark)'" onmouseout="this.style.background='var(--accent)'">
Generate Token
</button>
<div id="pn-token-result" style="display:none;margin-top:0.75rem;padding:0.6rem 0.75rem;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.25rem">Your token (save this!):</div>
<div id="pn-token-val" style="font-family:var(--mono);font-size:0.75rem;color:var(--accent);word-break:break-all"></div>
</div>
</div>
<div>
<div style="font-size:0.75rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.75rem">Benefits</div>
<ul style="list-style:none;display:flex;flex-direction:column;gap:0.5rem;font-size:0.85rem">
<li style="display:flex;align-items:center;gap:0.5rem"><span style="color:var(--accent);font-size:1rem">&#10003;</span> Free TIP API access (no token required)</li>
<li style="display:flex;align-items:center;gap:0.5rem"><span style="color:var(--accent);font-size:1rem">&#10003;</span> Contributor badge on your profile</li>
<li style="display:flex;align-items:center;gap:0.5rem"><span style="color:var(--accent);font-size:1rem">&#10003;</span> Early access to new TIP features</li>
<li style="display:flex;align-items:center;gap:0.5rem"><span style="color:var(--accent);font-size:1rem">&#10003;</span> Configurable bandwidth limit (default 10 GB)</li>
</ul>
</div>
</div>
</div>
<!-- Node list table -->
<div class="card">
<div class="card-header">Active Nodes</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:left;padding:8px 10px">Country / City</th>
<th style="text-align:left;padding:8px 10px">Name</th>
<th style="text-align:center;padding:8px 10px">Status</th>
<th style="text-align:right;padding:8px 10px">Uptime</th>
<th style="text-align:right;padding:8px 10px">Bandwidth</th>
<th style="text-align:right;padding:8px 10px">Requests</th>
<th style="text-align:right;padding:8px 10px">Latency</th>
<th style="text-align:right;padding:8px 10px">Last Seen</th>
</tr>
</thead>
<tbody id="pn-node-table"><tr><td colspan="8" style="padding:1rem;color:var(--text-dim);text-align:center">Loading…</td></tr></tbody>
</table>
</div>
</div>
</div><!-- end tab-network -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- REVIEW TAB — Manual equivalence review queue -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div id="tab-review" class="hidden fade-in">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div>
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">&#9998; Manual Review Queue</h2>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Uncertain competitor equivalence matches — approve or reject to update ★ Fully Verified status</p>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<div id="review-stat-pills" style="display:flex;gap:0.4rem"></div>
<button onclick="bulkApproveHighConfidence()" id="bulk-approve-btn" class="btn" style="background:#22c55e;color:#fff;font-size:0.75rem">
✓ Bulk-Approve ≥73%
</button>
<button onclick="approveAll()" id="approve-all-btn" class="btn" style="background:#f97316;color:#fff;font-size:0.75rem">
⚡ Approve All Pending
</button>
<button onclick="runEquivalenceMatcher()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">
▶ Run Matcher Now
</button>
</div>
</div>
<!-- Filter pills -->
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button class="review-filter-btn active" data-rfilter="pending" onclick="setReviewFilter('pending')" style="padding:4px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:0.75rem;color:var(--text)">Pending</button>
<button class="review-filter-btn" data-rfilter="auto_approved" onclick="setReviewFilter('auto_approved')" style="padding:4px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:0.75rem;color:var(--text)">Auto-Approved</button>
<button class="review-filter-btn" data-rfilter="approved" onclick="setReviewFilter('approved')" style="padding:4px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:0.75rem;color:var(--text)">Approved</button>
<button class="review-filter-btn" data-rfilter="rejected" onclick="setReviewFilter('rejected')" style="padding:4px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:0.75rem;color:var(--text)">Rejected</button>
<button class="review-filter-btn" data-rfilter="all" onclick="setReviewFilter('all')" style="padding:4px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:0.75rem;color:var(--text)">All</button>
<button class="review-filter-btn" id="filter-needs-research-btn" data-rfilter="needs_research" onclick="setReviewFilter('needs_research')" style="padding:4px 14px;border-radius:20px;border:1px solid #f59e0b;background:var(--surface2);cursor:pointer;font-size:0.75rem;color:#f59e0b">⏳ Re-Research <span id="needs-research-badge" style="display:none;background:#f59e0b;color:#fff;border-radius:10px;padding:0px 6px;font-size:0.65rem;font-weight:700;margin-left:2px">0</span></button>
</div>
<div id="review-list" style="display:flex;flex-direction:column;gap:0.75rem"></div>
<div id="review-empty" style="display:none;text-align:center;padding:3rem;color:var(--text-dim);font-size:0.85rem">
No items in this queue.
</div>
<div id="review-load-more" style="display:none;text-align:center;margin-top:1rem">
<button onclick="loadReviewPage(reviewState.page+1)" class="btn">Load more</button>
</div>
</div><!-- end tab-review -->
<!-- STOCK TAB -->
<div id="tab-stock" class="hidden fade-in">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div>
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Preise: real (scraped from fs.com · QSFPTEK · NADDOD &amp; more) · Abverkauf: <span style="color:#22c55e;font-weight:600">✅ echte Flexoptix-Verkaufszahlen (intern)</span> · <span style="color:#f59e0b">⚠ Scraper-Lagermengen: DEMO DATA</span></p>
</div>
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
</div>
<!-- Summary stat cards -->
<div class="grid mb" style="grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.25rem" id="stock-stat-cards">
<div class="stat-card" style="text-align:center">
<div class="stat-icon blue">📦</div>
<div class="stat-label">Total SKUs tracked</div>
<div class="stat-val" id="stock-stat-skus"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon green"></div>
<div class="stat-label">In Stock</div>
<div class="stat-val" id="stock-stat-instock"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#6366f1">🇩🇪</div>
<div class="stat-label">DE-Lager Total <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div>
<div class="stat-val" id="stock-stat-de"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#06b6d4">🌍</div>
<div class="stat-label">Global-Lager Total <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div>
<div class="stat-val" id="stock-stat-global"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#f59e0b"></div>
<div class="stat-label">In Nachlieferung <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div>
<div class="stat-val" id="stock-stat-backorder"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#22c55e">🔀</div>
<div class="stat-label">Multi-Vendor SKUs</div>
<div class="stat-val" id="stock-stat-multiv"></div>
</div>
</div>
<!-- 🔥 Flexoptix Sales Velocity (REAL DATA) -->
<div class="card" style="overflow:hidden;margin-bottom:1.5rem">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">
🔥 Flexoptix Sales Velocity — Abverkauf nach Technologie
<span style="font-size:0.65rem;font-weight:700;background:#16a34a22;color:#22c55e;border:1px solid #22c55e66;border-radius:3px;padding:1px 6px;letter-spacing:0.05em">REAL DATA</span>
<span style="margin-left:auto;font-size:0.68rem;color:var(--text-dim);font-weight:400">Quelle: internes XLSX-Export · 8.585 SKUs · AES-256 verschlüsselt</span>
</div>
<div style="padding:0.35rem 1rem;background:#16a34a11;border-bottom:1px solid #22c55e33;font-size:0.7rem;color:#22c55e">
✅ Echte Flexoptix-Verkaufszahlen (Bedarf/Monat) — aggregiert nach Technologie, keine einzelnen SKUs sichtbar
</div>
<!-- Stat summary row -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0;border-bottom:1px solid var(--border)" id="foxd-summary-row">
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Total SKUs mit Bedarf</div>
<div style="font-size:1.1rem;font-weight:700;color:#22c55e" id="foxd-stat-skus"></div>
</div>
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Gesamtbedarf/Monat (12M)</div>
<div style="font-size:1.1rem;font-weight:700;color:#6366f1" id="foxd-stat-demand12"></div>
</div>
<div style="padding:0.6rem 1rem;border-right:1px solid var(--border);text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Gesamtbedarf/Monat (3M)</div>
<div style="font-size:1.1rem;font-weight:700;color:#06b6d4" id="foxd-stat-demand3"></div>
</div>
<div style="padding:0.6rem 1rem;text-align:center">
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:0.2rem">Momentum (3M/12M)</div>
<div style="font-size:1.1rem;font-weight:700" id="foxd-stat-momentum"></div>
</div>
</div>
<!-- Demand by technology table -->
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="foxd-by-speed-table">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Technologie</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">SKUs</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Monat (12M) ▼</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Monat (3M)</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Momentum</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Trend</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Fast Movers</th>
</tr>
</thead>
<tbody id="foxd-by-speed-body">
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr>
</tbody>
</table>
</div>
<!-- Velocity class bar -->
<div style="padding:0.75rem 1rem;border-top:1px solid var(--border)">
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.4rem;font-weight:500">Velocity-Klassen (gesamt 8.585 SKUs)</div>
<div id="foxd-velocity-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<span style="color:var(--text-dim);font-size:0.72rem">Wird geladen…</span>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
<!-- Top Sellers -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">📊 Top Sellers (by units sold) <span style="font-size:0.65rem;font-weight:700;background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b66;border-radius:3px;padding:1px 6px;letter-spacing:0.05em">SCRAPER DATA</span></div>
<div style="padding:0.35rem 1rem;background:#f59e0b11;border-bottom:1px solid #f59e0b33;font-size:0.7rem;color:#f59e0b">⚠ Verkauft-Zahlen von Wettbewerbern (fs.com) — nicht Flexoptix-intern</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-top-sellers">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form Factor</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Verkauft <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Global-Lager <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Preis (net)</th>
</tr>
</thead>
<tbody id="stock-top-sellers-body">
<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet — waiting for first scrape run</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Vendor Breakdown -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright)">🏪 Vendor Breakdown</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-vendor-table">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Vendor</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">SKUs</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">In Stock</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Global</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Backorder</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500" title="Stock data quality: L3=per-warehouse, L2=aggregated, L1=boolean">Quality</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Last Scraped</th>
</tr>
</thead>
<tbody id="stock-vendor-body">
<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Recently Restocked -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">🆕 Recently Restocked (last 24h) <span style="font-size:0.65rem;font-weight:700;background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b66;border-radius:3px;padding:1px 6px">DEMO DATA</span></div>
<div id="stock-recent" style="padding:1rem;color:var(--text-dim);font-size:0.8rem">No recent restock events</div>
</div>
<!-- Multi-Vendor Price Comparison -->
<div class="card" style="overflow:hidden;margin-top:1rem">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright)">🔀 Multi-Vendor Price Comparison <span style="font-weight:400;color:var(--text-dim);font-size:0.75rem">— SKUs tracked by 2+ vendors</span></div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem" id="stock-price-compare-table">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Vendors</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Min Price</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Max Price</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Avg Price</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Vendors (low → high)</th>
</tr>
</thead>
<tbody id="stock-price-compare-body">
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No multi-vendor data yet</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Search by Part Number -->
<div class="card" style="margin-top:1rem;padding:1rem">
<div style="font-size:0.85rem;font-weight:600;color:var(--text-bright);margin-bottom:0.75rem">🔍 Lookup Stock by Part Number</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem">
<input id="stock-lookup-input" type="text" placeholder="e.g. SFP-10G-SR, QSFP-40G-LR4 …"
style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface2);color:var(--text);font-size:0.8rem"
onkeydown="if(event.key==='Enter')lookupStock()">
<button onclick="lookupStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.8rem">Look Up</button>
</div>
<div id="stock-lookup-result" style="font-size:0.78rem;color:var(--text-dim)"></div>
</div>
</div><!-- end tab-stock -->
<!-- PRICE COMPARISON -->
<div id="tab-prices" class="hidden fade-in">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div>
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">💲 Price Comparison — Optical Transceiver Market</h2>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Live pricing across 20+ vendors · Updated every 28h · No authentication required</p>
</div>
<button onclick="pricesLoaded=false;loadPriceComparison()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
</div>
<!-- Summary stat cards -->
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.25rem">
<div class="stat-card" style="text-align:center">
<div class="stat-icon blue">📊</div>
<div class="stat-label">SKUs Tracked</div>
<div class="stat-val" id="pc-stat-skus"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#6366f1">🏪</div>
<div class="stat-label">Active Vendors</div>
<div class="stat-val" id="pc-stat-vendors"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#22c55e">📋</div>
<div class="stat-label">Price Observations</div>
<div class="stat-val" id="pc-stat-obs"></div>
</div>
<div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#f59e0b">💵</div>
<div class="stat-label">Overall Avg Price</div>
<div class="stat-val" id="pc-stat-avg"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1.6fr;gap:1rem;margin-bottom:1.5rem">
<!-- By Form Factor -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright)">By Form Factor</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem">
<thead>
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form Factor</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">SKUs</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Vendors</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Min</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Avg</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Max</th>
</tr>
</thead>
<tbody id="pc-ff-body">
<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Top 50 SKUs -->
<div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright)">Top 50 SKUs by Vendor Coverage</div>
<div style="overflow-x:auto;max-height:340px">
<table style="width:100%;border-collapse:collapse;font-size:0.75rem">
<thead style="position:sticky;top:0;z-index:1;background:var(--surface2)">
<tr>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">SKU / Standard Name</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">FF</th>
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Speed</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Vendors</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Min</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Avg</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Spread</th>
</tr>
</thead>
<tbody id="pc-top-body">
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- SKU Detail Lookup -->
<div class="card" style="padding:1rem">
<div style="font-size:0.85rem;font-weight:600;color:var(--text-bright);margin-bottom:0.75rem">🔍 SKU Price Lookup</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.75rem">
<input id="pc-lookup-input" type="text" placeholder="e.g. SFP-10G-SR, QSFP-40G-LR4 …"
style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface2);color:var(--text);font-size:0.8rem"
onkeydown="if(event.key==='Enter')lookupPriceComparison()">
<button onclick="lookupPriceComparison()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.8rem">Look Up</button>
</div>
<div id="pc-lookup-result" style="font-size:0.78rem;color:var(--text-dim)"></div>
</div>
</div><!-- end tab-prices -->
</div>
</div><!-- .app -->
<!-- DETAIL PANEL -->
<div id="detail-panel" class="panel">
<button class="panel-close" id="panel-close">&times;</button>
<div id="panel-content"></div>
</div>
<!-- COMPARE OVERLAY -->
<div class="compare-overlay" id="compare-overlay">
<div id="compare-content"></div>
</div>
<script>
var API = window.location.origin;
// Auth helpers — use obfuscated token helpers (defined in auth guard above)
function getAuthHeaders() {
var token = window.loadToken ? window.loadToken() : '';
return token ? { 'Authorization': 'Bearer ' + token } : {};
}
function handleAuthError(status) {
if (status === 401) {
if (window.clearToken) window.clearToken();
window.location.replace('/dashboard/login.html');
}
}
// ── FX Rates — USD is lead currency ───────────────────────────────────────
var FX = { EUR: 0.92, GBP: 0.79, CNY: 7.25, JPY: 151.0, CHF: 0.90, CAD: 1.36, AUD: 1.53 };
(function loadFxRates() {
fetch('https://open.er-api.com/v6/latest/USD')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.rates) {
['EUR','GBP','CNY','JPY','CHF','CAD','AUD'].forEach(function(c) {
if (d.rates[c]) FX[c] = d.rates[c];
});
}
}).catch(function() {}); // silent fallback to hardcoded rates
})();
/** Convert any amount/currency → USD */
function toUSD(amount, currency) {
if (!currency || currency === 'USD') return amount;
var rate = FX[currency];
return rate ? amount / rate : null;
}
/** Convert any amount/currency → EUR */
function toEUR(amount, currency) {
if (!currency || currency === 'EUR') return amount;
var usd = toUSD(amount, currency);
return usd !== null ? usd * FX.EUR : null;
}
/** Format a price value as USD string */
function fmtUSD(v) { return 'USD\u00a0' + parseFloat(v).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2}); }
/** Format a price value as EUR string */
function fmtEUR(v) { return 'EUR\u00a0' + parseFloat(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}); }
function esc(str) {
if (str == null) return '';
var d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
function el(id) { return document.getElementById(id); }
function api(path, opts) {
var fetchOpts = Object.assign({}, opts || {});
fetchOpts.headers = Object.assign({}, getAuthHeaders(), (opts && opts.headers) || {});
return fetch(API + path, fetchOpts).then(function(r) {
if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); }
var ct = r.headers.get('content-type') || '';
if (ct.indexOf('application/json') === -1) {
if (!r.ok) throw new Error('HTTP ' + r.status);
throw new Error('Server returned non-JSON response');
}
// Parse JSON even on error so callers can read error.message / error.suggestion
return r.json().then(function(body) {
if (!r.ok) {
var err = new Error(body.error || ('HTTP ' + r.status));
err.body = body;
throw err;
}
return body;
});
});
}
function showToast(title, body, isError) {
var t = el('toast');
t.querySelector('.toast-title').textContent = title;
t.querySelector('.toast-body').textContent = body;
t.className = 'toast' + (isError ? ' error' : '');
void t.offsetWidth;
t.classList.add('show');
clearTimeout(showToast._timer);
showToast._timer = setTimeout(function() { t.classList.remove('show'); }, 4000);
}
function buildDOM(parent, html) {
parent.textContent = '';
var t = document.createElement('template');
t.innerHTML = html;
parent.appendChild(t.content.cloneNode(true));
}
// Temperature range code → human-readable display
function tempRangeDisplay(code) {
if (!code) return null;
var map = { 'COM': '0 70 °C (COM)', 'IND': '-40 85 °C (IND)', 'EXT': '-40 70 °C (EXT)' };
return map[code] || code;
}
// Format observed date as "DD.MM.YYYY"
function fmtDate(iso) {
if (!iso) return '';
var d = new Date(iso);
return d.getDate().toString().padStart(2,'0') + '.' + (d.getMonth()+1).toString().padStart(2,'0') + '.' + d.getFullYear();
}
// Build a human-readable descriptive product name from available fields
function txDescName(t) {
// Use description field if populated and meaningful (not just the SKU)
if (t.description && t.description.length > 10 && t.description !== t.standard_name && t.description !== t.slug) {
return t.description;
}
// Construct from specs
var parts = [];
if (t.speed) parts.push(t.speed);
if (t.form_factor) parts.push(t.form_factor);
if (t.reach_label) parts.push(t.reach_label);
else if (t.reach_meters) parts.push(t.reach_meters + ' km');
if (t.wavelengths) parts.push('\u03bb' + t.wavelengths); // λ
if (t.connector) parts.push(t.connector);
if (t.fiber_type) parts.push(t.fiber_type);
return parts.join(', ') || '';
}
function openPanel(html) {
var p = el('detail-panel');
buildDOM(el('panel-content'), html);
p.classList.add('open');
}
function closePanel() { el('detail-panel').classList.remove('open'); }
el('panel-close').addEventListener('click', closePanel);
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closePanel(); });
function animateValue(el, target, duration) {
if (!el || isNaN(target)) { if (el) el.textContent = target; return; }
var start = 0, startTime = null;
target = parseInt(target);
function step(ts) {
if (!startTime) startTime = ts;
var p = Math.min((ts - startTime) / duration, 1);
p = 1 - Math.pow(1 - p, 3);
el.textContent = Math.floor(p * target).toLocaleString();
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Use case classification
function classifyUseCase(t) {
var useCases = [];
var speed = parseFloat(t.speed_gbps) || 0;
var reach = parseInt(t.reach_meters) || 0;
var ff = (t.form_factor || '').toUpperCase();
var fiber = (t.fiber_type || '').toUpperCase();
var cat = (t.category || '').toLowerCase();
var coherent = t.coherent;
var name = (t.standard_name || t.slug || '').toLowerCase();
// Backbone / Long-haul / DCI
if (coherent || reach >= 40000 || name.includes('zr') || name.includes('dwdm') || name.includes('coherent')) {
useCases.push({ icon: '🌐', label: 'Backbone / DCI', desc: 'Long-haul, metro, data center interconnect' });
}
// Spine
if (speed >= 100 && reach <= 2000 && (ff.includes('QSFP') || ff.includes('OSFP'))) {
useCases.push({ icon: '🔀', label: 'Spine Layer', desc: 'High-speed spine-to-spine links in CLOS fabrics' });
}
// Leaf
if (speed >= 25 && speed <= 400 && reach <= 500 && !coherent) {
useCases.push({ icon: '🍃', label: 'Leaf / ToR', desc: 'Top-of-rack to spine connections, server uplinks' });
}
// Edge / Access
if (speed <= 25 || ff === 'SFP' || ff === 'SFP+') {
useCases.push({ icon: '📡', label: 'Edge / Access', desc: 'Campus, access layer, last-mile aggregation' });
}
// Enterprise / Campus
if (fiber.includes('MMF') && reach <= 300) {
useCases.push({ icon: '🏢', label: 'Enterprise Campus', desc: 'In-building fiber connections, short-reach MMF' });
}
// Reseller switch / Compatible
if (speed >= 10 && speed <= 100) {
useCases.push({ icon: '🔄', label: 'Reseller / Compatible', desc: 'Third-party compatible optics for vendor switches' });
}
// Breakout
if (t.breakout_capable) {
useCases.push({ icon: '🔌', label: 'Breakout Cable', desc: 'Splits into ' + (t.breakout_to || 'multiple lower-speed') + ' connections' });
}
// Metro
if (reach >= 10000 && reach < 40000) {
useCases.push({ icon: '🏙️', label: 'Metro / Regional', desc: 'Metropolitan area connections, 10-40km reach' });
}
// Deduplicate and limit to 4
var seen = {};
return useCases.filter(function(u) {
if (seen[u.label]) return false;
seen[u.label] = true;
return true;
}).slice(0, 4);
}
// Reference product photos per form factor (from Flexoptix catalog)
var FF_REFERENCE_IMAGES = {
'SFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'SFP+': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'SFP28': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/S/F/SFP_ZR_LC_Duplex_P.1696.23.xT_A_2.jpg',
'QSFP+': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'QSFP28': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'QSFP-DD': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'OSFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'CFP': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg',
'CFP2': 'https://www.flexoptix.net/media/catalog/product/cache/bd7a52a6ab629d9c2973634d6ae35193/Q/S/QSFP28_SR4_MTP-MPO_Q.851HG.02_A_2.jpg'
};
// Get best available image for a transceiver
function getTransceiverImage(t) {
// Real product image from DB
if (t.image_url) {
return '<img src="' + esc(t.image_url) + '" alt="' + esc(t.standard_name || t.slug) + '" style="max-width:100%;border-radius:8px" onerror="this.onerror=null;this.parentElement.innerHTML=getFormFactorImage(\'' + esc(t.form_factor) + '\')">';
}
// Reference image ONLY for FLEXOPTIX products
if (t.vendor_name === 'FLEXOPTIX') {
var ref = FF_REFERENCE_IMAGES[t.form_factor];
if (ref) {
return '<img src="' + esc(ref) + '" alt="' + esc(t.form_factor) + '" style="max-width:100%;border-radius:8px;opacity:0.85" onerror="this.onerror=null;this.parentElement.innerHTML=getFormFactorImage(\'' + esc(t.form_factor) + '\')">';
}
}
// SVG fallback for all other vendors
return getFormFactorImage(t.form_factor);
}
// Form factor image — realistic SVG transceiver diagrams (fallback)
function getFormFactorImage(ff, fiberType) {
var isSFP = /^SFP/i.test(ff);
var isQSFP = /^QSFP/i.test(ff);
var isOSFP = /^OSFP/i.test(ff);
var isCFP = /^CFP/i.test(ff);
var isCopper = (fiberType || '').toLowerCase().includes('copper') || (fiberType || '').toLowerCase().includes('dac');
var c1 = '#333333', c2 = '#1a1a1a', cAccent = '#FF8100';
if (isQSFP) { c1 = '#5e503f'; c2 = '#3a3328'; }
if (isOSFP) { c1 = '#6d4c41'; c2 = '#3e2723'; }
if (isCFP) { c1 = '#37474f'; c2 = '#263238'; }
if (isSFP) {
// SFP: smaller, single LC connector, bail latch on top
return '<svg viewBox="0 0 280 100" style="width:80%;max-height:120px">'
// Body
+ '<rect x="30" y="20" width="180" height="55" rx="4" fill="' + c2 + '"/>'
+ '<rect x="32" y="22" width="176" height="51" rx="3" fill="' + c1 + '"/>'
// Metal housing detail
+ '<rect x="32" y="22" width="176" height="8" rx="2" fill="' + c2 + '" opacity="0.5"/>'
// Bail latch (top handle)
+ '<rect x="50" y="12" width="80" height="10" rx="2" fill="' + c2 + '"/>'
+ '<rect x="55" y="14" width="70" height="6" rx="1" fill="#5a7a9a"/>'
// Label area
+ '<rect x="60" y="38" width="100" height="20" rx="2" fill="rgba(255,255,255,0.08)"/>'
+ '<text x="110" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="12" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
// LC connector end (right)
+ (isCopper
? '<rect x="210" y="30" width="40" height="35" rx="2" fill="#8d6e63"/><rect x="218" y="36" width="10" height="8" rx="1" fill="#ffb74d"/><rect x="232" y="36" width="10" height="8" rx="1" fill="#ffb74d"/>'
: '<rect x="210" y="30" width="30" height="35" rx="2" fill="' + c2 + '"/><circle cx="225" cy="42" r="4" fill="#81c784" opacity="0.7"/><circle cx="225" cy="55" r="4" fill="#81c784" opacity="0.7"/>')
// LED indicators
+ '<circle cx="42" y="35" r="2.5" fill="#4caf50" opacity="0.6"/>'
+ '<circle cx="42" y="45" r="2.5" fill="#ff9800" opacity="0.4"/>'
+ '</svg>';
}
if (isQSFP) {
// QSFP: wider body, MPO/MTP connector, pull tab
return '<svg viewBox="0 0 300 110" style="width:85%;max-height:120px">'
// Body
+ '<rect x="20" y="15" width="210" height="75" rx="5" fill="' + c2 + '"/>'
+ '<rect x="22" y="17" width="206" height="71" rx="4" fill="' + c1 + '"/>'
// Metal cage detail
+ '<rect x="22" y="17" width="206" height="10" rx="3" fill="' + c2 + '" opacity="0.6"/>'
// Pull tab
+ '<rect x="40" y="5" width="120" height="14" rx="3" fill="' + cAccent + '"/>'
+ '<rect x="50" y="8" width="100" height="8" rx="2" fill="' + cAccent + '" opacity="0.7"/>'
+ '<text x="100" y="15" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" font-weight="700" fill="#fff">PULL</text>'
// Label area
+ '<rect x="50" y="38" width="130" height="28" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="115" y="50" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="115" y="62" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="8" fill="rgba(255,255,255,0.4)">Optical Transceiver</text>'
// MPO/MTP connector (right)
+ (isCopper
? '<rect x="230" y="25" width="45" height="55" rx="3" fill="#6d4c41"/><rect x="238" y="32" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="254" y="32" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="238" y="50" width="12" height="12" rx="1" fill="#ffb74d"/><rect x="254" y="50" width="12" height="12" rx="1" fill="#ffb74d"/>'
: '<rect x="230" y="25" width="40" height="55" rx="3" fill="' + c2 + '"/><rect x="237" y="32" width="26" height="18" rx="2" fill="#263238"/><rect x="240" y="35" width="20" height="12" rx="1" fill="#4db6ac" opacity="0.3"/><rect x="237" y="56" width="26" height="18" rx="2" fill="#263238"/><rect x="240" y="59" width="20" height="12" rx="1" fill="#4db6ac" opacity="0.3"/>')
// Speed label
+ '<rect x="22" y="78" width="206" height="10" rx="2" fill="rgba(0,0,0,0.2)"/>'
+ '</svg>';
}
if (isOSFP) {
// OSFP: largest, wider than QSFP-DD
return '<svg viewBox="0 0 320 120" style="width:90%;max-height:120px">'
+ '<rect x="15" y="10" width="240" height="90" rx="6" fill="' + c2 + '"/>'
+ '<rect x="17" y="12" width="236" height="86" rx="5" fill="' + c1 + '"/>'
+ '<rect x="17" y="12" width="236" height="12" rx="4" fill="' + c2 + '" opacity="0.5"/>'
// Pull mechanism
+ '<rect x="35" y="2" width="140" height="12" rx="3" fill="' + cAccent + '"/>'
+ '<text x="105" y="11" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" font-weight="700" fill="#fff">PULL</text>'
// Label
+ '<rect x="45" y="36" width="155" height="35" rx="4" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="122" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="15" font-weight="800" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="122" y="66" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="8" fill="rgba(255,255,255,0.35)">High-Speed Optical Module</text>'
// Connector
+ '<rect x="255" y="20" width="45" height="70" rx="4" fill="' + c2 + '"/>'
+ '<rect x="262" y="28" width="30" height="22" rx="2" fill="#263238"/><rect x="265" y="31" width="24" height="16" rx="1" fill="#4db6ac" opacity="0.25"/>'
+ '<rect x="262" y="56" width="30" height="22" rx="2" fill="#263238"/><rect x="265" y="59" width="24" height="16" rx="1" fill="#4db6ac" opacity="0.25"/>'
+ '</svg>';
}
if (isCFP) {
// CFP: very large module
return '<svg viewBox="0 0 340 100" style="width:95%;max-height:120px">'
+ '<rect x="10" y="10" width="270" height="75" rx="5" fill="' + c2 + '"/>'
+ '<rect x="12" y="12" width="266" height="71" rx="4" fill="' + c1 + '"/>'
+ '<rect x="12" y="12" width="266" height="10" rx="3" fill="' + c2 + '" opacity="0.5"/>'
+ '<rect x="40" y="32" width="180" height="30" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="130" y="50" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="16" font-weight="800" fill="rgba(255,255,255,0.7)">' + esc(ff) + '</text>'
+ '<text x="130" y="60" text-anchor="middle" font-family="DM Sans,sans-serif" font-size="7" fill="rgba(255,255,255,0.35)">Coherent Optical Module</text>'
+ '<rect x="280" y="18" width="40" height="60" rx="3" fill="' + c2 + '"/>'
+ '<circle cx="300" cy="35" r="6" fill="#263238"/><circle cx="300" cy="35" r="3" fill="#4db6ac" opacity="0.3"/>'
+ '<circle cx="300" cy="55" r="6" fill="#263238"/><circle cx="300" cy="55" r="3" fill="#4db6ac" opacity="0.3"/>'
+ '</svg>';
}
// Generic fallback
return '<svg viewBox="0 0 280 100" style="width:75%;max-height:120px">'
+ '<rect x="25" y="18" width="190" height="60" rx="5" fill="' + c2 + '"/>'
+ '<rect x="27" y="20" width="186" height="56" rx="4" fill="' + c1 + '"/>'
+ '<rect x="55" y="36" width="120" height="24" rx="3" fill="rgba(255,255,255,0.06)"/>'
+ '<text x="115" y="52" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="13" font-weight="700" fill="rgba(255,255,255,0.7)">' + esc(ff || 'Transceiver') + '</text>'
+ '<rect x="215" y="26" width="35" height="44" rx="3" fill="' + c2 + '"/>'
+ '</svg>';
}
// TABS
function goToTab(tabName) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
var tabEl = document.querySelector('.tab[data-tab="' + tabName + '"]');
if (tabEl) tabEl.classList.add('active');
document.querySelectorAll('[id^="tab-"]').forEach(function(p) { p.classList.add('hidden'); });
var target = el('tab-' + tabName);
if (target) {
target.classList.remove('hidden');
target.classList.add('fade-in');
}
if (tabName === 'hype') loadHypeCycle();
if (tabName === 'transceivers') searchTransceivers();
if (tabName === 'switches') searchSwitches();
if (tabName === 'news') loadNews(1);
if (tabName === 'vendors') loadVendors();
if (tabName === 'standards') loadStandardsList();
if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); }
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
if (tabName === 'crawlers') loadCrawlerStatus();
if (tabName === 'selflearning') loadSelflearning();
if (tabName === 'procurement') loadProcurement();
if (tabName === 'network') loadProxyNetwork();
if (tabName === 'review') loadReview();
if (tabName === 'stock') loadStock();
if (tabName === 'prices') loadPriceComparison();
}
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() { goToTab(tab.dataset.tab); });
});
// Navigate to Transceivers tab with a verified filter pre-applied
// type: 'price' | 'image' | 'details' | 'full'
function goToVerifiedFilter(type) {
var filterEl = el('tx-verified-filter');
if (filterEl) filterEl.value = type;
goToTab('transceivers');
}
// Clickable header stats and overview cards
document.querySelectorAll('[data-goto]').forEach(function(elem) {
elem.addEventListener('click', function() { goToTab(this.getAttribute('data-goto')); });
});
// OVERVIEW
async function loadOverview() {
try {
var h = await api('/api/health');
animateValue(el('stat-transceivers'), h.database.stats.transceiver_count, 800);
animateValue(el('stat-vendors'), h.database.stats.vendor_count, 600);
animateValue(el('stat-switches'), h.database.stats.switch_count, 500);
animateValue(el('stat-standards'), h.database.stats.standard_count, 500);
animateValue(el('stat-news'), h.database.stats.news_count, 700);
if (h.stock && h.stock.total_observations > 0) {
animateValue(el('stat-stock-obs'), h.stock.total_observations, 900);
// Show warehouse stock summary card
var sc = el('ov-stock-card');
if (sc) sc.style.display = '';
var stockItems = [
{ icon: '📦', label: 'Beobachtungen', val: h.stock.total_observations.toLocaleString(), color: '#6366f1' },
{ icon: '🔌', label: 'SKUs mit Daten', val: h.stock.transceivers_with_stock.toLocaleString(), color: '#22c55e' },
{ icon: '🏪', label: 'Anbieter', val: h.stock.vendors_with_stock.toLocaleString(), color: '#3b82f6' },
{ icon: '🇩🇪', label: 'DE-Lager', val: h.stock.total_de_qty.toLocaleString(), color: '#a855f7' },
{ icon: '🌍', label: 'Global-Lager', val: h.stock.total_global_qty.toLocaleString(), color: '#06b6d4' },
];
buildDOM(el('ov-stock-grid'), stockItems.map(function(si) {
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;text-align:center">'
+ '<div style="font-size:1.4rem">' + si.icon + '</div>'
+ '<div style="font-size:1.1rem;font-weight:700;color:' + si.color + ';margin:0.2rem 0">' + si.val + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + si.label + '</div>'
+ '</div>';
}).join(''));
}
animateValue(el('ov-transceivers'), h.database.stats.transceiver_count, 1000);
animateValue(el('ov-vendors'), h.database.stats.vendor_count, 800);
animateValue(el('ov-switches'), h.database.stats.switch_count, 600);
animateValue(el('ov-standards'), h.database.stats.standard_count, 700);
animateValue(el('ov-news'), h.database.stats.news_count, 900);
// Verification status section
if (h.verification) {
var v = h.verification;
var total = v.total || 1;
var items = [
{ label: 'Price Verified', count: v.price_verified, pct: v.price_coverage_pct || Math.round(v.price_verified / total * 100), color: '#22c55e', filter: 'price' },
{ label: 'Image Verified', count: v.image_verified, pct: Math.round(v.image_verified / total * 100), color: '#3b82f6', filter: 'image' },
{ label: 'Details Verified', count: v.details_verified, pct: Math.round(v.details_verified / total * 100), color: '#a855f7', filter: 'details' },
{ label: 'Fully Verified', count: v.fully_verified, pct: v.fully_verified_pct || Math.round(v.fully_verified / total * 100), color: '#f97316', filter: 'full' },
];
buildDOM(el('verification-overview'), items.map(function(item) {
return '<div onclick="goToVerifiedFilter(\'' + item.filter + '\')" '
+ 'style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem 1rem;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s" '
+ 'onmouseover="this.style.borderColor=\'' + item.color + '\';this.style.boxShadow=\'0 0 0 1px ' + item.color + '40\'" '
+ 'onmouseout="this.style.borderColor=\'\';this.style.boxShadow=\'\'" '
+ 'title="Show ' + item.label + ' transceivers">'
+ '<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.4rem">'
+ '<span style="font-size:0.75rem;color:var(--text-dim)">' + item.label + '</span>'
+ '<span style="font-size:0.9rem;font-weight:700;color:var(--text-bright);font-family:var(--mono)">' + (item.count || 0).toLocaleString() + '</span>'
+ '</div>'
+ '<div style="background:var(--surface3);border-radius:4px;height:6px;overflow:hidden">'
+ '<div style="height:100%;width:' + (item.pct || 0) + '%;background:' + item.color + ';border-radius:4px;transition:width 1s ease"></div>'
+ '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:0.3rem;text-align:right">' + (item.pct || 0) + '% of ' + total.toLocaleString() + ' · click to filter →</div>'
+ '</div>';
}).join(''));
}
el('version-label').textContent = 'v' + h.version;
el('api-status').className = 'dot ' + (h.success ? 'dot-ok' : 'dot-err');
el('db-status').className = 'dot ' + (h.database.connected ? 'dot-ok' : 'dot-err');
if (!h.success) el('api-pill').classList.add('err');
if (!h.database.connected) el('db-pill').classList.add('err');
} catch(e) {
el('api-status').className = 'dot dot-err';
el('api-pill').classList.add('err');
}
try {
var stats = await api('/api/search/stats');
buildDOM(el('collections-list'), stats.collections.map(function(c) {
return '<div class="col-item">'
+ '<span class="col-name">' + esc(c.collection) + '</span>'
+ '<span class="b ' + (c.pointsCount > 0 ? 'b-green' : 'b-yellow') + '">' + esc(c.pointsCount) + ' vectors</span>'
+ '</div>';
}).join(''));
el('qdrant-status').className = 'dot dot-ok';
} catch(e) {
el('qdrant-status').className = 'dot dot-err';
el('qdrant-pill').classList.add('err');
}
try {
var root = await api('/');
buildDOM(el('endpoints-list'), (root.endpoints || []).map(function(e) {
return '<div class="endpoint-item">' + 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 class="ri">'
+ '<div class="ri-title">' + esc(n.title) + '</div>'
+ '<div class="ri-meta"><span class="b b-blue">' + esc(n.source) + '</span> ' + (n.published_at ? new Date(n.published_at).toLocaleDateString() : '') + '</div>'
+ '</div>';
}).join('') || '<div class="loading">No news yet</div>');
} catch(e) {}
}
// SEARCH
function doSearch() {
var q = el('search-input').value;
var col = el('search-collection').value;
if (!q) return;
el('search-results').innerHTML = '<div class="loading pulse">Searching...</div>';
api('/api/search?q=' + encodeURIComponent(q) + '&collection=' + col + '&limit=15').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) : '');
var score = (r.score * 100).toFixed(1);
var scoreColor = score > 70 ? 'var(--green)' : score > 40 ? 'var(--yellow)' : 'var(--text-dim)';
var clickAttr = '';
if (r.id && col === 'product_embeddings') {
clickAttr = ' style="cursor:pointer" onclick="openTxDetail(\'' + esc(r.id) + '\')"';
}
return '<div class="ri"' + clickAttr + '>'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(title) + '</div>'
+ '<span class="mono" style="font-size:0.72rem;color:' + scoreColor + ';font-weight:600">' + score + '%</span>'
+ '</div>'
+ '<div class="ri-body">' + esc(body) + '</div>'
+ '<div class="ri-meta">'
+ (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '')
+ (r.speed ? '<span class="b b-purple">' + esc(r.speed) + '</span>' : '')
+ (r.category ? '<span class="b b-yellow">' + esc(r.category) + '</span>' : '')
+ (r.severity ? '<span class="b b-red">' + esc(r.severity) + '</span>' : '')
+ (r.vendor ? '<span class="dim">' + esc(r.vendor) + '</span>' : '')
+ '</div></div>';
}).join('') || '<div class="loading">No results found</div>');
});
}
el('search-btn').addEventListener('click', doSearch);
el('search-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(); });
// HYPE CYCLE
var PC = {
'Innovation Trigger': '#FF8100',
'Peak of Inflated Expectations': '#FFa030',
'Trough of Disillusionment': '#c1121f',
'Slope of Enlightenment': '#555555',
'Plateau of Productivity': '#000000'
};
var PHASE_MAP = {
'INNOVATION_TRIGGER': 'Innovation Trigger',
'PEAK_OF_INFLATED_EXPECTATIONS': 'Peak of Inflated Expectations',
'TROUGH_OF_DISILLUSIONMENT': 'Trough of Disillusionment',
'SLOPE_OF_ENLIGHTENMENT': 'Slope of Enlightenment',
'PLATEAU_OF_PRODUCTIVITY': 'Plateau of Productivity'
};
var PHASE_DESC = {
'Innovation Trigger': 'Early-stage technology breakthrough. First proof-of-concept demos, limited vendor support.',
'Peak of Inflated Expectations': 'Maximum hype and media attention. Vendors announce products, but real-world deployments are rare.',
'Trough of Disillusionment': 'Reality check. Early deployments reveal limitations. Only committed adopters remain.',
'Slope of Enlightenment': 'Practical benefits become clear. Multi-vendor support grows. Best practices emerge.',
'Plateau of Productivity': 'Mainstream adoption. Stable pricing, broad vendor support, proven reliability.'
};
// Hype detail tooltip explanations
var HYPE_TIPS = {
'Adoption': 'Cumulative market adoption percentage based on the Norton-Bass diffusion model. Represents the fraction of total addressable market that has adopted this technology.',
'Position': 'Position on the Gartner-style hype curve (0-100%). 0% = early innovation trigger, 100% = late plateau/decline.',
'Peak Year': 'Estimated year when this technology reaches peak shipment volume, based on the Bass model parameters.',
'To Plateau': 'Estimated years remaining until this technology reaches mainstream, stable deployment (Plateau of Productivity).',
'Revenue Phase': 'Current phase in the revenue lifecycle: growing (pre-peak), peaking (at peak), declining (post-peak), or legacy.',
'Revenue Index': 'Revenue potential score (0-100) based on a bell curve centered on the peak revenue year. Higher = closer to peak revenue.',
'Composite Score': 'Weighted score (0-100) combining shipment share (30%), ASP decline (20%), standards maturity (15%), interop level (15%), vendor trend (10%), and media hype (10%).'
};
function curveY(x, w, h) {
var t = x / w;
if (t < 0.15) return h - (t / 0.15) * h * 0.85;
if (t < 0.22) return h * 0.15 + ((t - 0.15) / 0.07) * h * 0.02;
if (t < 0.42) return h * 0.17 + ((t - 0.22) / 0.20) * h * 0.55;
if (t < 0.48) return h * 0.72 - ((t - 0.42) / 0.06) * h * 0.02;
if (t < 0.80) return h * 0.70 - ((t - 0.48) / 0.32) * h * 0.35;
return h * 0.35 - ((t - 0.80) / 0.20) * h * 0.02;
}
function renderHypeSvg(techs) {
var W = 1400, TOP_LABEL = 30, H = 400, P = 30, BOTTOM_LABEL = 30, PHASE_ZONE = 40;
var curveTop = TOP_LABEL;
var totalH = TOP_LABEL + H + BOTTOM_LABEL + PHASE_ZONE;
var cw = W - P * 2, ch = H;
var pts = [];
for (var i = 0; i <= cw; i += 2) {
var cy = curveY(i, cw, ch) + curveTop;
pts.push((i + P) + ',' + cy);
}
var svg = '<svg viewBox="0 0 ' + W + ' ' + totalH + '" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">';
svg += '<defs>';
// Filters
svg += '<filter id="glow"><feGaussianBlur stdDeviation="4" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>';
svg += '<filter id="glow-soft"><feGaussianBlur stdDeviation="10" result="blur"/><feComposite in="SourceGraphic" in2="blur" operator="over"/></filter>';
// Curve gradient
svg += '<linearGradient id="curveGrad" x1="0%" y1="0%" x2="100%" y2="0%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="1"/>';
svg += '<stop offset="20%" stop-color="#FF9530" stop-opacity="1"/>';
svg += '<stop offset="45%" stop-color="#FF6B35" stop-opacity="0.9"/>';
svg += '<stop offset="60%" stop-color="#cc5500" stop-opacity="0.7"/>';
svg += '<stop offset="80%" stop-color="#FF8100" stop-opacity="0.85"/>';
svg += '<stop offset="100%" stop-color="#FFa040" stop-opacity="0.9"/>';
svg += '</linearGradient>';
// Area fill
svg += '<linearGradient id="fillGrad" x1="0%" y1="0%" x2="0%" y2="100%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.22"/>';
svg += '<stop offset="50%" stop-color="#FF8100" stop-opacity="0.07"/>';
svg += '<stop offset="100%" stop-color="#FF8100" stop-opacity="0"/>';
svg += '</linearGradient>';
// Glow line
svg += '<linearGradient id="glowLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.4"/>';
svg += '<stop offset="50%" stop-color="#FF6B35" stop-opacity="0.25"/>';
svg += '<stop offset="100%" stop-color="#FFa040" stop-opacity="0.35"/>';
svg += '</linearGradient>';
// Vertical drop line gradient top→down (fades out downward, for top labels)
svg += '<linearGradient id="dropGrad" x1="0%" y1="0%" x2="0%" y2="100%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.45"/>';
svg += '<stop offset="70%" stop-color="#FF8100" stop-opacity="0.18"/>';
svg += '<stop offset="100%" stop-color="#FF8100" stop-opacity="0.05"/>';
svg += '</linearGradient>';
// Vertical rise gradient bottom→up (fades out upward, for bottom labels)
svg += '<linearGradient id="riseGrad" x1="0%" y1="100%" x2="0%" y2="0%">';
svg += '<stop offset="0%" stop-color="#FF8100" stop-opacity="0.45"/>';
svg += '<stop offset="70%" stop-color="#FF8100" stop-opacity="0.18"/>';
svg += '<stop offset="100%" stop-color="#FF8100" stop-opacity="0.05"/>';
svg += '</linearGradient>';
svg += '</defs>';
// Subtle grid
for (var gy = 0; gy < 6; gy++) {
var gridY = curveTop + (ch / 5) * gy;
svg += '<line x1="' + P + '" y1="' + gridY + '" x2="' + (cw+P) + '" y2="' + gridY + '" stroke="rgba(0,0,0,0.04)" stroke-width="1" />';
}
// Phase zone separators
var rb = [0, 0.15, 0.28, 0.50, 0.78, 1.0];
for (var r = 1; r < 5; r++) {
var sepX = rb[r] * cw + P;
svg += '<line x1="' + sepX + '" y1="' + curveTop + '" x2="' + sepX + '" y2="' + (curveTop+ch) + '" stroke="rgba(255,129,0,0.06)" stroke-width="1" stroke-dasharray="3,5" />';
}
// Area fill
var areaPoints = pts.join(' ') + ' ' + (cw + P) + ',' + (curveTop + ch) + ' ' + P + ',' + (curveTop + ch);
svg += '<polygon points="' + areaPoints + '" fill="url(#fillGrad)" />';
// Glow behind curve
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#glowLineGrad)" stroke-width="14" stroke-linecap="round" filter="url(#glow-soft)" />';
// Main curve
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#curveGrad)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />';
// Phase labels at very bottom
var phaseY = curveTop + ch + BOTTOM_LABEL;
var pl = [
{l:'Innovation\\nTrigger',x:0.07},
{l:'Peak of Inflated\\nExpectations',x:0.18},
{l:'Trough of\\nDisillusionment',x:0.42},
{l:'Slope of\\nEnlightenment',x:0.64},
{l:'Plateau of\\nProductivity',x:0.90}
];
for (var p = 0; p < pl.length; p++) {
var px = pl[p].x * cw + P;
var ll = pl[p].l.split('\\n');
for (var li = 0; li < ll.length; li++) {
svg += '<text x="' + px + '" y="' + (phaseY + 12 + li * 13) + '" class="hype-phase-label" style="fill:rgba(0,0,0,0.45)">' + esc(ll[li]) + '</text>';
}
}
// Sort techs by x position
var sortedTechs = techs.slice().sort(function(a,b) { return a.positionPct - b.positionPct; });
// Calculate dot positions and assign top/bottom alternating
var techPositions = [];
for (var ti = 0; ti < sortedTechs.length; ti++) {
var t = sortedTechs[ti];
var dotX = (t.positionPct / 100) * cw + P;
var dotY = curveY((t.positionPct / 100) * cw, cw, ch) + curveTop;
var isTop = (ti % 2 === 0); // alternate: even=top, odd=bottom
techPositions.push({ t: t, dotX: dotX, dotY: dotY, labelX: dotX, isTop: isTop });
}
// Spread labels separately for top and bottom rows
function spreadLabels(positions, minGap) {
for (var i = 1; i < positions.length; i++) {
if (positions[i].labelX - positions[i-1].labelX < minGap) {
positions[i].labelX = positions[i-1].labelX + minGap;
}
}
// Compress if overflow
var maxX = W - P - 50;
if (positions.length > 0 && positions[positions.length - 1].labelX > maxX) {
var over = positions[positions.length - 1].labelX - maxX;
for (var i = 0; i < positions.length; i++) {
var sh = over * ((i + 1) / positions.length);
positions[i].labelX = Math.max(P + 40, positions[i].labelX - sh);
}
}
}
var topLabels = techPositions.filter(function(p) { return p.isTop; });
var bottomLabels = techPositions.filter(function(p) { return !p.isTop; });
spreadLabels(topLabels, 100);
spreadLabels(bottomLabels, 100);
// Render
for (var ti = 0; ti < techPositions.length; ti++) {
var tp = techPositions[ti];
var t = tp.t, color = PC[t.phase] || '#8888a4';
var dotX = tp.dotX, dotY = tp.dotY, labelX = tp.labelX;
var isTop = tp.isTop;
if (isTop) {
var labelY = 20;
var tickY1 = 28, tickY2 = 34;
svg += '<line x1="' + labelX + '" y1="' + tickY2 + '" x2="' + dotX + '" y2="' + (dotY - 8) + '" stroke="rgba(255,129,0,0.35)" stroke-width="1" />';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.7)" stroke-width="2" stroke-linecap="round" />';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11" style="fill:#1a1a2e">' + esc(t.technology) + '</text>';
} else {
var labelY = curveTop + ch + 18;
var tickY1 = labelY - 12, tickY2 = labelY - 6;
svg += '<line x1="' + dotX + '" y1="' + (dotY + 8) + '" x2="' + labelX + '" y2="' + tickY1 + '" stroke="rgba(255,129,0,0.35)" stroke-width="1" />';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.7)" stroke-width="2" stroke-linecap="round" />';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11" style="fill:#1a1a2e">' + esc(t.technology) + '</text>';
}
// Pulse ring
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="14" fill="' + color + '" class="hype-pulse" style="animation-delay:' + (ti * 0.35) + 's" />';
// Outer ring
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="10" fill="none" stroke="' + color + '" stroke-width="0.8" opacity="0.25" />';
// Main dot (with hover data attributes)
var adoptPct = t.adoptionPct != null ? Math.round(t.adoptionPct) : 0;
var peakYr = t.peakYear || '—';
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="5.5" fill="' + color + '" class="hype-dot" data-tech="' + esc(t.technology) + '" data-phase="' + esc(t.phase) + '" data-adoption="' + adoptPct + '" data-peak="' + peakYr + '" data-score="' + (t.compositeScore||0) + '" filter="url(#glow)" stroke="rgba(255,255,255,0.25)" stroke-width="0.8" style="cursor:pointer" />';
// Inner highlight
svg += '<circle cx="' + (dotX-1.2) + '" cy="' + (dotY-1.2) + '" r="1.5" fill="rgba(255,255,255,0.7)" opacity="0.6" pointer-events="none" />';
}
svg += '</svg>';
// Phase legend
svg += '<div class="hype-legend">';
var phases = [
{k:'Innovation Trigger',c:PC['Innovation Trigger']||'#4deaff'},
{k:'Peak of Inflated Expectations',c:PC['Peak of Inflated Expectations']||'#fbbf24'},
{k:'Trough of Disillusionment',c:PC['Trough of Disillusionment']||'#f87171'},
{k:'Slope of Enlightenment',c:PC['Slope of Enlightenment']||'#a78bfa'},
{k:'Plateau of Productivity',c:PC['Plateau of Productivity']||'#34d399'},
{k:'Legacy / Decline',c:PC['Legacy / Decline']||'#8888a4'}
];
for (var pi = 0; pi < phases.length; pi++) {
var cnt = techs.filter(function(t){return t.phase===phases[pi].k}).length;
if (cnt > 0) svg += '<span class="legend-item"><span class="legend-dot" style="background:'+phases[pi].c+'"></span>'+phases[pi].k+' ('+cnt+')</span>';
}
svg += '</div>';
return svg;
}
function tipAttr(key) {
return HYPE_TIPS[key] ? ' class="tip" data-tip="' + esc(HYPE_TIPS[key]) + '"' : '';
}
async function openHypeDetail(name) {
openPanel('<div class="loading pulse">Loading ' + esc(name) + '...</div>');
try {
var d = await api('/api/hype-cycle/' + encodeURIComponent(name));
var c = PC[d.phaseLabel] || '#8888a4';
var h = '<div class="panel-title">' + esc(d.technology) + '</div>';
h += '<div class="panel-sub"><span class="b" style="background:' + c + '18;color:' + c + ';border:1px solid ' + c + '33">' + esc(d.phaseLabel) + '</span></div>';
h += '<div class="panel-grid">';
h += '<div class="panel-stat"' + tipAttr('Adoption') + '><div class="panel-stat-label">Adoption</div><div class="panel-stat-val" style="color:' + c + '">' + (d.adoptionPct != null ? d.adoptionPct + '%' : '—') + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('Position') + '><div class="panel-stat-label">Position</div><div class="panel-stat-val">' + (d.positionPct||0) + '%</div></div>';
h += '<div class="panel-stat"' + tipAttr('Peak Year') + '><div class="panel-stat-label">Peak Year</div><div class="panel-stat-val">' + esc(d.forecast && d.forecast.peakShipmentYear || '—') + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('To Plateau') + '><div class="panel-stat-label">To Plateau</div><div class="panel-stat-val">' + (d.forecast && d.forecast.yearsToPlateauFromNow != null ? d.forecast.yearsToPlateauFromNow + 'y' : '—') + '</div></div>';
h += '</div>';
if (d.forecast && d.forecast.fiveYearProjection && d.forecast.fiveYearProjection.length) {
h += '<div class="panel-section">5-Year Forecast</div>';
for (var i = 0; i < d.forecast.fiveYearProjection.length; i++) {
var f = d.forecast.fiveYearProjection[i];
var fc = PC[PHASE_MAP[f.phase] || ''] || '#8888a4';
var pct = Math.min(f.adoptionPct || 0, 100);
h += '<div class="forecast-bar"><span class="yr">' + f.year + '</span><div class="track"><div class="fill" style="width:' + pct + '%;background:' + fc + '"></div></div><span class="pct">' + pct + '%</span></div>';
}
}
if (d.regionalAdoption && d.regionalAdoption.length) {
h += '<div class="panel-section">Regional Adoption</div>';
for (var ri = 0; ri < d.regionalAdoption.length; ri++) {
var ra = d.regionalAdoption[ri];
h += '<div class="panel-row"><span class="panel-row-label">' + esc(ra.region) + '</span><span class="panel-row-val"><span class="b b-cyan" style="margin-right:4px">' + esc(ra.adoptionPhase) + '</span>' + esc(ra.estimatedPeakYear) + '</span></div>';
}
}
if (d.revenueLifecycle) {
var rl = d.revenueLifecycle;
h += '<div class="panel-section">Revenue Lifecycle</div>';
var phaseColors = { growing: 'var(--green)', peaking: 'var(--yellow)', declining: 'var(--orange)', legacy: 'var(--text-dim)' };
h += '<div class="panel-grid">';
h += '<div class="panel-stat"' + tipAttr('Revenue Phase') + '><div class="panel-stat-label">Revenue Phase</div><div class="panel-stat-val" style="color:' + (phaseColors[rl.currentPhase]||'var(--text)') + ';font-size:1rem;text-transform:capitalize">' + esc(rl.currentPhase) + '</div></div>';
h += '<div class="panel-stat"' + tipAttr('Revenue Index') + '><div class="panel-stat-label">Revenue Index</div><div class="panel-stat-val">' + esc(rl.revenueIndex) + '<small>/100</small></div></div>';
h += '</div>';
h += '<div class="panel-row"><span class="panel-row-label">Peak Revenue Year</span><span class="panel-row-val">' + esc(rl.estimatedPeakRevenueYear) + '</span></div>';
h += '<div class="panel-row"><span class="panel-row-label">Decline Start</span><span class="panel-row-val">' + esc(rl.estimatedDeclineStartYear) + '</span></div>';
h += '<div class="panel-row"><span class="panel-row-label">Half-Life</span><span class="panel-row-val">' + esc(rl.revenueHalfLifeYears) + ' years</span></div>';
}
h += '<div class="panel-section">Composite Score</div>';
h += '<div style="padding:0.75rem;background:var(--surface2);border-radius:var(--radius-md);border:1px solid var(--border)"' + tipAttr('Composite Score') + '>';
h += '<span class="mono" style="font-size:1.75rem;font-weight:800;color:' + c + '">' + (d.compositeScore||0) + '</span>';
h += '<span style="font-size:0.8rem;color:var(--text-dim)"> / 100</span>';
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
// ── Forecast Area Chart (SVG) ──────────────────────────────────────
var TECH_COLORS = ['#FF8100','#4deaff','#a78bfa','#34d399','#fbbf24','#f87171','#818cf8','#fb923c','#22d3ee','#e879f9','#94a3b8'];
function renderForecastChart(techs) {
var withFc = techs.filter(function(t) { return t.fiveYearForecast && t.fiveYearForecast.length > 0; });
if (!withFc.length) return '<div class="dim" style="padding:1rem;text-align:center">No forecast data — use enriched API endpoint</div>';
var W = 900, H = 320, P = 50, PR = 30, PT = 20, PB = 40;
var cw = W - P - PR, ch = H - PT - PB;
var years = withFc[0].fiveYearForecast.map(function(f) { return f.year; });
var numYears = years.length;
var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">';
// Grid + Y axis
for (var y = 0; y <= 100; y += 25) {
var gy = PT + ch - (y / 100) * ch;
svg += '<line x1="' + P + '" y1="' + gy + '" x2="' + (P + cw) + '" y2="' + gy + '" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>';
svg += '<text x="' + (P - 8) + '" y="' + (gy + 4) + '" text-anchor="end" fill="rgba(255,255,255,0.4)" font-size="10" font-family="JetBrains Mono,monospace">' + y + '%</text>';
}
// X axis labels
for (var xi = 0; xi < numYears; xi++) {
var xx = P + (xi / (numYears - 1)) * cw;
svg += '<text x="' + xx + '" y="' + (H - 8) + '" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="11" font-family="DM Sans,sans-serif">' + years[xi] + '</text>';
}
// Lines per technology
for (var ti = 0; ti < withFc.length; ti++) {
var t = withFc[ti];
var color = TECH_COLORS[ti % TECH_COLORS.length];
var pts = [];
for (var fi = 0; fi < t.fiveYearForecast.length; fi++) {
var f = t.fiveYearForecast[fi];
var fx = P + (fi / (numYears - 1)) * cw;
var fy = PT + ch - (Math.min(f.adoptionPct, 100) / 100) * ch;
pts.push(fx + ',' + fy);
}
// Area fill
svg += '<polygon points="' + pts.join(' ') + ' ' + (P + cw) + ',' + (PT + ch) + ' ' + P + ',' + (PT + ch) + '" fill="' + color + '" opacity="0.08"/>';
// Line
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + color + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>';
// Dots
for (var di = 0; di < pts.length; di++) {
var dp = pts[di].split(',');
svg += '<circle cx="' + dp[0] + '" cy="' + dp[1] + '" r="3" fill="' + color + '" stroke="#0a0b10" stroke-width="1.5"/>';
}
// Label at end
var lastPt = pts[pts.length - 1].split(',');
svg += '<text x="' + (parseFloat(lastPt[0]) + 6) + '" y="' + (parseFloat(lastPt[1]) + 3) + '" fill="' + color + '" font-size="9" font-weight="600" font-family="DM Sans,sans-serif">' + esc(t.technology) + '</text>';
}
svg += '</svg>';
return svg;
}
// ── Regional Adoption Heatmap ──────────────────────────────────────
function renderRegionalHeatmap(techs) {
var regions = ['North America (Hyperscale)', 'China (BAT/Hyperscale)', 'APAC (ex-China)', 'Europe', 'Rest of World'];
var shortRegions = ['NA', 'China', 'APAC', 'Europe', 'RoW'];
// We need regional data — fetch per tech
var h = '<table style="width:100%;border-collapse:collapse;font-size:0.8rem">';
h += '<thead><tr><th style="text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:600">Technology</th>';
for (var ri = 0; ri < shortRegions.length; ri++) {
h += '<th style="padding:6px 8px;color:var(--text-dim);font-weight:600;text-align:center">' + shortRegions[ri] + '</th>';
}
h += '<th style="padding:6px 8px;color:var(--text-dim);font-weight:600;text-align:center">Peak Year</th></tr></thead><tbody id="heatmap-body"></tbody></table>';
return h;
}
async function loadRegionalData(techs) {
var body = document.getElementById('heatmap-body');
if (!body) return;
var html = '';
for (var ti = 0; ti < techs.length; ti++) {
var t = techs[ti];
var color = PC[t.phase] || '#8888a4';
try {
var rd = await api('/api/hype-cycle/regional/' + encodeURIComponent(t.technology));
var regions = rd.regions || [];
html += '<tr>';
html += '<td style="padding:6px 8px;font-weight:600;color:' + color + '">' + esc(t.technology) + '</td>';
for (var ri = 0; ri < 5; ri++) {
var r = regions[ri];
if (r) {
var share = r.marketSharePct || 0;
var opacity = Math.max(0.15, share / 45);
html += '<td style="padding:6px 8px;text-align:center;background:rgba(255,129,0,' + opacity.toFixed(2) + ');border-radius:4px"><span class="mono" style="font-size:0.75rem">' + share + '%</span><br><span style="font-size:0.65rem;color:var(--text-dim)">' + esc(r.adoptionPhase) + '</span></td>';
} else {
html += '<td style="padding:6px 8px;text-align:center;color:var(--text-dim)">—</td>';
}
}
html += '<td style="padding:6px 8px;text-align:center" class="mono">' + (rd.globalPeakYear || '—') + '</td>';
html += '</tr>';
} catch(e) {
html += '<tr><td style="padding:6px 8px;color:' + color + '">' + esc(t.technology) + '</td><td colspan="6" style="padding:6px 8px;color:var(--text-dim)">—</td></tr>';
}
}
buildDOM(body, html);
}
// Map DB snake_case phase → UI display label
var DB_PHASE_LABEL = {
'innovation_trigger': 'Innovation Trigger',
'peak_inflated_expectations': 'Peak of Inflated Expectations',
'trough_disillusionment': 'Trough of Disillusionment',
'slope_enlightenment': 'Slope of Enlightenment',
'plateau_productivity': 'Plateau of Productivity'
};
async function loadHypeCycle() {
var techs = [], dataSource = 'static';
// 1) Try DB-fitted Bass model results (freshest — computed daily 04:30)
try {
var dbRes = await api('/api/hype-cycle/analysis');
if (dbRes.success && Array.isArray(dbRes.data) && dbRes.data.length > 0) {
var now = new Date().getFullYear();
techs = dbRes.data.map(function(r) {
var phaseLabel = DB_PHASE_LABEL[r.hype_phase] || r.hype_phase;
// Estimate years to plateau: rough heuristic from phase + years_to_next_phase
var phasesLeft = { innovation_trigger:4, peak_inflated_expectations:3, trough_disillusionment:2, slope_enlightenment:1, plateau_productivity:0 };
var ytp = (phasesLeft[r.hype_phase] || 0) * (r.years_to_next_phase || 2);
return {
technology: r.technology,
phase: phaseLabel,
positionPct: Math.round(r.hype_score || 0),
adoptionPct: Math.round((r.current_share || 0) * 100),
peakYear: r.t_peak_year ? Math.round(r.t_peak_year) : null,
yearsToPlateauFromNow: ytp > 0 ? Math.round(ytp) : null,
// extra DB fields for tooltip
aspCurrentUsd: r.asp_current_usd,
aspDecline3y: r.asp_decline_pct_3y,
rSquared: r.r_squared,
computedAt: r.computed_at
};
});
dataSource = 'db';
var computedAt = dbRes.data[0] && dbRes.data[0].computed_at ? new Date(dbRes.data[0].computed_at).toLocaleDateString() : '';
el('hype-year').textContent = now + (computedAt ? ' · computed ' + computedAt : '');
}
} catch(e) { /* fall through to static */ }
// 2) Fallback: static enriched/base endpoint
if (techs.length === 0) {
var data;
try {
data = await api('/api/hype-cycle/enriched');
} catch(e) {
data = await api('/api/hype-cycle');
}
techs = data.technologies || [];
el('hype-year').textContent = data.year;
}
// Badge showing data source
var srcBadge = el('hype-data-source');
if (srcBadge) srcBadge.textContent = dataSource === 'db' ? '● Live DB' : '● Static';
var c = el('hype-svg-container');
buildDOM(c, renderHypeSvg(techs));
c.querySelectorAll('.hype-dot').forEach(function(dot) {
dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
});
buildDOM(el('hype-table'), techs.map(function(t) {
var color = PC[t.phase] || '#8888a4';
// FIX: adoptionPct is already a percentage (0-100), do NOT multiply by 100
var adoptionDisplay = (t.adoptionPct != null ? t.adoptionPct : 0) + '%';
return '<tr class="clickable" data-tech="' + esc(t.technology) + '">'
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>'
+ '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>'
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>'
+ '<td class="mono tip" data-tip="' + esc(HYPE_TIPS['Adoption']) + '">' + adoptionDisplay + '</td>'
+ '<td class="mono">' + esc(t.peakYear || '—') + '</td>'
+ '<td class="mono">' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
+ '<td class="mono">' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '</td>'
+ '<td class="mono">' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '</td>'
+ '</tr>';
}).join(''));
el('hype-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
});
// Render forecast chart (only if enriched data has forecasts)
var fcEl = document.getElementById('forecast-chart');
if (fcEl) buildDOM(fcEl, renderForecastChart(techs));
// Render regional heatmap shell, then load data async
var hmEl = document.getElementById('regional-heatmap');
if (hmEl) {
buildDOM(hmEl, renderRegionalHeatmap(techs));
loadRegionalData(techs);
}
// Render Sourcing Hype Cycle
loadSourcingHypeCycle();
}
// ── SOURCING HYPE CYCLE ──────────────────────────────────────────────────────
// Phases by observation count: <50=Discovery, 50-150=Ramp-Up, 150-350=Peak Demand, 350-1000=Mature, >1000=Commodity
var SOURCING_PHASES = [
{ key: 'DISCOVERY', label: 'Discovery', min: 0, max: 49, color: '#7c7c9e', glow: '#9a9abf' },
{ key: 'RAMP_UP', label: 'Ramp-Up', min: 50, max: 149, color: '#2a9d5c', glow: '#38c574' },
{ key: 'PEAK_DEMAND', label: 'Peak Demand', min: 150, max: 349, color: '#FF8100', glow: '#FFa030' },
{ key: 'MATURE', label: 'Mature', min: 350, max: 999, color: '#e07b00', glow: '#FFa030' },
{ key: 'COMMODITY', label: 'Commodity', min: 1000, max: 1e9, color: '#c1321f', glow: '#e05030' }
];
// X positions (0-100%) for each phase band on the sourcing curve
var SOURCING_PHASE_X = { DISCOVERY: 8, RAMP_UP: 26, PEAK_DEMAND: 50, MATURE: 72, COMMODITY: 90 };
function getSourcingPhase(obsCount) {
for (var i = 0; i < SOURCING_PHASES.length; i++) {
if (obsCount >= SOURCING_PHASES[i].min && obsCount <= SOURCING_PHASES[i].max) return SOURCING_PHASES[i];
}
return SOURCING_PHASES[SOURCING_PHASES.length - 1];
}
async function loadSourcingHypeCycle() {
var container = document.getElementById('sourcing-hype-chart');
var meta = document.getElementById('sourcing-hype-meta');
if (!container) return;
var seedData = [
{ label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 },
{ label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 },
{ label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 },
{ label: 'QSFP+ 40G', speed_gbps: 40, form_factor: 'QSFP+', obs: 217, avg_price: 180.17 },
{ label: 'SFP28 25G', speed_gbps: 25, form_factor: 'SFP28', obs: 198, avg_price: 142.50 },
{ label: 'QSFP-DD 400G', speed_gbps: 400, form_factor: 'QSFP-DD', obs: 193, avg_price: 510.99 },
{ label: 'OSFP 800G', speed_gbps: 800, form_factor: 'OSFP', obs: 80, avg_price: 810.06 },
{ label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 }
];
var items = seedData;
try {
var d = await api('/api/procurement/signals?limit=30');
if (d && d.signals && d.signals.length > 0) {
d.signals.forEach(function(sig) {
var key = (sig.form_factor || '') + String(sig.speed_gbps || '');
var ex = items.find(function(it) { return (it.form_factor || '') + String(it.speed_gbps || '') === key; });
if (ex && sig.observation_count > 0) ex.obs = sig.observation_count;
});
}
} catch(e) {}
var totalObs = items.reduce(function(a, b) { return a + b.obs; }, 0);
if (meta) meta.textContent = totalObs.toLocaleString() + ' obs · ' + items.length + ' segments';
// ── Same dimensions & curve as renderHypeSvg ───────────────────────────
var W = 1400, TOP_LABEL = 30, H = 400, P = 30, BOTTOM_LABEL = 30, PHASE_ZONE = 40;
var curveTop = TOP_LABEL;
var totalH = TOP_LABEL + H + BOTTOM_LABEL + PHASE_ZONE;
var cw = W - P * 2, ch = H;
var pts = [];
for (var i = 0; i <= cw; i += 2) {
var cy2 = curveY(i, cw, ch) + curveTop;
pts.push((i + P) + ',' + cy2);
}
// Reuse exact same gradients/filters as renderHypeSvg — no separate defs needed
var svg = '<svg viewBox="0 0 ' + W + ' ' + totalH + '" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:auto">';
// Grid lines — identical to renderHypeSvg
for (var gy = 0; gy < 6; gy++) {
var gridY = curveTop + (ch / 5) * gy;
svg += '<line x1="' + P + '" y1="' + gridY + '" x2="' + (cw+P) + '" y2="' + gridY + '" stroke="rgba(0,0,0,0.04)" stroke-width="1"/>';
}
// Phase separators — same positions as transceiver cycle
var rb = [0, 0.15, 0.28, 0.50, 0.78, 1.0];
for (var r = 1; r < 5; r++) {
var sepX = rb[r] * cw + P;
svg += '<line x1="' + sepX + '" y1="' + curveTop + '" x2="' + sepX + '" y2="' + (curveTop+ch) + '" stroke="rgba(255,129,0,0.06)" stroke-width="1" stroke-dasharray="3,5"/>';
}
// Area fill — same gradient as transceiver
var areaPoints = pts.join(' ') + ' ' + (cw + P) + ',' + (curveTop + ch) + ' ' + P + ',' + (curveTop + ch);
svg += '<polygon points="' + areaPoints + '" fill="url(#fillGrad)"/>';
// Glow + main curve — reuse curveGrad, glow-soft, identical stroke widths
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#glowLineGrad)" stroke-width="14" stroke-linecap="round" filter="url(#glow-soft)"/>';
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="url(#curveGrad)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>';
// Phase labels at bottom — same style as transceiver
var phaseY = curveTop + ch + BOTTOM_LABEL;
var srcPhaseLabels = [
{ l: 'Discovery', x: 0.075 },
{ l: 'Ramp-Up', x: 0.215 },
{ l: 'Peak\\nDemand', x: 0.39 },
{ l: 'Mature', x: 0.64 },
{ l: 'Commodity', x: 0.89 }
];
for (var sp = 0; sp < srcPhaseLabels.length; sp++) {
var spx = srcPhaseLabels[sp].x * cw + P;
var sll = srcPhaseLabels[sp].l.split('\\n');
for (var sli = 0; sli < sll.length; sli++) {
svg += '<text x="' + spx + '" y="' + (phaseY + 12 + sli * 13) + '" class="hype-phase-label" style="fill:rgba(0,0,0,0.45)">' + esc(sll[sli]) + '</text>';
}
}
// Map sourcing phases to PC colors (same as transceiver dots)
var SRC_TO_PC = {
DISCOVERY: PC['Innovation Trigger'] || '#FF8100',
RAMP_UP: PC['Peak of Inflated Expectations'] || '#FFa030',
PEAK_DEMAND: PC['Trough of Disillusionment'] || '#c1121f',
MATURE: PC['Slope of Enlightenment'] || '#555555',
COMMODITY: PC['Plateau of Productivity'] || '#000000'
};
// Sort items by x position
var sortedItems = items.slice().sort(function(a, b) {
var pA = getSourcingPhase(a.obs), pB = getSourcingPhase(b.obs);
var xA = SOURCING_PHASE_X[pA.key] + ((a.avg_price % 100) / 100 - 0.5) * 8;
var xB = SOURCING_PHASE_X[pB.key] + ((b.avg_price % 100) / 100 - 0.5) * 8;
return xA - xB;
});
// Build dot positions
var techPositions = [];
for (var ti = 0; ti < sortedItems.length; ti++) {
var sit = sortedItems[ti];
var sph = getSourcingPhase(sit.obs);
var jitter = ((sit.avg_price % 100) / 100 - 0.5) * 8;
var xPct = Math.min(Math.max(SOURCING_PHASE_X[sph.key] + jitter, 2), 98);
var dotX = P + (xPct / 100) * cw;
var dotY = curveY((xPct / 100) * cw, cw, ch) + curveTop;
techPositions.push({ it: sit, ph: sph, dotX: dotX, dotY: dotY, labelX: dotX, isTop: (ti % 2 === 0) });
}
// Spread labels — same function as transceiver
function spreadSrcLabels(positions, minGap) {
for (var i = 1; i < positions.length; i++) {
if (positions[i].labelX - positions[i-1].labelX < minGap)
positions[i].labelX = positions[i-1].labelX + minGap;
}
var maxX = W - P - 50;
if (positions.length && positions[positions.length - 1].labelX > maxX) {
var over = positions[positions.length - 1].labelX - maxX;
for (var i = 0; i < positions.length; i++)
positions[i].labelX = Math.max(P + 40, positions[i].labelX - over * ((i + 1) / positions.length));
}
}
spreadSrcLabels(techPositions.filter(function(p) { return p.isTop; }), 110);
spreadSrcLabels(techPositions.filter(function(p) { return !p.isTop; }), 110);
// Render — 1:1 identical to renderHypeSvg render loop
for (var ti = 0; ti < techPositions.length; ti++) {
var tp = techPositions[ti];
var sit = tp.it, sph = tp.ph;
var color = SRC_TO_PC[sph.key] || '#8888a4';
var dotX = tp.dotX, dotY = tp.dotY, labelX = tp.labelX;
var obsStr = sit.obs >= 1000 ? (sit.obs / 1000).toFixed(1) + 'k' : String(sit.obs);
var priceStr = '€' + Math.round(sit.avg_price);
if (tp.isTop) {
var labelY = 20;
var tickY1 = 28, tickY2 = 34;
svg += '<line x1="' + labelX + '" y1="' + tickY2 + '" x2="' + dotX + '" y2="' + (dotY - 8) + '" stroke="rgba(255,129,0,0.35)" stroke-width="1"/>';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.7)" stroke-width="2" stroke-linecap="round"/>';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11" style="fill:#1a1a2e">' + esc(sit.label) + '</text>';
} else {
var labelY = curveTop + ch + 18;
var tickY1 = labelY - 12, tickY2 = labelY - 6;
svg += '<line x1="' + dotX + '" y1="' + (dotY + 8) + '" x2="' + labelX + '" y2="' + tickY1 + '" stroke="rgba(255,129,0,0.35)" stroke-width="1"/>';
svg += '<line x1="' + labelX + '" y1="' + tickY1 + '" x2="' + labelX + '" y2="' + tickY2 + '" stroke="rgba(255,129,0,0.7)" stroke-width="2" stroke-linecap="round"/>';
svg += '<text x="' + labelX + '" y="' + labelY + '" text-anchor="middle" class="hype-label" font-weight="700" font-size="11" style="fill:#1a1a2e">' + esc(sit.label) + '</text>';
}
// Pulse ring + outer ring + main dot — 1:1 identical to renderHypeSvg
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="14" fill="' + color + '" class="hype-pulse" style="animation-delay:' + (ti * 0.35) + 's"/>';
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="10" fill="none" stroke="' + color + '" stroke-width="0.8" opacity="0.25"/>';
svg += '<g class="hype-dot" style="cursor:pointer" onclick="el(\'tx-ff-filter\').value=\'' + esc(sit.form_factor) + '\';goToTab(\'transceivers\');searchTransceivers()">';
svg += '<title>' + esc(sit.label) + ' · ' + sit.obs + ' obs · avg ' + priceStr + ' · ' + sph.label + '</title>';
svg += '<circle cx="' + dotX + '" cy="' + dotY + '" r="5.5" fill="' + color + '" filter="url(#glow)" stroke="rgba(255,255,255,0.25)" stroke-width="0.8" style="cursor:pointer"/>';
svg += '</g>';
svg += '<circle cx="' + (dotX-1.2) + '" cy="' + (dotY-1.2) + '" r="1.5" fill="rgba(255,255,255,0.7)" opacity="0.6" pointer-events="none"/>';
svg += '<text x="' + (dotX + 8) + '" y="' + (dotY - 8) + '" font-size="8" font-family="\'JetBrains Mono\',monospace" fill="' + color + '" pointer-events="none" font-weight="700">' + esc(obsStr) + '</text>';
}
svg += '</svg>';
buildDOM(container, svg);
}
// TRANSCEIVERS
var lastTxData = []; // store for export/compare
function searchTransceivers() {
var q = el('tx-search').value;
var ff = el('tx-ff-filter').value;
var vf = el('tx-vendor-filter').value;
var verifiedF = (el('tx-verified-filter') || {}).value || '';
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (ff) params.push('form_factor=' + encodeURIComponent(ff));
if (vf) params.push('vendor=' + encodeURIComponent(vf));
if (verifiedF) params.push('verified=' + encodeURIComponent(verifiedF));
params.push('limit=200');
api('/api/transceivers?' + params.join('&')).then(function(data) {
lastTxData = data.data || data.transceivers || [];
// Show result count in search bar placeholder
var total = data.total || lastTxData.length;
var activeFilter = q || ff || vf || verifiedF;
var txSearchEl = el('tx-search');
if (txSearchEl && !activeFilter) txSearchEl.placeholder = 'Filter: Nexus 9300, QSFP28, 400G, coherent… (' + total + ' transceivers total)';
// Show count above table + clear button
var countNote = el('tx-result-count');
var filterLabel = verifiedF ? (' — ' + (verifiedF === 'full' ? '★ Fully Verified' : verifiedF.charAt(0).toUpperCase() + verifiedF.slice(1) + ' Verified') + ' filter') : (activeFilter ? ' — filter active' : '');
if (countNote) countNote.textContent = 'Showing ' + lastTxData.length + (data.total && data.total > lastTxData.length ? ' of ' + data.total : '') + ' transceivers' + filterLabel;
var clearBtn = el('tx-clear-filter');
if (clearBtn) clearBtn.style.display = activeFilter ? '' : 'none';
buildDOM(el('tx-table'), lastTxData.map(function(t) {
return '<tr class="clickable" data-txid="' + esc(t.id) + '">'
+ '<td onclick="event.stopPropagation()"><input type="checkbox" class="compare-cb" data-id="' + esc(t.id) + '"></td>'
+ '<td><div style="font-weight:600;color:var(--text-bright);line-height:1.2">' + esc(t.part_number || t.standard_name || t.slug) + '</div>'
+ (txDescName(t) && txDescName(t) !== (t.part_number || t.standard_name || t.slug) ? '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.15rem;line-height:1.3;max-width:40ch;white-space:normal">' + esc(txDescName(t)) + '</div>' : '')
+ '</td>'
+ '<td>' + esc(t.vendor_name || '—') + '</td>'
+ '<td><span class="b b-blue">' + esc(t.form_factor) + '</span></td>'
+ '<td class="mono">' + esc(t.speed) + '</td>'
+ '<td>' + esc(t.reach_label) + '</td>'
+ '<td class="mono">' + (t.street_price_usd ? fmtUSD(t.street_price_usd) : t.price_verified_eur ? '<span style="color:var(--text-dim)">€' + parseFloat(t.price_verified_eur).toFixed(2) + '</span>' : '—') + '</td>'
+ '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>'
+ '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>'
+ '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>'
+ (function() {
var pv = !!(t.price_verified && (t.street_price_usd || t.price_verified_eur));
var iv = !!t.image_verified;
var dv = !!t.details_verified;
var cv = !!t.competitor_verified;
if (pv && iv && dv && cv) return '<td><span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.62rem;font-weight:700;padding:2px 6px;border-radius:4px;white-space:nowrap">★ 100%</span></td>';
var s = '<td style="white-space:nowrap">';
s += '<span style="' + (pv ? 'color:#16a34a' : 'color:#dc2626') + ';font-size:0.65rem;font-weight:700;margin-right:2px" title="Price verified">' + (pv ? '✓' : '—') + 'P</span>';
s += '<span style="' + (iv ? 'color:#16a34a' : 'color:#dc2626') + ';font-size:0.65rem;font-weight:700;margin-right:2px" title="Image verified">' + (iv ? '✓' : '—') + 'I</span>';
s += '<span style="' + (dv ? 'color:#16a34a' : 'color:#dc2626') + ';font-size:0.65rem;font-weight:700;margin-right:2px" title="Details verified">' + (dv ? '✓' : '—') + 'D</span>';
s += '<span style="' + (cv ? 'color:#16a34a' : 'color:#dc2626') + ';font-size:0.65rem;font-weight:700" title="Competitor price">' + (cv ? '✓' : '—') + 'C</span>';
s += '</td>';
return s;
})()
+ '</tr>';
}).join(''));
el('tx-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openTxDetail(this.getAttribute('data-txid')); });
});
});
}
async function openTxDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/transceivers/' + id);
var t = data.data || data.transceiver || data;
var h = '';
// Product name header — above the image
var descName = txDescName(t);
var sku = t.part_number || t.standard_name || t.slug;
h += '<div style="margin-bottom:0.9rem">';
h += '<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright);letter-spacing:-0.01em;line-height:1.2">' + esc(sku) + '</div>';
if (descName && descName !== sku) {
h += '<div style="font-size:0.82rem;color:var(--text-dim);margin-top:0.3rem;line-height:1.4">' + esc(descName) + '</div>';
}
h += '</div>';
// Image section — entire box clickable if product_page_url exists
var hasRealImage = t.image_url || (t.vendor_name === 'FLEXOPTIX' && FF_REFERENCE_IMAGES[t.form_factor]);
if (t.product_page_url) {
h += '<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="display:block;text-decoration:none;cursor:pointer" title="Open on ' + esc(t.vendor_name || 'Vendor') + '">';
}
h += '<div class="tx-image-box ' + (hasRealImage ? 'has-photo' : 'has-svg') + '" style="' + (t.product_page_url ? 'cursor:pointer' : '') + '">';
h += getTransceiverImage(t);
if (t.vendor_name && t.image_url) h += '<span class="img-badge">' + esc(t.vendor_name) + '</span>';
if (t.product_page_url) {
h += '<span class="img-link">View on ' + esc(t.vendor_name || 'Vendor') + ' &rarr;</span>';
}
h += '</div>';
if (t.product_page_url) h += '</a>';
// Title below image — proper manufacturer designation only, never garbage/auto-generated names
var isGarbageName = function(s) {
if (!s) return true;
if (s.startsWith('scraped-')) return true; // auto-generated slug
if (/^[a-z0-9-]+$/.test(s)) return true; // pure slug: only lowercase+digits+dash
if (/^all optical/i.test(s)) return true; // GBICS category page garbage
if (/^compatible \d+/i.test(s)) return true; // "Compatible 800GBASE-..." category
if (/^osfp \d+g/i.test(s)) return true; // generic form-factor description
if (/^qsfp.{0,5}\d+g/i.test(s)) return true; // "QSFP 400G Gigabit Ethernet" etc.
if (s.toLowerCase().includes('gigabit ethernet') && s.length > 25) return true;
if (s.toLowerCase().startsWith('sfp') && /^sfp\s*\d+g\s*\w+/i.test(s)) return true;
return false;
};
var titleName = (!isGarbageName(t.standard_name) ? t.standard_name : null)
|| (!isGarbageName(t.part_number) ? t.part_number : null)
|| (t.description && !isGarbageName(t.description) ? t.description : null)
|| txDescName(t)
|| t.slug;
h += '<div class="panel-title">' + esc(titleName) + '</div>';
// Show data quality warning when product is not details-verified
if (!t.details_verified) {
h += '<div style="font-size:0.72rem;color:#c1440e;background:rgba(193,68,14,0.07);border:1px solid rgba(193,68,14,0.2);border-radius:5px;padding:0.35rem 0.6rem;margin:0.4rem 0">⚠ Produktdaten nicht aus offizieller Quelle verifiziert</div>';
}
h += '<div class="panel-sub">';
if (t.vendor_name) h += '<span class="b b-blue" title="Hersteller / Marke dieses Produkts">' + esc(t.vendor_name) + '</span> ';
if (t.category) h += '<span class="b b-neutral" title="Einsatzbereich: ' + esc(t.category) + '">' + esc(t.category) + '</span> ';
if (t.market_status) {
var msTooltip = t.market_status === 'Emerging' ? 'Hype Cycle: Technologie gewinnt erste Akzeptanz wachsendes Ökosystem, noch Premium-Preise'
: t.market_status === 'Mainstream' ? 'Hype Cycle: Technologie ist etabliert breite Verfügbarkeit, kompetitive Preise'
: t.market_status === 'Legacy' ? 'Hype Cycle: Technologie veraltet wird durch neuere Generationen abgelöst'
: 'Marktphase: ' + t.market_status;
h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '" title="' + msTooltip + '">' + esc(t.market_status) + '</span>';
}
h += '</div>';
if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>';
// Key specs — hero grid (like Flexoptix top section)
h += '<div class="panel-grid" style="margin-top:1rem">';
h += '<div class="panel-stat"><div class="panel-stat-label">Form Factor</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.form_factor) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Speed</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.speed) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Reach</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.reach_label || (t.reach_meters ? t.reach_meters + 'm' : '—')) + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>';
h += '</div>';
// Verification summary bar — explicit === true to handle any type coercion
var pVer = t.price_verified === true;
var iVer = t.image_verified === true;
// details_verified from DB can be stale — runtime-check reach_label as gating requirement
var dVer = t.details_verified === true && !!(t.reach_label && t.reach_label.trim() !== '');
var fVer = t.fully_verified === true && dVer;
// Competitor verified: at least 1 price from a non-Flexoptix vendor in last 30 days
var allPricesForBadge = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
var cVer = allPricesForBadge.some(function(p) {
return p.vendor_name && p.vendor_name.toUpperCase().indexOf('FLEXOPTIX') === -1;
});
var verItems = [];
if (pVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Price</span>');
if (iVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Image</span>');
if (dVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Details</span>');
var noMarket = !cVer && t.competitor_has_product === false && t.last_competitor_scan;
if (cVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Competitor</span>');
else if (noMarket) verItems.push('<span style="color:#dc2626;font-size:0.75rem;font-weight:700;background:rgba(220,38,38,0.1);padding:1px 6px;border-radius:4px;border:1px solid rgba(220,38,38,0.35)" title="Kein Wettbewerber bietet dieses Produkt an — letzter Scan: ' + fmtDate(t.last_competitor_scan) + '">⚠ Kein Markt</span>');
else verItems.push('<span style="color:#b45309;font-size:0.75rem;font-weight:500" title="Competitor prices are being researched 24/7">⟳ Competitor</span>');
// 100% VERIFIED requires all 4: Price + Image + Details + Competitor
var fullyVerified = fVer && cVer;
if (fullyVerified) {
// Inside the green bar: all text must be white/light — not #2d6a4f (same as bg)
var fvItems = [];
if (pVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Price</span>');
if (iVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Image</span>');
if (dVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Details</span>');
fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Competitor</span>');
h += '<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;margin:0.8rem 0;padding:0.55rem 0.85rem;background:linear-gradient(135deg,#1b4332,#2d6a4f);border-radius:8px">'
+ '<span style="color:#fff;font-size:0.82rem;font-weight:700;letter-spacing:0.04em">★ 100% VERIFIED</span>'
+ '<span style="color:rgba(255,255,255,0.4);font-size:0.7rem"></span>'
+ fvItems.join('<span style="color:rgba(255,255,255,0.3);font-size:0.7rem;margin:0 0.1rem">·</span>')
+ (t.fully_verified_at ? '<span style="color:rgba(255,255,255,0.55);font-size:0.68rem;margin-left:auto">seit ' + fmtDate(t.fully_verified_at) + '</span>' : '')
+ '</div>';
} else if (verItems.length > 0) {
// Partial verification on white background: dark green is fine here
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.08);border:1px solid rgba(45,106,79,0.2);border-radius:6px">'
+ verItems.join('<span style="color:#aaa;font-size:0.7rem">·</span>')
+ '</div>';
}
// No verification → no badge. Never show unverified data as verified.
// Helper: render a spec section as a clean table (like Flexoptix spec tables)
function renderSpecTable(title, rows) {
var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; });
if (visible.length === 0) return '';
var out = '<div class="panel-section">' + title + '</div>';
out += '<div class="spec-table">';
for (var i = 0; i < visible.length; i++) {
out += '<div class="spec-row"><span class="spec-label">' + esc(visible[i][0]) + '</span><span class="spec-val">' + esc(String(visible[i][1])) + '</span></div>';
}
out += '</div>';
return out;
}
// SPECIFICATION — Physical
h += renderSpecTable('Physical', [
['Connector / Polish', t.connector],
['Interface', t.fiber_type],
['Wavelengths', t.wavelengths],
['WDM Type', t.wdm_type],
['Channel Count', t.channel_count],
['Channel Spacing', t.channel_spacing_ghz ? t.channel_spacing_ghz + ' GHz' : null],
['Tunable', t.tunable ? 'Yes' : null],
['ITU Grid', t.itu_grid],
['Coherent', t.coherent ? 'Yes' : null],
['Temperature Range', tempRangeDisplay(t.temp_range)],
]);
// SPECIFICATION — Performance
h += renderSpecTable('Performance', [
['Lanes', t.lanes],
['Lane Rate', t.lane_rate],
['Modulation', t.modulation],
['Baud Rate', t.baud_rate_gbaud ? t.baud_rate_gbaud + ' GBaud' : null],
['FEC Type', t.fec_type],
['DSP Vendor', t.dsp_vendor],
['Power Consumption', t.power_consumption_w ? t.power_consumption_w + ' W' : null],
['DOM Support', t.dom_support ? 'Yes' : (t.dom_support === false ? 'No' : null)],
['Digital Diagnostics', t.digital_diagnostics],
]);
// SPECIFICATION — Optical Budget
h += renderSpecTable('Optical Budget', [
['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null],
['TX Power (Min)', t.tx_power_min_dbm != null ? t.tx_power_min_dbm + ' dBm' : null],
['TX Power (Max)', t.tx_power_max_dbm != null ? t.tx_power_max_dbm + ' dBm' : null],
['RX Sensitivity', t.rx_sensitivity_dbm != null ? t.rx_sensitivity_dbm + ' dBm' : null],
]);
// SPECIFICATION — Breakout
if (t.breakout_capable) {
h += renderSpecTable('Breakout', [
['Breakout Capable', 'Yes'],
['Breakout To', t.breakout_to],
]);
}
// SPECIFICATION — Product Info
h += renderSpecTable('Product Info', [
['Vendor', t.vendor_name],
['Part Number', t.part_number],
['Standard', t.standard_full_name || t.ieee_reference],
['Category', t.category],
['Market Status', t.market_status],
['Year Introduced', t.year_introduced],
['Year Mainstream', t.year_mainstream],
]);
// SPECIFICATION — Pricing
// Rule: Only prices with real URL from last 30 days. No estimated/fallback prices.
var allPrices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
var directPrices = allPrices.filter(function(p) { return p.is_same_product !== false; });
var comparPrices = allPrices.filter(function(p) { return p.is_same_product === false; });
if (allPrices.length > 0) {
// Price anomaly warning — show before price table if ratio ≥ 10x
var anomaly = t.price_anomaly;
var anomalyBanner = '';
if (anomaly && anomaly.ratio >= 10) {
anomalyBanner = '<div style="background:#3d1a1a;border:1px solid #7a2e2e;border-radius:6px;padding:0.6rem 0.9rem;margin-bottom:0.6rem;font-size:0.75rem;color:#f08080;line-height:1.5">'
+ '<strong style="color:#ff6b6b">⚠ Preisanomalie</strong> — '
+ anomaly.ratio + 'x Unterschied zwischen Anbietern'
+ ' (min. EUR\u00a0' + anomaly.min_eur.toLocaleString('de-DE',{minimumFractionDigits:2}) + ' / max. EUR\u00a0' + anomaly.max_eur.toLocaleString('de-DE',{minimumFractionDigits:2}) + ').'
+ ' Entweder ist ein Preis falsch erfasst, oder es handelt sich um unterschiedliche Produktvarianten.'
+ '</div>';
}
h += '<div class="panel-section">Current Prices</div>';
h += anomalyBanner;
h += '<div class="spec-table">';
function renderPriceRow(p) {
var verBadge = p.is_verified === true
? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600;margin-left:0.5rem" title="Scraped from official vendor page, max. 7 days old">✓ Verified</span>'
: '';
// Always show USD (lead) + EUR — convert from original currency
var origAmt = parseFloat(p.price);
var origCur = (p.currency || 'USD').toUpperCase();
var usdAmt = toUSD(origAmt, origCur);
var eurAmt = toEUR(origAmt, origCur);
var priceStr = '<strong style="font-size:0.9rem">';
if (usdAmt !== null) {
priceStr += fmtUSD(usdAmt);
} else {
priceStr += origCur + '\u00a0' + origAmt.toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2});
}
priceStr += '</strong>';
// EUR secondary (always)
if (eurAmt !== null) {
priceStr += '<span style="color:#aaa;font-size:0.78rem;margin-left:0.45rem">/ ' + fmtEUR(eurAmt) + '</span>';
}
// Original in grey if neither USD nor EUR
if (origCur !== 'USD' && origCur !== 'EUR') {
priceStr += '<span style="color:#666;font-size:0.65rem;margin-left:0.3rem" title="Original price">(' + origCur + '\u00a0' + origAmt.toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ')</span>';
}
var dateStr = '<span style="color:#aaa;font-size:0.67rem;margin-left:0.5rem">Stand: ' + fmtDate(p.observed_at) + '</span>';
var urlLink = '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none;margin-left:0.5rem">↗</a>';
// Same layout for all rows — comparable_part as tooltip only, never as block
var label = esc(p.vendor_name)
+ (p.is_same_product === false && p.comparable_part
? ' <span style="color:#aaa;font-size:0.65rem;font-weight:400" title="' + esc(p.comparable_part) + '">ⓘ</span>'
: '');
return '<div class="spec-row"><span class="spec-label">' + label + '</span>'
+ '<span class="spec-val" style="display:flex;align-items:center;flex-wrap:wrap">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
}
directPrices.forEach(function(p) { h += renderPriceRow(p); });
h += '</div>';
// Comparable products → Side-by-Side spec comparison cards
if (comparPrices.length > 0) {
h += '<div class="panel-section" style="margin-top:0.8rem">Vergleichbare Wettbewerber-Produkte</div>';
h += '<div style="font-size:0.72rem;color:#888;margin-bottom:0.5rem">Gleiche Spezifikationsklasse — andere Part Number</div>';
comparPrices.forEach(function(p) {
// Calculate price delta (EUR-normalized)
var myEur = null;
var refPrice = directPrices.length > 0 ? directPrices[0] : null;
if (refPrice) {
var ra = parseFloat(refPrice.price), rc = (refPrice.currency||'USD').toUpperCase();
myEur = rc === 'EUR' ? ra : rc === 'USD' ? ra * 0.92 : ra;
}
var compEur = null;
var ca = parseFloat(p.price), cc = (p.currency||'USD').toUpperCase();
compEur = cc === 'EUR' ? ca : cc === 'USD' ? ca * 0.92 : ca;
var savBadge = '';
if (myEur && compEur && myEur > 0 && compEur > 0) {
var diff = myEur - compEur;
var pct = Math.round(Math.abs(diff) / myEur * 100);
if (diff > 0) {
savBadge = '<span style="background:rgba(22,163,74,0.15);color:#16a34a;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(22,163,74,0.35)">'
+ '' + pct + '% günstiger</span>';
} else if (diff < 0) {
savBadge = '<span style="background:rgba(220,38,38,0.1);color:#dc2626;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(220,38,38,0.25)">'
+ '+' + pct + '% teurer</span>';
}
}
// Spec comparison helper — highlight match/mismatch
function specRow(label, myVal, compVal) {
var match = myVal && compVal && String(myVal).toLowerCase() === String(compVal).toLowerCase();
var compColor = !myVal || !compVal ? '#aaa' : match ? '#4ade80' : '#fb923c';
return '<tr><td style="color:#888;font-size:0.7rem;padding:2px 6px 2px 0;white-space:nowrap">' + label + '</td>'
+ '<td style="color:#ccc;font-size:0.7rem;padding:2px 8px 2px 0">' + esc(myVal || '—') + '</td>'
+ '<td style="color:' + compColor + ';font-size:0.7rem;padding:2px 0">' + esc(compVal || '—') + '</td></tr>';
}
var mySpeed = t.speed_gbps >= 1000 ? (t.speed_gbps / 1000).toFixed(1).replace('.0','') + 'T' : t.speed_gbps + 'G';
var compSpeed = p.comp_speed_gbps ? (p.comp_speed_gbps >= 1000 ? (p.comp_speed_gbps/1000).toFixed(1).replace('.0','')+'T' : p.comp_speed_gbps+'G') : null;
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">';
// Header: vendor + part + price + savings badge
h += '<div style="display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.75rem;background:rgba(255,255,255,0.03);border-bottom:1px solid var(--border)">';
h += '<div>';
h += '<span style="font-size:0.78rem;font-weight:700;color:var(--accent)">' + esc(p.vendor_name) + '</span>';
h += '<span style="font-size:0.7rem;color:#888;margin-left:0.5rem">' + esc(p.comparable_part || '—') + '</span>';
h += '</div>';
h += '<div style="display:flex;align-items:center;gap:0.4rem">';
var priceDisplayEur = compEur ? ('EUR\u00a0' + compEur.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})) : '';
h += '<span style="font-size:0.82rem;font-weight:700;color:var(--text)">' + priceDisplayEur + '</span>';
if (p.url) h += '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none">↗</a>';
h += '</div>';
h += '</div>';
// Savings badge row
if (savBadge) {
h += '<div style="padding:0.3rem 0.75rem;background:rgba(255,255,255,0.02);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:0.5rem">';
h += savBadge;
h += '<span style="font-size:0.68rem;color:#666">vs. Flexoptix Listenpreis</span>';
h += '</div>';
}
// Spec comparison table: Flexoptix (links) vs. Wettbewerber (rechts)
h += '<div style="padding:0.5rem 0.75rem">';
h += '<table style="width:100%;border-collapse:collapse">';
h += '<thead><tr>';
h += '<th style="font-size:0.67rem;color:#555;text-align:left;padding-bottom:4px;padding-right:8px"></th>';
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px;padding-right:8px">Flexoptix</th>';
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px">' + esc(p.vendor_name) + '</th>';
h += '</tr></thead><tbody>';
h += specRow('Form Factor', t.form_factor, p.comp_form_factor);
h += specRow('Speed', mySpeed, compSpeed);
h += specRow('Reach', t.reach_label || (t.reach_meters ? t.reach_meters + 'm' : null), p.comp_reach_label || (p.comp_reach_meters ? p.comp_reach_meters + 'm' : null));
h += specRow('Fiber', t.fiber_type, p.comp_fiber_type);
if (t.wavelengths || p.comp_wavelengths) h += specRow('Wavelengths', t.wavelengths, p.comp_wavelengths);
h += '</tbody></table>';
h += '<div style="font-size:0.65rem;color:#555;margin-top:0.35rem">🕐 Stand: ' + fmtDate(p.observed_at) + (p.is_verified ? ' &nbsp;·&nbsp; <span style="color:#2d6a4f">✓ Verified</span>' : '') + '</div>';
h += '</div>';
h += '</div>'; // card end
});
}
}
// No competitor prices → show "Kein Markt" info block with last scan date
if (!cVer && t.last_competitor_scan) {
h += '<div class="panel-section">Wettbewerber-Verfügbarkeit</div>';
h += '<div style="display:flex;align-items:flex-start;gap:0.75rem;padding:0.75rem 0.9rem;background:rgba(220,38,38,0.07);border:1px solid rgba(220,38,38,0.25);border-radius:8px;margin-bottom:0.5rem">';
h += '<span style="font-size:1.1rem;margin-top:1px">🔴</span>';
h += '<div>';
h += '<div style="font-size:0.82rem;font-weight:700;color:#dc2626;margin-bottom:0.25rem">Kein Wettbewerber bietet dieses Produkt an</div>';
h += '<div style="font-size:0.78rem;color:var(--text-dim);line-height:1.5">';
h += 'Keiner unserer ' + (t.speed_gbps >= 800 ? '60+' : '60+') + ' überwachten Anbieter (FS.com, ATGBICS, Prolabs, Skylane u.a.) hat ein identisches oder vergleichbares Produkt im Sortiment.';
h += '</div>';
h += '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.4rem">';
h += '🕐 Letzter Competitor-Scan: <strong style="color:var(--text)">' + fmtDate(t.last_competitor_scan) + '</strong>';
h += ' &nbsp;·&nbsp; Scans laufen täglich automatisch';
h += '</div>';
h += '</div>';
h += '</div>';
}
// No price_observations at all → show nothing. Never display estimated prices.
// Notes (scraped extra specs)
if (t.notes) {
h += '<div class="panel-section">Additional Specifications</div>';
var noteItems = t.notes.split('; ');
h += '<div class="spec-table">';
for (var n = 0; n < noteItems.length; n++) {
var parts = noteItems[n].split(': ');
if (parts.length >= 2) {
h += '<div class="spec-row"><span class="spec-label">' + esc(parts[0]) + '</span><span class="spec-val">' + esc(parts.slice(1).join(': ')) + '</span></div>';
}
}
h += '</div>';
}
// Use Cases
var useCases = classifyUseCase(t);
if (useCases.length > 0) {
h += '<div class="panel-section">Typical Use Cases</div>';
for (var u = 0; u < useCases.length; u++) {
h += '<div class="use-case-card">'
+ '<span class="use-case-icon">' + useCases[u].icon + '</span>'
+ '<div><div class="use-case-label">' + esc(useCases[u].label) + '</div>'
+ '<div class="use-case-desc">' + esc(useCases[u].desc) + '</div></div>'
+ '</div>';
}
}
// Documents & Links
var links = [];
if (t.product_page_url) links.push('<a href="' + esc(t.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Product Page</a>');
if (t.datasheet_url) links.push('<a href="' + esc(t.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Datasheet (PDF)</a>');
if (t.image_url) links.push('<a href="' + esc(t.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:600">Full Image</a>');
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
buildDOM(el('panel-content'), h);
// Load compatible switches async
api('/api/transceivers/' + id + '/compatibility').then(function(cdata) {
var swList = cdata.data || [];
if (swList.length === 0) return;
var groups = {};
swList.forEach(function(sw) {
var key = sw.vendor_name || 'Other';
if (!groups[key]) groups[key] = [];
groups[key].push(sw);
});
var ch = '<div class="panel-section">Compatible Switches <span class="b b-green" style="margin-left:0.5rem">' + swList.length + '</span></div>';
ch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Switches verified to work with this transceiver</div>';
Object.keys(groups).sort().forEach(function(vendor) {
var items = groups[vendor];
ch += '<div style="margin:0.6rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--accent)">' + esc(vendor) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem">';
items.slice(0, 12).forEach(function(sw) {
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.7rem" onclick="openSwitchDetail(\'' + esc(sw.id) + '\')">' + esc(sw.model) + '</span>';
});
if (items.length > 12) ch += '<span class="dim" style="font-size:0.7rem">+' + (items.length - 12) + ' more</span>';
ch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('tx-search-btn').addEventListener('click', searchTransceivers);
el('tx-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchTransceivers(); });
el('tx-ff-filter').addEventListener('change', searchTransceivers);
el('tx-vendor-filter').addEventListener('change', searchTransceivers);
// Populate vendor dropdown
api('/api/vendors').then(function(data) {
var vendors = (data.data || []).filter(function(v) { return parseInt(v.transceiver_count) > 0; });
vendors.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
var sel = el('tx-vendor-filter');
vendors.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v.name;
opt.textContent = v.name + ' (' + v.transceiver_count + ')';
sel.appendChild(opt);
});
});
// CSV Export
el('tx-export-btn').addEventListener('click', function() {
if (!lastTxData.length) return;
var cols = ['standard_name','vendor_name','form_factor','speed','speed_gbps','reach_label','reach_meters','fiber_type','connector','wdm_type','category','market_status','price_tier','msrp_usd','street_price_usd'];
var csv = cols.join(',') + '\n';
lastTxData.forEach(function(t) {
csv += cols.map(function(c) {
var v = t[c] != null ? String(t[c]).replace(/"/g, '""') : '';
return '"' + v + '"';
}).join(',') + '\n';
});
var blob = new Blob([csv], { type: 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'transceivers-' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
});
// Compare
el('tx-compare-btn').addEventListener('click', openCompare);
function openCompare() {
var checked = document.querySelectorAll('.compare-cb:checked');
if (checked.length < 2) { alert('Select at least 2 transceivers to compare.'); return; }
if (checked.length > 6) { alert('Select at most 6 transceivers to compare.'); return; }
var ids = [];
checked.forEach(function(cb) { ids.push(cb.getAttribute('data-id')); });
var items = lastTxData.filter(function(t) { return ids.indexOf(t.id) !== -1; });
var overlay = el('compare-overlay');
overlay.classList.add('visible');
var fields = [
['Vendor', 'vendor_name'], ['Form Factor', 'form_factor'], ['Speed', 'speed'],
['Speed (Gbps)', 'speed_gbps'], ['Reach', 'reach_label'], ['Reach (m)', 'reach_meters'],
['Fiber', 'fiber_type'], ['Connector', 'connector'], ['WDM', 'wdm_type'],
['Wavelengths', 'wavelengths'], ['Power (W)', 'power_consumption_w'],
['Temp Range', 'temp_range'], ['Category', 'category'], ['Market Status', 'market_status'],
['Price Tier', 'price_tier'], ['MSRP ($)', 'msrp_usd'], ['Street Price ($)', 'street_price_usd'],
];
var h = '<div class="compare-panel">';
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">';
h += '<h3 style="margin:0;font-size:1.1rem">Compare Transceivers (' + items.length + ')</h3>';
h += '<button id="compare-close-btn" class="btn" style="background:var(--red);color:#fff;padding:0.3rem 0.8rem">Close</button>';
h += '</div>';
h += '<div class="compare-table"><table><thead><tr><th>Spec</th>';
items.forEach(function(t) { h += '<th>' + esc(t.standard_name || t.slug) + '</th>'; });
h += '</tr></thead><tbody>';
fields.forEach(function(f) {
var vals = items.map(function(t) { return t[f[1]] != null ? String(t[f[1]]) : '—'; });
var allSame = vals.every(function(v) { return v === vals[0]; });
// Find best price (lowest)
var isBest = [];
if (f[1] === 'msrp_usd' || f[1] === 'street_price_usd') {
var nums = vals.map(function(v) { return parseFloat(v) || Infinity; });
var mn = Math.min.apply(null, nums);
isBest = nums.map(function(n) { return n === mn && n !== Infinity; });
}
h += '<tr><td style="font-weight:600">' + esc(f[0]) + '</td>';
vals.forEach(function(v, i) {
var cls = allSame ? '' : ' class="compare-diff"';
if (isBest.length && isBest[i]) cls = ' class="compare-best"';
h += '<td' + cls + '>' + esc(v) + '</td>';
});
h += '</tr>';
});
h += '</tbody></table></div></div>';
el('compare-content').innerHTML = h;
el('compare-close-btn').addEventListener('click', function() {
overlay.classList.remove('visible');
});
}
// Vendor URL builder
function buildVendorUrl(vendorName, model) {
var v = (vendorName || '').toLowerCase();
if (v.includes('cisco')) {
if (model.startsWith('N9K') || model.startsWith('N3K') || model.startsWith('N5K') || model.startsWith('N7K'))
return 'https://www.cisco.com/c/en/us/products/switches/nexus-' + model.replace(/^N(\d)K.*/, '$1000') + '-series-switches/index.html';
if (model.startsWith('C93') || model.startsWith('C92') || model.startsWith('C95'))
return 'https://www.cisco.com/c/en/us/products/switches/catalyst-' + model.replace(/^C/, '') + '/index.html';
return 'https://www.cisco.com/c/en/us/products/switches/index.html';
}
if (v.includes('arista')) {
var series = model.replace(/^DCS-/, '').replace(/-.*$/, '');
return 'https://www.arista.com/en/products/' + series.toLowerCase() + '-series';
}
if (v.includes('juniper')) {
if (model.startsWith('QFX')) return 'https://www.juniper.net/us/en/products/switches/qfx-series.html';
if (model.startsWith('EX')) return 'https://www.juniper.net/us/en/products/switches/ex-series.html';
return 'https://www.juniper.net/us/en/products/switches.html';
}
if (v.includes('mikrotik')) return 'https://mikrotik.com/product/' + model.replace(/[-\s]+/g, '_');
if (v.includes('fortinet')) return 'https://www.fortinet.com/products/switches';
if (v.includes('ubiquiti') || v.includes('ui.com')) return 'https://store.ui.com/us/en/collections/switching';
if (v.includes('netgear')) return 'https://www.netgear.com/business/wired/switches/' + model.toLowerCase() + '/';
if (v.includes('tp-link')) return 'https://www.tp-link.com/us/business-networking/managed-switch/' + model.toLowerCase() + '/';
if (v.includes('zyxel')) return 'https://www.zyxel.com/products/' + model + '/';
if (v.includes('dell')) return 'https://www.dell.com/en-us/shop/networking/cp/networking-switches';
if (v.includes('hpe') || v.includes('aruba')) return 'https://www.arubanetworks.com/products/switches/';
if (v.includes('mellanox') || v.includes('nvidia')) return 'https://www.nvidia.com/en-us/networking/ethernet-switching/';
if (v.includes('edgecore')) return 'https://www.edge-core.com/product_category-switches.html';
if (v.includes('celestica')) return 'https://www.celestica.com/enterprise-solutions/networking';
if (v.includes('accton')) return 'https://www.edge-core.com/';
return null;
}
// SWITCHES
function searchSwitches() {
var q = el('sw-search').value;
var cat = el('sw-cat-filter').value;
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (cat) params.push('category=' + encodeURIComponent(cat));
params.push('limit=100');
api('/api/switches?' + params.join('&')).then(function(data) {
var items = data.data || data.switches || [];
buildDOM(el('sw-table'), items.map(function(s) {
var catColors = { DataCenter: 'b-blue', Campus: 'b-green', SP: 'b-purple', Core: 'b-orange', Edge: 'b-cyan', Industrial: 'b-yellow' };
var statusColors = { Active: 'b-green', 'EoS_Announced': 'b-yellow', EoL: 'b-red', Legacy: 'b-neutral' };
var maxSpd = s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : s.max_speed_gbps + 'G';
var cap = s.switching_capacity_tbps ? s.switching_capacity_tbps + ' Tbps' : '—';
// Thumbnail — show image if available, otherwise a switch icon
var thumb = s.image_url
? '<img src="' + esc(s.image_url) + '" alt="" style="width:48px;height:34px;object-fit:contain;border-radius:4px;background:var(--surface2);vertical-align:middle;display:block;margin:0 auto" loading="lazy" onerror="this.outerHTML=\'<span style=font-size:1.3rem;opacity:0.35>&#9881;</span>\'">'
: '<span style="font-size:1.3rem;opacity:0.3;display:block;text-align:center">&#9881;</span>';
var modelTitle = s.description ? ' title="' + esc(s.description.slice(0, 120)) + '"' : '';
return '<tr class="clickable" data-swid="' + esc(s.id) + '">'
+ '<td style="padding:4px 8px;text-align:center;vertical-align:middle">' + thumb + '</td>'
+ '<td style="font-weight:600;color:var(--text-bright)"' + modelTitle + '>' + esc(s.model) + '</td>'
+ '<td>' + esc(s.vendor_name || '') + '</td>'
+ '<td class="mono dim">' + esc(s.series || '') + '</td>'
+ '<td><span class="b ' + (catColors[s.category] || 'b-neutral') + '">' + esc(s.category || '') + '</span></td>'
+ '<td class="mono">' + esc(s.total_ports || '—') + '</td>'
+ '<td class="mono">' + esc(maxSpd) + '</td>'
+ '<td class="mono">' + esc(cap) + '</td>'
+ '<td class="dim">' + esc(s.asic_vendor ? s.asic_vendor + (s.asic_model ? ' ' + s.asic_model : '') : '—') + '</td>'
+ '<td><span class="b ' + (statusColors[s.lifecycle_status] || 'b-neutral') + '">' + esc(s.lifecycle_status || 'Active') + '</span></td>'
+ '</tr>';
}).join('') || '<tr><td colspan="10" class="loading">No switches found</td></tr>');
el('sw-table').querySelectorAll('tr.clickable').forEach(function(row) {
row.addEventListener('click', function() { openSwitchDetail(this.getAttribute('data-swid')); });
});
}).catch(function(err) {
buildDOM(el('sw-table'), '<tr><td colspan="10" class="loading">Error loading switches</td></tr>');
});
}
async function openSwitchDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/switches/' + id);
var s = data.data || data;
// Image — real photo or placeholder
var h = '';
if (s.image_url) {
h += '<div class="tx-image-box has-photo">';
h += '<img src="' + esc(s.image_url) + '" alt="' + esc(s.model) + '" style="max-width:100%;border-radius:8px" onerror="this.onerror=null;this.outerHTML=\'<span style=font-size:1.5rem;color:var(--text-dim)>&#9881; ' + esc(s.model) + '</span>\'">';
if (s.vendor_name) h += '<span class="img-badge">' + esc(s.vendor_name) + '</span>';
} else {
h += '<div class="tx-image-box has-svg">';
h += '<span style="font-size:1.5rem;color:var(--text-dim)">&#9881; ' + esc(s.model) + '</span>';
}
if (s.product_page_url) {
h += '<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" class="img-link">View on ' + esc(s.vendor_name || 'Vendor') + ' &rarr;</a>';
}
h += '</div>';
h += '<div class="panel-title">' + esc(s.model) + '</div>';
h += '<div class="panel-sub">' + esc(s.vendor_name || '') + (s.series ? ' &mdash; ' + esc(s.series) : '') + '</div>';
// Data quality indicators for switch
var swQual = [];
if (s.image_url && !s.image_url.includes('placeholder')) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Image</span>');
if (s.product_page_url) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Product Page</span>');
else swQual.push('<span style="color:#aaa;font-size:0.75rem">⚠ Estimated URL</span>');
if (s.datasheet_r2_key) swQual.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Datasheet</span>');
if (swQual.length > 0) {
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.07);border:1px solid rgba(45,106,79,0.18);border-radius:6px">'
+ swQual.join('<span style="color:#ccc;font-size:0.7rem">·</span>')
+ '</div>';
}
h += '<div class="panel-grid">';
h += '<div class="panel-stat"><div class="panel-stat-label">Category</div><div class="panel-stat-val" style="font-size:1rem">' + esc(s.category || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Total Ports</div><div class="panel-stat-val">' + esc(s.total_ports || '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Switching Capacity</div><div class="panel-stat-val">' + (s.switching_capacity_tbps ? s.switching_capacity_tbps + ' <small>Tbps</small>' : '—') + '</div></div>';
h += '<div class="panel-stat"><div class="panel-stat-label">Max Speed</div><div class="panel-stat-val">' + (s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : (s.max_speed_gbps || '—') + 'G') + '</div></div>';
h += '</div>';
h += '<div class="panel-section">Specifications</div>';
var specs = [
['Layer', s.layer], ['ASIC', (s.asic_vendor || '') + ' ' + (s.asic_model || '')],
['Forwarding Rate', s.forwarding_rate_mpps ? s.forwarding_rate_mpps + ' Mpps' : null],
['Rack Units', s.rack_units ? s.rack_units + 'U' : null],
['Typical Power', s.typical_power_w ? s.typical_power_w + 'W' : null],
['Max Power', s.max_power_w ? s.max_power_w + 'W' : null],
['Weight', s.weight_kg ? s.weight_kg + ' kg' : null],
['PoE', s.poe_support && s.poe_support !== 'None' ? s.poe_support : null],
['Status', s.lifecycle_status],
];
for (var i = 0; i < specs.length; i++) {
if (specs[i][1] && String(specs[i][1]).trim()) {
h += '<div class="panel-row"><span class="panel-row-label">' + esc(specs[i][0]) + '</span><span class="panel-row-val">' + esc(specs[i][1]) + '</span></div>';
}
}
// Certifications
if (s.certifications && s.certifications.length > 0) {
h += '<div class="panel-row"><span class="panel-row-label">Certifications</span><span class="panel-row-val" style="display:flex;gap:0.3rem;flex-wrap:wrap">'
+ s.certifications.map(function(c) { return '<span style="background:rgba(99,102,241,0.12);color:#818cf8;font-size:0.65rem;padding:1px 6px;border-radius:8px;font-weight:600">' + esc(c) + '</span>'; }).join('')
+ '</span></div>';
}
h += '<div class="panel-section">Features</div>';
var features = [];
// Use JSONB features array from DB if populated, fall back to boolean flags
if (s.features && Array.isArray(s.features) && s.features.length > 0) {
features = s.features;
} else {
if (s.vxlan_support) features.push('VXLAN');
if (s.evpn_support) features.push('EVPN');
if (s.bgp_support) features.push('BGP');
if (s.mpls_support) features.push('MPLS');
if (s.openconfig_support) features.push('OpenConfig');
if (s.sonic_compatible) features.push('SONiC');
if (s.macsec_support) features.push('MACsec');
if (s.stacking_support) features.push('Stacking');
}
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + features.map(function(f) { return '<span class="b b-cyan">' + esc(f) + '</span>'; }).join('') + (features.length === 0 ? '<span class="dim">None listed</span>' : '') + '</div>';
// Description
if (s.description) {
h += '<div class="panel-section">Description</div>';
h += '<div style="font-size:0.78rem;color:var(--text-dim);line-height:1.6;padding:0.25rem 0">' + esc(s.description) + '</div>';
}
// eBay Refurbished Price
if (s.ebay_refurb_price_usd) {
h += '<div class="panel-section">Market Pricing</div>';
h += '<div class="panel-row"><span class="panel-row-label">Refurbished (eBay)</span><span class="panel-row-val" style="color:var(--yellow)">€' + parseFloat(s.ebay_refurb_price_usd).toFixed(0) + ' <span style="font-size:0.7rem;color:var(--text-dim)">(incl. warranty, market)</span></span></div>';
if (s.msrp_usd) h += '<div class="panel-row"><span class="panel-row-label">List Price (MSRP)</span><span class="panel-row-val">$' + parseFloat(s.msrp_usd).toFixed(0) + '</span></div>';
}
if (s.ports_config && Object.keys(s.ports_config).length > 0) {
h += '<div class="panel-section">Port Configuration</div>';
Object.keys(s.ports_config).forEach(function(k) {
h += '<div class="panel-row"><span class="panel-row-label">' + esc(k.replace(/_/g, ' ')) + '</span><span class="panel-row-val">' + esc(s.ports_config[k]) + 'x</span></div>';
});
}
// Known Issues placeholder (loaded async)
h += '<div class="panel-section" id="sw-issues-hdr-' + id + '" style="display:none">Known Issues <span id="sw-issues-cnt-' + id + '" style="background:#ff4d4d18;color:#ff4d4d;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-issues-body-' + id + '"></div>';
// Documents placeholder (loaded async)
h += '<div class="panel-section" id="sw-docs-hdr-' + id + '" style="display:none">Datasheets &amp; Manuals <span id="sw-docs-cnt-' + id + '" style="background:#2563eb18;color:#4287f5;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-docs-body-' + id + '"></div>';
var links = [];
if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>');
if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>');
if (s.catalog_url) links.push('<a href="' + esc(s.catalog_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Catalog</a>');
if (s.image_url) links.push('<a href="' + esc(s.image_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Full Image</a>');
if (!s.product_page_url && s.vendor_name && s.model) {
var vendorUrl = buildVendorUrl(s.vendor_name, s.model);
if (vendorUrl) links.push('<a href="' + esc(vendorUrl) + '" target="_blank" rel="noopener" style="color:var(--yellow);text-decoration:none;font-weight:600;font-size:0.8rem">Vendor Page (estimated)</a>');
}
if (links.length > 0) {
h += '<div class="panel-section">Documents &amp; Links</div>';
h += '<div style="display:flex;gap:1rem;flex-wrap:wrap;padding:0.5rem 0">' + links.join('') + '</div>';
}
if (s.is_whitebox) {
h += '<div class="panel-section">Open Networking</div>';
var nos = [];
if (s.sonic_compatible) nos.push('SONiC');
if (s.onl_compatible) nos.push('ONL');
if (s.dent_compatible) nos.push('DENT');
if (s.cumulus_compatible) nos.push('Cumulus');
if (s.fboss_compatible) nos.push('FBOSS');
if (s.onie_support) nos.push('ONIE');
if (s.supported_nos && s.supported_nos.length) nos = nos.concat(s.supported_nos);
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + nos.map(function(n) { return '<span class="b b-green">' + esc(n) + '</span>'; }).join('') + '</div>';
if (s.sonic_hwsku) h += '<div class="panel-row"><span class="panel-row-label">SONiC HWSKU</span><span class="panel-row-val mono">' + esc(s.sonic_hwsku) + '</span></div>';
if (s.cpu) h += '<div class="panel-row"><span class="panel-row-label">CPU</span><span class="panel-row-val">' + esc(s.cpu) + (s.cpu_cores ? ' (' + s.cpu_cores + ' cores)' : '') + '</span></div>';
if (s.ram_gb) h += '<div class="panel-row"><span class="panel-row-label">RAM</span><span class="panel-row-val">' + esc(s.ram_gb) + ' GB</span></div>';
}
buildDOM(el('panel-content'), h);
// Async: load known issues
api('/api/switches/' + id + '/issues').then(function(idata) {
var issues = idata.data || [];
if (issues.length === 0) return;
var hdr = document.getElementById('sw-issues-hdr-' + id);
var cnt = document.getElementById('sw-issues-cnt-' + id);
var body = document.getElementById('sw-issues-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = issues.length;
var ih = '';
var severityColors = { critical: '#ff4d4d', warning: '#f59e0b', info: '#6b7280' };
var severityIcons = { critical: '🔴', warning: '⚠️', info: '' };
issues.forEach(function(issue) {
var col = severityColors[issue.severity] || '#6b7280';
var icon = severityIcons[issue.severity] || '';
ih += '<div style="border-left:3px solid ' + col + ';padding:0.5rem 0.75rem;margin:0.4rem 0;background:' + col + '10;border-radius:0 4px 4px 0">';
ih += '<div style="font-size:0.78rem;font-weight:600;color:' + col + '">' + icon + ' ' + esc(issue.title) + '</div>';
if (issue.summary) ih += '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem;line-height:1.5">' + esc(issue.summary) + '</div>';
var meta = [];
if (issue.source_name) meta.push('<a href="' + esc(issue.source_url || '#') + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.68rem;text-decoration:none">' + esc(issue.source_name) + ' ↗</a>');
if (issue.affected_firmware) meta.push('<span style="font-size:0.68rem;color:var(--text-dim)">Affects: ' + esc(issue.affected_firmware) + '</span>');
if (issue.fix_firmware) meta.push('<span style="font-size:0.68rem;color:#22c55e">Fixed in: ' + esc(issue.fix_firmware) + '</span>');
if (issue.is_resolved) meta.push('<span style="font-size:0.68rem;color:#22c55e">✓ Resolved</span>');
if (issue.issue_tags && issue.issue_tags.length) {
issue.issue_tags.forEach(function(tag) { meta.push('<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px">' + esc(tag) + '</span>'); });
}
if (meta.length) ih += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.3rem;align-items:center">' + meta.join('') + '</div>';
ih += '</div>';
});
buildDOM(body, ih);
}).catch(function() {});
// Async: load datasheets & manuals
api('/api/switches/' + id + '/documents').then(function(ddata) {
var docs = ddata.data || [];
if (docs.length === 0) return;
var hdr = document.getElementById('sw-docs-hdr-' + id);
var cnt = document.getElementById('sw-docs-cnt-' + id);
var body = document.getElementById('sw-docs-body-' + id);
if (!hdr || !body) return;
hdr.style.display = '';
if (cnt) cnt.textContent = docs.length;
var docTypeLabels = { datasheet: '📄 Datasheet', user_guide: '📖 Manual', release_notes: '📋 Release Notes', app_note: '📝 App Note', product_page: '🌐 Product Page', config_guide: '⚙️ Config Guide' };
var docTypeColors = { datasheet: '#ff6600', user_guide: '#4287f5', release_notes: '#22c55e', app_note: '#f59e0b', product_page: '#8b5cf6', config_guide: '#06b6d4' };
var dh = '<div style="display:flex;flex-direction:column;gap:0.35rem;padding:0.25rem 0">';
docs.forEach(function(doc) {
var url = doc.download_url || doc.source_url;
var label = docTypeLabels[doc.doc_type] || doc.doc_type;
var col = docTypeColors[doc.doc_type] || 'var(--accent)';
var officialBadge = doc.is_official ? '<span style="background:#22c55e18;color:#22c55e;font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.4rem">Official</span>' : '';
var langBadge = doc.language && doc.language !== 'en' ? '<span style="background:#ffffff10;color:var(--text-dim);font-size:0.65rem;padding:1px 5px;border-radius:8px;margin-left:0.3rem">' + esc(doc.language.toUpperCase()) + '</span>' : '';
dh += '<div style="display:flex;align-items:center;gap:0.5rem">';
if (url) {
dh += '<a href="' + esc(url) + '" target="_blank" rel="noopener" style="color:' + col + ';font-size:0.78rem;font-weight:600;text-decoration:none">' + label + '</a>';
} else {
dh += '<span style="color:' + col + ';font-size:0.78rem;font-weight:600">' + label + '</span>';
}
dh += '<span style="font-size:0.72rem;color:var(--text-dim)">' + esc(doc.title) + '</span>';
dh += officialBadge + langBadge;
dh += '</div>';
});
dh += '</div>';
buildDOM(body, dh);
}).catch(function() {});
// ── Load Flexoptix orderable transceivers (form-factor based, always works) ──
api('/api/switches/' + id + '/flexoptix').then(function(foData) {
var foAll = foData.data || [];
if (foAll.length === 0) return;
var fch = '';
fch += '<div class="panel-section" style="color:#ff6600;margin-top:1rem;display:flex;align-items:center;gap:0.5rem">'
+ '<span>Bei Flexoptix bestellen</span>'
+ '<span style="background:#ff660018;color:#ff6600;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px">' + foAll.length + '</span>'
+ '</div>';
fch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.6rem">Passend für diesen Switch — FlexBox-Codierung möglich</div>';
// Group by speed class
var foGroups = {};
foAll.forEach(function(t) {
var key = (t.speed_gbps ? t.speed_gbps + 'G' : (t.speed || '?')) + ' ' + (t.form_factor || '?');
if (!foGroups[key]) foGroups[key] = [];
foGroups[key].push(t);
});
// Sort speed groups descending (highest speed first)
var foKeys = Object.keys(foGroups).sort(function(a, b) {
var ga = parseFloat(a) || 0, gb = parseFloat(b) || 0;
return gb - ga;
});
foKeys.forEach(function(key) {
var items = foGroups[key];
fch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.8rem;color:var(--text-bright)">'
+ esc(key) + '<span style="font-weight:400;font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem">(' + items.length + ')</span></div>';
fch += '<div style="display:flex;flex-direction:column;gap:0.3rem">';
items.slice(0, 10).forEach(function(t) {
var priceStr = '';
if (t.latest_price) {
var _pAmt = parseFloat(t.latest_price);
var _pCur = (t.latest_currency || 'EUR').toUpperCase();
var _pEUR = toEUR(_pAmt, _pCur);
var _pUSD = toUSD(_pAmt, _pCur);
priceStr = _pEUR !== null ? fmtEUR(_pEUR) : (_pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2));
}
var shopHref = t.product_page_url || ('https://www.flexoptix.net/en/search/ajax/suggest/?q=' + encodeURIComponent(t.part_number || t.standard_name || ''));
var reach = t.reach_label ? '<span style="color:var(--text-dim);font-size:0.68rem;margin-left:0.25rem">' + esc(t.reach_label) + '</span>' : '';
fch += '<div style="display:flex;align-items:center;padding:0.35rem 0.5rem;background:rgba(255,102,0,0.05);border:1px solid rgba(255,102,0,0.2);border-radius:6px;gap:0.35rem;cursor:pointer" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<div style="flex:1;min-width:0">'
+ '<span style="font-weight:600;font-size:0.8rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
+ reach
+ '</div>'
+ (priceStr
? '<span style="font-weight:700;font-size:0.78rem;color:#ff6600;white-space:nowrap">' + priceStr + '</span>'
: '<span style="font-size:0.68rem;color:var(--text-dim)">Preis anfragen</span>')
+ '<a href="' + esc(shopHref) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="background:#ff6600;color:#fff;font-size:0.65rem;font-weight:700;padding:2px 7px;border-radius:4px;text-decoration:none;white-space:nowrap;flex-shrink:0">Bestellen ↗</a>'
+ '</div>';
});
if (items.length > 10) {
fch += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0.5rem">+' + (items.length - 10) + ' weitere Flexoptix-Optionen</div>';
}
fch += '</div>';
});
el('panel-content').insertAdjacentHTML('beforeend', fch);
}).catch(function() {});
// ── Load compatibility table (vendor-tested + competitor data) ────────────
api('/api/switches/' + id + '/compatibility').then(function(cdata) {
var txList = cdata.data || cdata.transceivers || [];
if (txList.length === 0) return;
// Only show non-Flexoptix here — Flexoptix already shown via /flexoptix
var otherList = txList.filter(function(t) { return (t.vendor_name || '').toLowerCase() !== 'flexoptix'; });
if (otherList.length === 0) return;
var verifiedOthers = otherList.filter(function(t) {
return t.verification_method === 'vendor_matrix' || t.verification_method === 'vendor_compat';
});
var specOthers = otherList.filter(function(t) {
return t.verification_method !== 'vendor_matrix' && t.verification_method !== 'vendor_compat';
});
var ch = '';
ch += '<div class="panel-section">Competitor Transceivers <span class="b b-green" style="margin-left:0.5rem">' + otherList.length + '</span>'
+ (verifiedOthers.length > 0 ? '<span style="font-size:0.67rem;color:#888;margin-left:0.5rem">(' + verifiedOthers.length + ' vendor-tested)</span>' : '') + '</div>';
// Vendor-tested with price
if (verifiedOthers.length > 0) {
var groups = {};
verifiedOthers.forEach(function(t) {
var key = (t.form_factor || '?') + ' ' + (t.speed || '?');
if (!groups[key]) groups[key] = [];
groups[key].push(t);
});
Object.keys(groups).sort().forEach(function(key) {
var items = groups[key];
ch += '<div style="margin:0.5rem 0 0.3rem;font-weight:600;font-size:0.76rem;color:var(--text-bright)">' + esc(key) + ' <span class="dim" style="font-weight:400">(' + items.length + ')</span></div>';
ch += '<div style="display:flex;flex-direction:column;gap:0.25rem">';
items.slice(0, 6).forEach(function(t) {
var priceStr = '';
if (t.latest_price) {
var _pAmt = parseFloat(t.latest_price);
var _pCur = (t.latest_currency || 'USD').toUpperCase();
var _pUSD = toUSD(_pAmt, _pCur);
priceStr = _pUSD !== null ? fmtUSD(_pUSD) : _pCur + ' ' + _pAmt.toFixed(2);
}
ch += '<div style="display:flex;align-items:center;gap:0.4rem;padding:0.25rem 0.4rem;background:var(--surface2);border-radius:5px;cursor:pointer;font-size:0.72rem" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<span style="font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.standard_name) + '</span>'
+ '<span style="color:var(--text-dim)">' + esc(t.vendor_name || '') + '</span>'
+ (priceStr ? '<span style="color:#f59e0b;margin-left:auto">' + priceStr + '</span>' : '')
+ '</div>';
});
if (items.length > 6) ch += '<div style="font-size:0.68rem;color:var(--text-dim)">+' + (items.length - 6) + ' more</div>';
ch += '</div>';
});
}
// Spec-match as compact chips
if (specOthers.length > 0) {
ch += '<div style="margin-top:0.5rem">';
ch += '<div style="font-size:0.67rem;color:var(--text-dim);margin-bottom:0.3rem">Form factor compatible</div>';
ch += '<div style="display:flex;flex-wrap:wrap;gap:0.25rem">';
specOthers.slice(0, 20).forEach(function(t) {
var fullyBadge = (t.fully_verified === true) ? '★ ' : '';
ch += '<span class="b b-neutral" style="cursor:pointer;font-size:0.68rem" onclick="openTxDetail(\'' + esc(t.id) + '\')" title="' + esc(t.vendor_name || '') + (t.reach_label ? ' · ' + t.reach_label : '') + '">'
+ fullyBadge + esc(t.standard_name || t.slug || t.part_number) + '</span>';
});
if (specOthers.length > 20) ch += '<span class="dim" style="font-size:0.68rem">+' + (specOthers.length - 20) + ' more</span>';
ch += '</div></div>';
}
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
el('sw-search-btn').addEventListener('click', searchSwitches);
el('sw-search').addEventListener('keydown', function(e) { if (e.key === 'Enter') searchSwitches(); });
el('sw-cat-filter').addEventListener('change', searchSwitches);
// NEWS
var _newsPage = 1;
var _newsCategory = '';
async function loadNews(page) {
page = page || _newsPage;
_newsPage = page;
_newsCategory = (el('news-cat-filter') ? el('news-cat-filter').value : '') || '';
el('news-list').innerHTML = '<div class="loading pulse">Loading articles…</div>';
el('news-pagination').innerHTML = '';
var url = '/api/news?page=' + page + '&limit=10' + (_newsCategory ? '&category=' + encodeURIComponent(_newsCategory) : '');
var data = await api(url).catch(function() { return {}; });
// Populate category filter (once)
var catSel = el('news-cat-filter');
if (catSel && (data.categories || []).length > 0) {
var cur = catSel.value;
catSel.innerHTML = '<option value="">All Categories</option>';
(data.categories || []).forEach(function(c) {
catSel.innerHTML += '<option value="' + esc(c) + '"' + (c === cur ? ' selected' : '') + '>' + esc(c) + '</option>';
});
}
var total = data.total || 0;
var pages = data.pages || 1;
var meta = el('news-meta');
if (meta) meta.textContent = total + ' articles · Page ' + page + ' of ' + pages;
var articles = data.articles || [];
if (!articles.length) {
el('news-list').innerHTML = '<div style="color:var(--text-dim);padding:1.5rem;text-align:center">No articles yet — scrapers are collecting data.</div>';
return;
}
buildDOM(el('news-list'), articles.map(function(n) {
var urlSafe = (n.source_url && /^https?:\/\//.test(n.source_url)) ? n.source_url : '#';
var catColor = {
'Networking': '#e6a800', 'Data Center': '#2d6a4f', 'Optical': '#FF8100',
'AI Infrastructure': '#c1121f', 'Market': '#4287f5', 'NOG': '#8b5cf6'
}[n.category] || '#666';
return '<div class="ri">'
+ '<div class="ri-title">' + esc(n.title) + '</div>'
+ '<div class="ri-body">' + esc(n.summary || '') + '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-blue">' + esc(n.source || '') + '</span>'
+ (n.category ? '<span style="background:' + catColor + '22;color:' + catColor + ';padding:1px 6px;border-radius:8px;font-size:0.65rem;font-weight:600">' + esc(n.category) + '</span>' : '')
+ (n.relevance_score ? '<span style="color:var(--text-dim);font-size:0.68rem">relevance: ' + parseFloat(n.relevance_score).toFixed(2) + '</span>' : '')
+ (n.published_at ? '<span>' + new Date(n.published_at).toLocaleDateString('de-DE') + '</span>' : '')
+ (urlSafe !== '#' ? '<a href="' + esc(urlSafe) + '" target="_blank" rel="noopener noreferrer" style="color:var(--accent);text-decoration:none;font-size:0.72rem;font-weight:600">Read &rarr;</a>' : '')
+ '</div></div>';
}).join(''));
// Pagination buttons
if (pages > 1) {
var pag = '';
var btnStyle = 'padding:5px 12px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);color:var(--text);cursor:pointer;font-size:0.8rem';
var activeStyle = 'padding:5px 12px;border-radius:6px;border:1px solid var(--accent);background:var(--accent);color:white;cursor:pointer;font-size:0.8rem;font-weight:700';
if (page > 1) pag += '<button style="' + btnStyle + '" onclick="loadNews(' + (page-1) + ')">← Prev</button>';
var start = Math.max(1, page - 2);
var end = Math.min(pages, page + 2);
for (var p = start; p <= end; p++) {
pag += '<button style="' + (p === page ? activeStyle : btnStyle) + '" onclick="loadNews(' + p + ')">' + p + '</button>';
}
if (page < pages) pag += '<button style="' + btnStyle + '" onclick="loadNews(' + (page+1) + ')">Next →</button>';
el('news-pagination').innerHTML = pag;
}
}
// ── VENDORS TAB ──────────────────────────────────────────────────────────────
var _allVendors = [];
async function loadVendors() {
var grid = el('vendor-grid');
if (!grid) return;
// Always reload on tab switch (fresh data)
grid.innerHTML = '<div class="loading pulse">Loading vendors…</div>';
var data = await api('/api/vendors').catch(function() { return {}; });
// Use v.type (DB column name) — sort by transceiver count desc, then name
_allVendors = (data.data || []);
_allVendors.sort(function(a, b) {
var diff = parseInt(b.transceiver_count || 0) - parseInt(a.transceiver_count || 0);
return diff !== 0 ? diff : (a.name || '').localeCompare(b.name || '');
});
filterVendorCards();
}
function filterVendorCards() {
var grid = el('vendor-grid');
if (!grid) return;
var q = el('vendor-search') ? el('vendor-search').value.toLowerCase() : '';
var typ = el('vendor-type-filter') ? el('vendor-type-filter').value.toLowerCase() : '';
var filtered = _allVendors.filter(function(v) {
var vType = (v.type || '').toLowerCase(); // DB column is "type", not "vendor_type"
var matchQ = !q || (v.name || '').toLowerCase().includes(q)
|| (v.slug || '').toLowerCase().includes(q)
|| (v.headquarters || '').toLowerCase().includes(q);
// Map UI filter values to DB type values
var typeMap = { oem: ['oem','manufacturer'], compatible: ['compatible','reseller'], distributor: ['distributor'] };
var matchT = !typ || (typeMap[typ] || [typ]).indexOf(vType) !== -1;
return matchQ && matchT;
});
var cnt = el('vendor-count');
if (cnt) cnt.textContent = filtered.length + ' of ' + _allVendors.length + ' vendors';
if (!filtered.length) {
grid.innerHTML = '<div style="color:var(--text-dim);grid-column:1/-1;padding:1.5rem">No vendors match your filter.</div>';
return;
}
// Color by DB type value
var typeColors = { manufacturer: '#c1121f', oem: '#c1121f', compatible: '#2d6a4f', reseller: '#2d6a4f', distributor: '#4287f5' };
var html = '';
filtered.forEach(function(v) {
var tc = parseInt(v.transceiver_count || 0);
var vt = (v.type || 'unknown').toLowerCase();
var col = typeColors[vt] || '#888';
var colRgba = 'rgba(' + (col === '#c1121f' ? '193,18,31' : col === '#2d6a4f' ? '45,106,79' : col === '#4287f5' ? '66,135,245' : '136,136,136') + ',0.12)';
var initials = (v.name || v.slug || '?').substring(0, 2).toUpperCase();
html += '<div class="card" style="padding:0.85rem;cursor:pointer;border-left:3px solid ' + col + ';transition:box-shadow 0.15s" '
+ 'data-vendor-id="' + esc(v.id) + '" onclick="vendorCardClick(this)">'
// Logo placeholder + name
+ '<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem">'
+ '<div style="width:32px;height:32px;border-radius:6px;background:' + colRgba + ';display:flex;align-items:center;justify-content:center;font-weight:800;font-size:0.7rem;color:' + col + ';flex-shrink:0">' + initials + '</div>'
+ '<div>'
+ '<div style="font-weight:700;font-size:0.88rem;color:var(--text-bright);line-height:1.2">' + esc(v.name || v.slug) + '</div>'
+ (v.headquarters ? '<div style="font-size:0.65rem;color:var(--text-dim)">' + esc(v.headquarters) + '</div>' : '')
+ '</div>'
+ '</div>'
// Badges
+ '<div style="display:flex;gap:0.35rem;flex-wrap:wrap;margin-bottom:0.4rem">'
+ '<span style="background:' + colRgba + ';color:' + col + ';font-size:0.6rem;font-weight:700;padding:1px 7px;border-radius:8px;text-transform:uppercase">' + esc(v.type || 'unknown') + '</span>'
+ (tc > 0 ? '<span style="background:var(--surface2);color:var(--text-dim);font-size:0.6rem;padding:1px 7px;border-radius:8px">' + tc + ' products</span>' : '')
+ (v.founded_year ? '<span style="background:var(--surface2);color:var(--text-dim);font-size:0.6rem;padding:1px 7px;border-radius:8px">est. ' + v.founded_year + '</span>' : '')
+ '</div>'
+ (v.website ? '<a href="' + esc(v.website) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="font-size:0.67rem;color:var(--accent);text-decoration:none">↗ ' + esc(v.website.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]) + '</a>' : '')
+ '</div>';
});
grid.innerHTML = html;
}
function vendorCardClick(card) {
var vid = card.getAttribute('data-vendor-id');
if (!vid) return;
// Find vendor → filter by name (dropdown uses name as value)
var v = _allVendors.find(function(x) { return x.id === vid; });
if (!v) return;
goToTab('transceivers');
var vf = el('tx-vendor-filter');
if (vf) {
// Match by name (exact) since options are keyed by v.name
vf.value = v.name || '';
// If exact match not found (e.g. name casing differs), try to find matching option
if (vf.value !== v.name) {
var opts = vf.options;
for (var i = 0; i < opts.length; i++) {
if (opts[i].value.toLowerCase() === (v.name || '').toLowerCase()) {
vf.value = opts[i].value;
break;
}
}
}
}
searchTransceivers();
}
// ── CREATE VENDOR MODAL ──────────────────────────────────────────────────────
function openCreateVendorModal() {
var m = el('vendor-modal');
if (m) { m.style.display = 'flex'; m.style.alignItems = 'flex-start'; m.style.justifyContent = 'center'; }
// Show crawl notice by default (encourage adding website)
el('cv-website') && el('cv-website').addEventListener('input', function() {
var notice = el('cv-crawl-notice');
if (notice) notice.style.display = this.value.trim() ? 'block' : 'none';
}, { once: true });
}
function closeCreateVendorModal() {
var m = el('vendor-modal');
if (m) m.style.display = 'none';
// Reset form
['cv-name','cv-website','cv-shopurl','cv-hq','cv-market','cv-revenue','cv-employees','cv-specialties'].forEach(function(id) {
var el2 = el(id); if (el2) el2.value = '';
});
var yr = el('cv-year'); if (yr) yr.value = '';
var ct = el('cv-competitor'); if (ct) ct.checked = false;
var notice = el('cv-crawl-notice'); if (notice) notice.style.display = 'none';
}
async function submitCreateVendor() {
var nameEl = el('cv-name');
if (!nameEl || !nameEl.value.trim()) {
if (typeof showToast === 'function') showToast('Fehler', 'Name ist erforderlich', true);
nameEl && nameEl.focus();
return;
}
var btn = el('cv-submit');
if (btn) { btn.disabled = true; btn.textContent = 'Speichern…'; }
var payload = {
name: el('cv-name') ? el('cv-name').value.trim() : '',
type: el('cv-type') ? el('cv-type').value : 'compatible',
website: el('cv-website') ? el('cv-website').value.trim() : '',
shop_url: el('cv-shopurl') ? el('cv-shopurl').value.trim() : '',
headquarters: el('cv-hq') ? el('cv-hq').value.trim() : '',
market_position: el('cv-market') ? el('cv-market').value.trim() : '',
founded_year: el('cv-year') ? el('cv-year').value : '',
revenue_usd: el('cv-revenue') ? el('cv-revenue').value : '',
employee_count: el('cv-employees')? el('cv-employees').value : '',
specialties: el('cv-specialties')? el('cv-specialties').value.trim(): '',
is_competitor: el('cv-competitor') ? el('cv-competitor').checked : false,
};
try {
var token = window.loadToken ? window.loadToken() : (localStorage.getItem('tip_token') || '');
var resp = await fetch(API + '/api/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(payload)
});
var data = await resp.json();
if (data.success) {
if (typeof showToast === 'function') showToast('Vendor angelegt ✓', data.message || payload.name);
closeCreateVendorModal();
_allVendors = []; // Force reload
loadVendors();
} else {
if (typeof showToast === 'function') showToast('Fehler', data.error || 'Unbekannter Fehler', true);
}
} catch(err) {
if (typeof showToast === 'function') showToast('Netzwerkfehler', String(err), true);
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Vendor anlegen'; }
}
}
// Close modal on backdrop click
document.addEventListener('click', function(e) {
var modal = el('vendor-modal');
if (modal && e.target === modal) closeCreateVendorModal();
});
// ── STANDARDS TAB ────────────────────────────────────────────────────────────
var _allStandards = [];
async function loadStandardsList() {
var tbody = el('std-table');
if (!tbody) return;
// Sourcing activity: fetch procurement signals for hot demand
api('/api/procurement/signals?limit=20').then(function(d) {
var signals = (d.signals || []).slice(0, 8);
if (!signals.length) return;
var banner = el('sourcing-activity-banner');
if (!banner) return;
banner.innerHTML = '<div class="card" style="padding:0.75rem 1rem;border-left:3px solid #FF8100;margin-bottom:0">'
+ '<div style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#FF8100;margin-bottom:0.5rem">🔥 Sourcing Hype Cycle — Most Active Right Now</div>'
+ '<div style="display:flex;gap:0.5rem;flex-wrap:wrap">'
+ signals.map(function(s) {
var score = parseFloat(s.composite_score || s.market_score || 0);
var heat = score > 0.7 ? '#c1121f' : score > 0.4 ? '#FF8100' : '#2d6a4f';
var ff = Array.isArray(s.form_factors) ? s.form_factors[0] : (s.form_factors || s.form_factor || '');
return '<span style="background:' + heat + '22;color:' + heat + ';padding:2px 10px;border-radius:10px;font-size:0.72rem;font-weight:600;cursor:pointer" '
+ 'title="Composite score: ' + score.toFixed(2) + '">'
+ esc((s.speed_gbps ? s.speed_gbps + 'G ' : '') + (ff || s.name || s.category || ''))
+ '</span>';
}).join('')
+ '</div></div>';
}).catch(function() {});
if (_allStandards.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="loading pulse">Loading…</td></tr>';
var data = await api('/api/standards').catch(function() { return {}; });
_allStandards = data.data || [];
}
filterStandardsTable();
}
function filterStandardsTable() {
var tbody = el('std-table');
if (!tbody) return;
var q = (el('std-search') ? el('std-search').value.toLowerCase() : '');
var speed = (el('std-speed-filter') ? el('std-speed-filter').value : '');
var rows = _allStandards.filter(function(s) {
var ffStr = Array.isArray(s.form_factors) ? s.form_factors.join(' ') : (s.form_factors || '');
var matchQ = !q || (s.name || '').toLowerCase().includes(q)
|| (s.ieee_reference || '').toLowerCase().includes(q)
|| ffStr.toLowerCase().includes(q)
|| (s.speed || '').toLowerCase().includes(q);
var matchS = !speed || String(s.speed_gbps) === speed;
return matchQ && matchS;
});
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="10" style="color:var(--text-dim);padding:1rem">No standards match your filter.</td></tr>';
return;
}
var speedColors = { 1600: '#7c3aed', 800: '#c1121f', 400: '#FF8100', 200: '#e6a800', 100: '#2d6a4f', 40: '#4287f5', 25: '#0ea5e9', 10: '#888', 1: '#555' };
var statusColors = { ratified: '#2d6a4f', published: '#2d6a4f', draft: '#e6a800', deprecated: '#c1121f', emerging: '#FF8100' };
tbody.innerHTML = rows.map(function(s) {
var col = speedColors[s.speed_gbps] || '#888';
var sCl = statusColors[(s.status || '').toLowerCase()] || '#888';
// Form factors as mini badges
var ffs = Array.isArray(s.form_factors) ? s.form_factors : (s.form_factors ? [s.form_factors] : []);
var ffBadges = ffs.slice(0,3).map(function(f) {
return '<span class="b b-blue" style="font-size:0.68rem;padding:1px 5px;margin-right:2px">' + esc(f) + '</span>';
}).join('') + (ffs.length > 3 ? '<span style="color:var(--text-dim);font-size:0.68rem">+' + (ffs.length-3) + '</span>' : '');
var ieee = s.ieee_reference || '—';
var bodyYear = [s.body, s.year_ratified].filter(Boolean).join(' · ') || '—';
var wavelength = s.wavelength ? s.wavelength + ' nm' : '—';
var statusBadge = s.status
? '<span style="background:' + sCl + '22;color:' + sCl + ';padding:1px 7px;border-radius:8px;font-size:0.68rem;font-weight:600">' + esc(s.status) + '</span>'
: '<span style="color:var(--text-dim)">—</span>';
var sidx = _allStandards.indexOf(s);
return '<tr style="cursor:pointer" onclick="openStandardDetail(' + sidx + ')">'
+ '<td style="font-weight:700;color:var(--text-bright);min-width:120px">' + esc(s.name || '—') + '</td>'
+ '<td><span style="background:' + col + '22;color:' + col + ';padding:2px 8px;border-radius:8px;font-weight:700;font-size:0.8rem;white-space:nowrap">' + (s.speed_gbps || '—') + 'G</span></td>'
+ '<td style="min-width:100px">' + (ffBadges || '<span style="color:var(--text-dim)">—</span>') + '</td>'
+ '<td style="white-space:nowrap">' + esc(s.max_reach_label || '—') + '</td>'
+ '<td style="color:var(--text-dim);font-size:0.8rem">' + esc(s.fiber_type || '—') + '</td>'
+ '<td style="font-size:0.8rem;color:var(--text-dim);white-space:nowrap">' + esc(wavelength) + '</td>'
+ '<td style="font-size:0.75rem;color:var(--cyan);font-family:var(--mono)">' + esc(ieee) + '</td>'
+ '<td style="font-size:0.75rem;color:var(--text-dim)">' + esc(bodyYear) + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td style="color:var(--text-dim);font-size:0.8rem;text-align:right;padding-right:0.5rem">'
+ (s.transceiver_count > 0 ? '<span style="color:var(--accent);font-weight:600">' + s.transceiver_count + '</span>' : '—') + '</td>'
+ '</tr>';
}).join('');
}
// ── STANDARD DETAIL PANEL ────────────────────────────────────────────────────
// Plain-language explanation generator — also understood by non-technical colleagues
var STD_REACH_LABELS = {
SR: 'Kurzdistanz (Short Range)', SR2: 'Kurzdistanz', SR4: 'Kurzdistanz',
SR8: 'Kurzdistanz', SR10: 'Kurzdistanz',
LR: 'Langdistanz (Long Range)', LR1: 'Langdistanz', LR4: 'Langdistanz',
LR8: 'Langdistanz', LR10: 'Langdistanz',
ER: 'Erweiterte Distanz (Extended Range)', ER4: 'Erweiterte Distanz', ER8: 'Erweiterte Distanz',
ZR: 'Sehr lange Distanz (up to 80km+)', 'ZR+': 'Ultra-Langdistanz kohärent',
FR: 'Kurzdistanz SMF (FR)', FR1: 'Kurzdistanz SMF', FR4: 'Kurzdistanz SMF', FR8: 'Kurzdistanz SMF',
DR: 'Kurzdistanz parallel SMF', DR4: 'Kurzdistanz parallel SMF', DR8: 'Kurzdistanz parallel SMF',
PSM4: 'Parallel Single-Mode (4 Fasern)',
CWDM4: 'CWDM4 — 4 Wellenlängen auf einer Faser',
LH: 'Long Haul (~70km)', EX: 'Extended (~40km)',
BX: 'BiDi — Senden und Empfangen auf einer Faser',
PLR4: 'Parallel Long Range', CSR4: 'Client Side', UNIV: 'Universal (SMF + MMF)'
};
var STD_FIBER_EXPLAIN = {
'SMF': 'Singlemode-Glasfaser (gelb) — für größere Distanzen, dünne Glasfaser mit einzelnem Lichtstrahl.',
'MMF': 'Multimode-Glasfaser (orange/grau) — für kurze Distanzen innerhalb von Gebäuden oder Rechenzentren.',
'SMF (parallel)': 'Mehrere Singlemode-Fasern parallel — für hohe Bandbreite auf kurzen Strecken.',
'MMF (OM3)': 'Multimode OM3 (50µm aqua) — Standard für 10G/40G in RZ-Umgebungen bis 300m.',
'MMF (OM4)': 'Multimode OM4 (50µm aqua) — Hochleistungs-MMF bis 100m bei 100G.',
'MMF (OM5)': 'Multimode OM5 (50µm lime green) — Breitband-MMF für SWDM, unterstützt 4 Wellenlängen.',
'MMF (OM3/OM4)': 'Multimode OM3 oder OM4 kompatibel.',
'SMF (DWDM)': 'Singlemode mit DWDM — viele Wellenlängen auf einer Faser (Carrier/WDM-Netze).',
'Copper': 'Kupfer-DAC/Twinax — kein Glasfaser, direktverkabeltes Kupferkabel bis ca. 5m.'
};
var STD_USE_CASES = {
800: ['Ultra-High-Speed Hyperscaler AI/ML-Fabrics (800G Switches zu GPUs)', 'Next-Gen Spine-Layer in Cloud-RZs', 'Vorbereitung für 1.6T Netzwerke'],
400: ['400G Spine/Leaf in modernen Cloud-RZs', 'AI/ML-Training-Cluster-Interconnects', 'Carrier-Grade WAN (kohärent)', 'Hochperformante Storage-Netzwerke'],
200: ['200G Aggregation Layer', 'High-Density 200G Server-Anbindung', 'Übergangsband zwischen 100G und 400G'],
100: ['Standard für moderne RZ-Infrastruktur', '100G Server-Uplinks', 'Campus-Core-Verbindungen', 'Carrier Edge-Anbindung'],
40: ['Ältere 40G Spine-Verbindungen (werden durch 100G ersetzt)', '40G Uplinks für Blade-Server', 'Legacy QSFP+ Infrastruktur'],
25: ['Standard Server-NIC-Anbindung (25G ToR)', '25G Hyperscaler Server-Fabric', 'Modern Data Center Access Layer'],
10: ['Enterprise Server-Anbindung (10G ToR-Switch)', 'Storage-Netzwerke (iSCSI, NFS)', 'Campus-Aggregation', 'Klassische VMware/vSAN Umgebungen'],
1: ['Endgeräte-Anbindung (PCs, Telefone, IP-Kameras)', 'IoT-Geräte', 'Out-of-Band-Management', 'Kleine Büronetzwerke']
};
function genStdPlainExplanation(s) {
var name = s.name || '';
var spd = s.speed_gbps || 0;
var spdStr = spd >= 1000 ? (spd/1000) + 'T' : spd + 'G';
var ffs = Array.isArray(s.form_factors) ? s.form_factors : [s.form_factors || ''];
var fiber = s.fiber_type || '';
var reach = s.max_reach_label || '';
// Extract suffix code (SR, LR, ER, ZR, etc.)
var suffixMatch = name.match(/BASE-([A-Z0-9.]+?)(\d+)?$/i);
var suffix = suffixMatch ? suffixMatch[1].toUpperCase() : '';
var reachLabel = STD_REACH_LABELS[suffix] || STD_REACH_LABELS[suffix + (suffixMatch && suffixMatch[2] ? suffixMatch[2] : '')] || '';
// Form factor plain name
var ffNames = { 'SFP': 'SFP (kleinstes Modul, 1G)', 'SFP+': 'SFP+ (10G-Modul)', 'SFP28': 'SFP28 (25G-Modul)',
'QSFP+': 'QSFP+ (4×10G = 40G-Modul)', 'QSFP28': 'QSFP28 (100G-Modul, Standard heute)',
'QSFP56': 'QSFP56 (200G-Modul)', 'QSFP-DD': 'QSFP-DD (400G Double-Density)', 'QSFP-DD800': 'QSFP-DD800 (800G)',
'OSFP': 'OSFP (800G, etwas größer)', 'CFP': 'CFP (älteres 100G-Modul)', 'CFP2': 'CFP2 (100G/400G kohärent)',
'CFP2-DCO': 'CFP2-DCO (kohärent, programmierbar)', 'XFP': 'XFP (älteres 10G-Modul)' };
var ffText = ffs.map(function(f) { return ffNames[f] || f; }).join(' oder ');
// Fiber explanation
var fiberText = STD_FIBER_EXPLAIN[fiber] || (fiber ? 'Glasfaser-Typ: ' + fiber : '');
// Build headline
var headline = spdStr + ' Ethernet';
if (spd >= 800) headline = 'Hochgeschwindigkeits-' + spdStr + ' Ethernet — modernste Infrastruktur';
else if (spd >= 400) headline = spdStr + ' Ethernet — aktuelle High-End-Infrastruktur';
else if (spd === 100) headline = spdStr + ' Ethernet — heutiger Standard im Rechenzentrum';
else if (spd === 10) headline = spdStr + ' Ethernet — bewährter Enterprise-Standard';
else if (spd === 1) headline = spdStr + ' Ethernet — klassische Netzwerkanbindung';
if (reachLabel) headline += ', ' + reachLabel;
// Build body description
var body = '<strong>' + esc(name) + '</strong> ist ein ';
if (name.includes('BASE-')) {
body += '<strong>' + spdStr + ' Ethernet-Standard</strong>, der definiert wie Netzwerkgeräte mit exakt '
+ spdStr + ' Datenrate kommunizieren. ';
} else {
body += '<strong>Optischer Übertragungsstandard</strong> für ' + spdStr + '-Verbindungen. ';
}
if (reachLabel) body += 'Die Bezeichnung <em>' + esc(suffix) + '</em> steht für <strong>' + esc(reachLabel) + '</strong>. ';
if (reach) body += 'Die maximale Reichweite beträgt <strong>' + esc(reach) + '</strong>. ';
if (fiberText) body += fiberText + ' ';
if (name.includes('BiDi') || name.includes('BX')) {
body += '🔄 <strong>BiDi</strong> bedeutet: Senden und Empfangen über <em>eine einzige</em> Glasfaser — spart Verkabelungsaufwand. ';
}
if (name.includes('DWDM') || name.includes('ZR') || name.includes('Coherent') || name.toLowerCase().includes('coherent')) {
body += '🌊 <strong>Kohärente Übertragung</strong>: Nutzt komplexe Modulationsverfahren (wie DSP-Chips) um sehr lange Strecken zu überbrücken — typisch für Carrier-Netze und U-Bahn-/Fernverbindungen. ';
}
if (name.includes('CWDM') || name.includes('WDM')) {
body += '🌈 <strong>WDM (Wavelength Division Multiplexing)</strong>: Mehrere Datensignale in verschiedenen Farben (Wellenlängen) gleichzeitig über eine Faser. ';
}
if (name.includes('PSM') || name.includes('parallel') || (fiber && fiber.includes('parallel'))) {
body += '🔀 <strong>Parallel-Optik</strong>: Nutzt mehrere Glasfasern gleichzeitig (MPO-Kabel) — höhere Bandbreite durch Parallelisierung. ';
}
var ieee = s.ieee_reference || '';
var bodyOrg = s.body || '';
var year = s.year_ratified || '';
if (ieee) body += 'Der Standard wurde von <strong>' + esc(bodyOrg || 'IEEE') + '</strong>';
if (year) body += ' im Jahr <strong>' + year + '</strong>';
if (ieee) body += ' als <em>' + esc(ieee) + '</em> verabschiedet. ';
return { headline: headline, body: body };
}
async function openStandardDetail(idx) {
var s = _allStandards[idx];
if (!s) return;
var spd = s.speed_gbps || 0;
var speedColors = { 1600: '#7c3aed', 800: '#c1121f', 400: '#FF8100', 200: '#e6a800', 100: '#2d6a4f', 40: '#4287f5', 25: '#0ea5e9', 10: '#888', 1: '#555' };
var col = speedColors[spd] || '#888';
var ffs = Array.isArray(s.form_factors) ? s.form_factors : [s.form_factors || ''];
var explained = genStdPlainExplanation(s);
var useCases = STD_USE_CASES[spd] || STD_USE_CASES[10];
var h = '';
// Header
h += '<div style="margin-bottom:1rem">';
h += '<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem">';
h += '<span style="background:' + col + '22;color:' + col + ';padding:3px 12px;border-radius:8px;font-weight:700;font-size:1rem">' + (spd || '?') + 'G</span>';
h += '<span style="font-size:1.1rem;font-weight:700;color:var(--text-bright)">' + esc(s.name || '') + '</span>';
if (s.status) {
var sCl = { ratified: '#2d6a4f', published: '#2d6a4f', draft: '#e6a800', deprecated: '#c1121f' }[s.status.toLowerCase()] || '#888';
h += '<span style="background:' + sCl + '22;color:' + sCl + ';padding:2px 8px;border-radius:6px;font-size:0.72rem;font-weight:600">' + esc(s.status) + '</span>';
}
h += '</div>';
h += '<div style="font-size:0.82rem;color:#FF8100;font-weight:600">' + esc(explained.headline) + '</div>';
h += '</div>';
// Plain-language explanation
h += '<div class="card" style="margin-bottom:1rem;padding:1rem;border-left:3px solid ' + col + '">';
h += '<div style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.6rem">Was ist das?</div>';
h += '<div style="font-size:0.83rem;color:var(--text);line-height:1.65">' + explained.body + '</div>';
h += '</div>';
// Key specs grid
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.6rem;margin-bottom:1rem">';
var specs = [
['Reichweite', s.max_reach_label || '—'],
['Glasfaser', s.fiber_type || '—'],
['Wellenlänge', s.wavelength ? s.wavelength + ' nm' : '—'],
['IEEE / MSA', s.ieee_reference || '—'],
['Standardisierung', [s.body, s.year_ratified].filter(Boolean).join(' · ') || '—'],
['FEC', s.fec_required ? 'Erforderlich' : (s.fec_required === false ? 'Nicht erforderlich' : '—')]
];
specs.forEach(function(sp) {
h += '<div style="background:var(--surface3);padding:0.6rem 0.75rem;border-radius:8px">'
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:2px">' + esc(sp[0]) + '</div>'
+ '<div style="font-size:0.8rem;font-weight:600;color:var(--text-bright)">' + esc(sp[1]) + '</div>'
+ '</div>';
});
h += '</div>';
// Form factors
if (ffs.length > 0 && ffs[0]) {
h += '<div style="margin-bottom:1rem">';
h += '<div style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.5rem">Modul-Bauform</div>';
h += '<div style="display:flex;gap:0.4rem;flex-wrap:wrap">';
ffs.forEach(function(f) {
h += '<span class="b b-blue" style="font-size:0.8rem;padding:3px 10px">' + esc(f) + '</span>';
});
h += '</div></div>';
}
// Use cases
h += '<div style="margin-bottom:1rem">';
h += '<div style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.5rem">Typische Einsatzgebiete</div>';
h += '<div style="display:flex;flex-direction:column;gap:0.35rem">';
useCases.forEach(function(uc) {
h += '<div style="font-size:0.8rem;color:var(--text);display:flex;align-items:center;gap:0.5rem">'
+ '<span style="color:' + col + ';flex-shrink:0">▸</span>' + esc(uc) + '</div>';
});
h += '</div></div>';
// Notes
if (s.notes) {
h += '<div class="card" style="margin-bottom:1rem;padding:0.75rem;font-size:0.78rem;color:var(--text-dim);line-height:1.6">'
+ '<strong style="color:var(--text)">Hinweis:</strong> ' + esc(s.notes) + '</div>';
}
// Action buttons
h += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem">';
h += '<button class="btn" style="background:' + col + ';color:#fff;font-size:0.78rem" '
+ 'onclick="goToTab(\'transceivers\');el(\'tx-search\').value=\'' + esc(s.name || '') + '\';searchTransceivers();closePanel()">'
+ 'Transceiver anzeigen →</button>';
if (s.url) {
h += '<a href="' + esc(s.url) + '" target="_blank" rel="noopener" class="btn" style="font-size:0.78rem;text-decoration:none">'
+ 'Standard-Dokument ↗</a>';
}
h += '</div>';
// Load related transceivers
h += '<div id="std-related-tx"><div class="loading pulse" style="font-size:0.8rem">Lade passende Transceiver…</div></div>';
openPanel(h);
// Async: load matching transceivers
try {
var txData = await api('/api/transceivers?q=' + encodeURIComponent(s.name || '') + '&limit=6');
var txList = (txData.data || []).slice(0, 6);
var relEl = document.getElementById('std-related-tx');
if (!relEl) return;
if (!txList.length) { relEl.innerHTML = ''; return; }
var rh = '<div style="font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.5rem">Passende Transceiver (' + txList.length + ')</div>';
rh += '<div style="display:flex;flex-direction:column;gap:0.4rem">';
txList.forEach(function(t) {
rh += '<div style="background:var(--surface3);padding:0.5rem 0.75rem;border-radius:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center" '
+ 'onclick="openTxDetail(\'' + esc(t.id) + '\')">'
+ '<div><div style="font-size:0.8rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.slug) + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.vendor_name || '') + (t.reach_label ? ' · ' + t.reach_label : '') + '</div></div>'
+ (t.price_verified_eur ? '<span style="font-size:0.78rem;font-weight:600;color:#FF8100">€' + t.price_verified_eur + '</span>' : '')
+ '</div>';
});
rh += '</div>';
relEl.innerHTML = rh;
} catch(e) {
var relEl2 = document.getElementById('std-related-tx');
if (relEl2) relEl2.innerHTML = '';
}
}
// Markdown to HTML
function mdToHtml(md) {
if (!md) return '';
return md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h4 style="color:var(--text-bright);margin:1.2rem 0 0.4rem;font-size:0.95rem">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 style="color:var(--accent);margin:1.5rem 0 0.5rem;font-size:1.05rem;font-family:var(--font-heading);border-bottom:1px solid var(--border);padding-bottom:0.3rem">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 style="color:var(--text-bright);margin:0 0 0.8rem;font-size:1.2rem;font-family:var(--font-heading)">$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--text-bright)">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code style="background:var(--surface3);padding:0.15rem 0.4rem;border-radius:4px;font-family:var(--mono);font-size:0.8rem;color:var(--accent)">$1</code>')
.replace(/^```[\s\S]*?\n([\s\S]*?)```/gm, function(m, code) {
return '<pre style="background:var(--surface-dark);color:#d4d4d4;padding:0.8rem;border-radius:var(--radius-md);overflow-x:auto;font-size:0.8rem;line-height:1.5;border:1px solid var(--border);margin:0.5rem 0"><code style="font-family:var(--mono)">' + code.trim() + '</code></pre>';
})
.replace(/^- (.+)$/gm, '<div style="padding-left:1rem;position:relative;margin:0.15rem 0"><span style="position:absolute;left:0;color:var(--accent)">•</span> $1</div>')
.replace(/^\d+\. (.+)$/gm, function(m, text) {
return '<div style="padding-left:1.2rem;margin:0.15rem 0">' + text + '</div>';
})
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>');
}
// Copy to clipboard
function copyBlogContent(id) {
api('/api/blog/' + id).then(function(data) {
var content = data.draft.draft_content || '';
navigator.clipboard.writeText(content).then(function() {
var btn = document.getElementById('copy-btn-' + id);
if (btn) {
btn.classList.add('copied');
btn.innerHTML = '&#10003; Copied';
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = '&#128203; Copy';
}, 2000);
}
showToast('Copied', 'Article text copied to clipboard');
}).catch(function() {
showToast('Error', 'Failed to copy to clipboard', true);
});
});
}
function copyLinkedInPost(id) {
api('/api/blog/' + id).then(function(data) {
var content = data.draft.linkedin_post || '';
if (!content) { showToast('No LinkedIn post', 'Regenerate to produce one', true); return; }
navigator.clipboard.writeText(content).then(function() {
showToast('Copied', 'LinkedIn post (' + content.length + ' chars) copied to clipboard');
}).catch(function() {
showToast('Error', 'Failed to copy to clipboard', true);
});
});
}
// BLOG
function generateBlog(topic, speed) {
var body = { topic: topic };
if (speed) body.speed = speed;
var token = window.loadToken ? window.loadToken() : '';
fetch(API + '/api/blog/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(body)
}).then(function(r) { if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); } return r.json(); }).then(function(data) {
if (data.success) {
showToast('⚙️ Generating…', data.draft.title + ' — pipeline running (~10 min)');
loadBlogDrafts();
pollBlogLlm(data.draft.id, 0);
} else showToast('Failed', data.error || 'Unknown error', true);
}).catch(function(err) { if (err.message !== 'Unauthorized') showToast('Network error', err.message, true); });
}
function toggleBlogReviewed(id, starEl) {
fetch(API + '/api/blog/' + id + '/review', { method: 'PUT', headers: { 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var isReviewed = data.review_tag === 'reviewed';
// Update star opacity
starEl.style.opacity = isReviewed ? '1' : '0.3';
starEl.title = isReviewed ? 'Reviewed — klicken zum Zurücksetzen' : 'Noch nicht reviewed — klicken zum Markieren';
// Update row border + reviewed badge
var row = starEl.closest('.ri');
if (row) {
row.style.borderLeft = isReviewed ? '3px solid #1a7a3a' : '';
row.dataset.reviewed = isReviewed ? '1' : '0';
// Toggle reviewed badge in meta
var existingBadge = row.querySelector('.blog-reviewed-badge');
if (isReviewed && !existingBadge) {
var meta = row.querySelector('.ri-meta');
if (meta) {
var badge = document.createElement('span');
badge.className = 'b b-green blog-reviewed-badge';
badge.style.cssText = 'background:#1a7a3a22;color:#1a7a3a;border-color:#1a7a3a44';
badge.textContent = '✓ reviewed';
meta.appendChild(badge);
}
} else if (!isReviewed && existingBadge) {
existingBadge.remove();
}
}
showToast(isReviewed ? '✅ Reviewed' : '↩ Review zurückgesetzt', '');
})
.catch(function() { showToast('Fehler', 'Review-Status konnte nicht gesetzt werden', true); });
}
function generateBlogManual() {
var customTitle = (document.getElementById('blog-custom-title').value || '').trim();
var topic = document.getElementById('blog-manual-topic').value || 'technology_deep_dive';
var additionalContext = (document.getElementById('blog-additional-context').value || '').trim();
var body = { topic: topic };
if (customTitle) body.custom_title = customTitle;
if (additionalContext) body.additional_context = additionalContext;
var token = window.loadToken ? window.loadToken() : '';
showToast('⚙️ Gestartet', (customTitle || 'Artikel') + ' — Pipeline läuft (~10 min)');
fetch(API + '/api/blog/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(body)
}).then(function(r) { if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); } return r.json(); }).then(function(data) {
if (data.success) {
showToast('✓ Pipeline gestartet', data.draft.title + ' — wird in ~10 min fertig');
document.getElementById('blog-custom-title').value = '';
document.getElementById('blog-additional-context').value = '';
loadBlogDrafts();
pollBlogLlm(data.draft.id, 0);
} else showToast('Fehler', data.error || 'Unbekannter Fehler', true);
}).catch(function(err) { if (err.message !== 'Unauthorized') showToast('Netzwerkfehler', err.message, true); });
}
function pollBlogLlm(id, attempt) {
if (attempt > 60) return; // max 10 min (60 × 10s)
setTimeout(function() {
api('/api/blog/' + id + '/progress').then(function(p) {
if (!p.running) {
// Pipeline done — update badge to "ready" and reload list
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-green blog-status-badge';
badge.textContent = 'ready ✓';
}
api('/api/blog/' + id).then(function(data) {
if (data.draft) {
showToast('✅ Blog ready', data.draft.title + ' — ' + data.draft.word_count + ' words');
}
}).catch(function() {});
loadBlogDrafts();
// Show posting time recommendation after generation
showPostingTimeForBlog();
} else {
// Still running — update badge with step info
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
if (badge) {
badge.className = 'b b-yellow blog-status-badge';
badge.textContent = 'step ' + p.step + '/14';
}
pollBlogLlm(id, attempt + 1);
}
}).catch(function() {
pollBlogLlm(id, attempt + 1);
});
}, 8000);
}
// Hot topics loaded dynamically via hot-topics.js
// ══════════════════════════════════════════════════════
// FINDER — Switch → Transceiver
// ══════════════════════════════════════════════════════
function finderQuick(model) {
document.getElementById('finder-switch-input').value = model;
runFinder();
}
async function runFinder() {
var model = (document.getElementById('finder-switch-input').value || '').trim();
var speed = document.getElementById('finder-speed-filter').value;
var results = document.getElementById('finder-results');
if (!model) { results.innerHTML = '<p style="color:var(--text-dim)">Enter a switch model to search.</p>'; return; }
results.innerHTML = '<div class="loading pulse">Searching compatibility database...</div>';
var url = '/api/finder?switch=' + encodeURIComponent(model) + (speed ? '&speed=' + speed : '');
try {
var data = await api(url);
if (data.error) {
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f"><b>Not found:</b> ' + data.error +
(data.suggestion ? '<br><span style="color:var(--text-dim);font-size:0.85rem">' + data.suggestion + '</span>' : '') + '</div>';
return;
}
var sw = data.switch;
var transceivers = data.compatible_transceivers || [];
var total = data.total || 0;
// Switch info header
var swHtml = '<div class="card mb" style="display:flex;gap:1rem;align-items:center">' +
(sw.image_url ? '<img src="' + sw.image_url + '" style="height:60px;border-radius:6px;object-fit:contain" onerror="this.style.display=\'none\'">' : '') +
'<div style="flex:1">' +
'<div style="font-size:1.1rem;font-weight:700">' + sw.vendor + ' ' + sw.model + '</div>' +
'<div style="color:var(--text-dim);font-size:0.8rem">' +
(sw.series ? sw.series + ' · ' : '') +
'Max speed: ' + (sw.max_speed_gbps || '?') + 'G' +
'</div>' +
'</div>' +
'<div style="text-align:right;font-size:0.8rem;color:var(--text-dim)">' +
'<b style="font-size:1.1rem;color:var(--text)">' + total + '</b> compatible transceivers' +
'</div>' +
'</div>';
if (transceivers.length === 0) {
results.innerHTML = swHtml + '<div class="card" style="color:var(--text-dim)">No compatible transceivers found for this switch. Try removing the speed filter.</div>';
return;
}
// Group by speed class
var bySpeed = {};
for (var t of transceivers) {
var key = t.speed_gbps + 'G ' + t.form_factor;
if (!bySpeed[key]) bySpeed[key] = [];
bySpeed[key].push(t);
}
var speedColors = { 800: '#c1121f', 400: '#FF8100', 100: '#e6a800', 25: '#2d6a4f', 10: '#888' };
var tcvrHtml = Object.entries(bySpeed).sort(function(a, b) {
return parseInt(b[0]) - parseInt(a[0]);
}).map(function(entry) {
var speedClass = entry[0];
var items = entry[1];
var speedGbps = items[0].speed_gbps;
var color = speedColors[speedGbps] || '#888';
var cards = items.slice(0, 12).map(function(t) {
var isFlexoptix = (t.vendor || '').toUpperCase() === 'FLEXOPTIX';
var fullyVerified = t.fully_verified === true;
var priceVerified = t.price_verified === true;
// Price: always show USD (lead) + EUR
var rawPrice = t.price_verified_eur || t.price;
var rawCur = t.price_verified_eur ? 'EUR' : (t.currency || 'USD');
var hasPrice = rawPrice != null;
var priceHtml;
if (hasPrice) {
var pAmt = parseFloat(rawPrice);
var pUSD = toUSD(pAmt, rawCur);
var pEUR = toEUR(pAmt, rawCur);
priceHtml = '<span style="color:var(--accent);font-weight:700">'
+ (pUSD !== null ? fmtUSD(pUSD) : rawCur + ' ' + pAmt.toFixed(2))
+ '</span>'
+ (pEUR !== null ? '<span style="color:#aaa;font-size:0.75rem;margin-left:0.3rem">/ ' + fmtEUR(pEUR) + '</span>' : '')
+ (priceVerified ? ' <span title="Price verified from official source" style="color:#2d6a4f;font-size:0.6rem;cursor:help">✓</span>' : '');
} else {
priceHtml = '<span style="color:var(--text-dim);font-size:0.8rem">see flexoptix.net</span>';
}
var stockHtml = t.stock === 'in_stock' ? '<span style="color:#2d6a4f;font-size:0.65rem">● In Stock</span>'
: t.stock === 'limited' ? '<span style="color:#e6a800;font-size:0.65rem">● Limited</span>'
: '';
var partNum = t.part_number || t.slug || t.id;
// 100% Verified stamp
var verifiedStamp = fullyVerified
? '<div title="Price, product image and specifications all verified from official sources" style="display:inline-flex;align-items:center;gap:3px;background:linear-gradient(135deg,#1b4332,#2d6a4f);color:white;font-size:0.6rem;font-weight:700;padding:2px 7px;border-radius:10px;margin-bottom:4px;cursor:help;letter-spacing:0.03em">★ 100% VERIFIED</div><br>'
: '';
// Card border: 100% verified = green, Flexoptix = orange, else default
var cardBorder = fullyVerified ? 'border:1px solid #2d6a4f;box-shadow:0 0 0 1px #2d6a4f20'
: isFlexoptix ? 'border-left:3px solid var(--accent)' : '';
return '<div class="card" style="padding:0.8rem;' + cardBorder + '">' +
verifiedStamp +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem">' +
'<div style="flex:1;min-width:0">' +
(isFlexoptix ? '<span style="font-size:0.6rem;background:var(--accent);color:white;padding:1px 5px;border-radius:3px;margin-right:4px">FLEXOPTIX</span>' : '') +
'<span style="font-size:0.7rem;color:var(--text-dim)">' + (t.vendor || '') + '</span><br>' +
'<b style="font-size:0.85rem;word-break:break-all">' + partNum + '</b><br>' +
'<span style="font-size:0.75rem;color:var(--text-dim)">' +
(t.reach || '') + (t.fiber_type ? ' · ' + t.fiber_type : '') +
(t.connector ? ' · ' + t.connector : '') +
'</span>' +
'</div>' +
'<div style="text-align:right;flex-shrink:0">' +
priceHtml + '<br>' + stockHtml +
'</div>' +
'</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem">' +
(t.buy_url ? '<a href="' + t.buy_url + '" target="_blank" style="font-size:0.7rem;color:var(--accent)">Buy at Flexoptix →</a>' : '<span></span>') +
(t.price_verified_url ? '<a href="' + t.price_verified_url + '" target="_blank" title="Price source" style="font-size:0.6rem;color:var(--text-dim)">price source ↗</a>' : '') +
'</div>' +
'</div>';
}).join('');
return '<div style="margin-bottom:1.2rem">' +
'<div style="font-size:0.8rem;font-weight:700;color:' + color + ';margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em">' +
speedClass + ' <span style="color:var(--text-dim);font-weight:400">(' + items.length + ' options)</span>' +
'</div>' +
'<div class="grid g3">' + cards + '</div>' +
'</div>';
}).join('');
// Count verified in results
var verifiedCount = transceivers.filter(function(t) { return t.fully_verified; }).length;
var priceVerCount = transceivers.filter(function(t) { return t.price_verified; }).length;
results.innerHTML = swHtml +
'<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.8rem;display:flex;gap:1rem;flex-wrap:wrap">' +
'<span>Showing ' + Math.min(transceivers.length, total) + ' of ' + total + ' compatible transceivers</span>' +
(verifiedCount > 0 ? '<span style="color:#2d6a4f">★ ' + verifiedCount + ' × 100% Verified</span>' : '') +
(priceVerCount > 0 ? '<span style="color:#2d6a4f">✓ ' + priceVerCount + ' with verified prices</span>' : '') +
'</div>' +
tcvrHtml;
} catch(e) {
var body = e.body || {};
var msg = body.error || e.message || 'Unknown error';
var suggestion = body.suggestion || '';
results.innerHTML = '<div class="card" style="border-left:3px solid #c1121f;padding:1rem">'
+ '<div style="font-weight:700;margin-bottom:0.3rem">Switch not found</div>'
+ '<div style="color:var(--text-dim);font-size:0.85rem">' + esc(msg) + '</div>'
+ (suggestion ? '<div style="color:var(--text-dim);font-size:0.8rem;margin-top:0.4rem">💡 ' + esc(suggestion) + '</div>' : '')
+ '</div>';
}
}
async function loadBlogLLMStatus() {
try {
var data = await api('/api/blog/llm/status');
var llm = data.llm || {};
var badge = document.getElementById('blog-llm-status-badge');
var activeModel = document.getElementById('blog-llm-active-model');
var activeProvider = document.getElementById('blog-llm-active-provider');
var queueEl = document.getElementById('blog-llm-queue');
if (badge) {
badge.textContent = llm.ok ? 'online' : 'offline';
badge.style.background = llm.ok ? 'rgba(34,197,94,0.2)' : 'rgba(193,18,31,0.2)';
badge.style.color = llm.ok ? '#86efac' : '#f87171';
}
if (activeModel) activeModel.textContent = llm.model || '—';
if (activeProvider) {
var provLabel = llm.provider === 'claude-code' ? 'claude-code' : llm.provider === 'anthropic' ? 'anthropic' : 'ollama';
activeProvider.textContent = provLabel;
activeProvider.style.background = 'var(--accent)';
activeProvider.style.color = '#fff';
}
if (queueEl) {
var q = data.queue_depth || 0;
queueEl.textContent = q > 0 ? 'Queue: ' + q + ' Jobs' : 'Queue: idle';
}
// Reset all card borders + active badges
['cc','claude','fo'].forEach(function(k) {
var card = document.getElementById('blog-model-card-' + k);
if (card) card.style.border = '2px solid var(--border)';
var badge2 = document.getElementById('blog-model-' + k + '-active');
if (badge2) badge2.style.display = 'none';
});
if (llm.provider === 'claude-code') {
var ccCard = document.getElementById('blog-model-card-cc');
if (ccCard) ccCard.style.border = '2px solid var(--accent)';
var ccActive = document.getElementById('blog-model-cc-active');
if (ccActive) ccActive.style.display = 'inline';
var ccSt = document.getElementById('blog-model-cc-status');
if (ccSt) {
ccSt.textContent = llm.ok ? '● Aktiv — claude-bridge erreichbar' : '⚠ claude-bridge nicht erreichbar: ' + (llm.error || '').slice(0, 60);
ccSt.style.color = llm.ok ? '#1a7a3a' : '#b45309';
ccSt.style.fontWeight = '600';
}
var clSt2 = document.getElementById('blog-model-claude-status');
if (clSt2) { clSt2.textContent = 'bereit (nicht aktiv)'; clSt2.style.color = 'var(--text-dim)'; clSt2.style.fontWeight = '400'; }
var foSt2 = document.getElementById('blog-model-fo-status');
if (foSt2) { foSt2.textContent = 'bereit (nicht aktiv)'; foSt2.style.color = 'var(--text-dim)'; foSt2.style.fontWeight = '400'; }
} else if (llm.provider === 'anthropic') {
var claudeCard = document.getElementById('blog-model-card-claude');
if (claudeCard) claudeCard.style.border = '2px solid var(--accent)';
var claudeActive = document.getElementById('blog-model-claude-active');
if (claudeActive) claudeActive.style.display = 'inline';
var claudeStatusEl = document.getElementById('blog-model-claude-status');
if (claudeStatusEl) {
claudeStatusEl.textContent = '● Aktiv — API-Key konfiguriert';
claudeStatusEl.style.color = '#1a7a3a';
claudeStatusEl.style.fontWeight = '600';
}
var ccSt2 = document.getElementById('blog-model-cc-status');
if (ccSt2) { ccSt2.textContent = 'bereit (nicht aktiv)'; ccSt2.style.color = 'var(--text-dim)'; ccSt2.style.fontWeight = '400'; }
var foSt3 = document.getElementById('blog-model-fo-status');
if (foSt3) { foSt3.textContent = 'bereit (nicht aktiv)'; foSt3.style.color = 'var(--text-dim)'; foSt3.style.fontWeight = '400'; }
} else {
// ollama
var foCard = document.getElementById('blog-model-card-fo');
if (foCard) foCard.style.border = '2px solid var(--accent)';
var foActive = document.getElementById('blog-model-fo-active');
if (foActive) foActive.style.display = 'inline';
var foStatusEl = document.getElementById('blog-model-fo-status');
if (foStatusEl) {
foStatusEl.textContent = llm.ok ? '● Aktiv — Ollama erreichbar' : '⚠ Ollama nicht erreichbar: ' + (llm.error || '').slice(0, 60);
foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309';
foStatusEl.style.fontWeight = '600';
}
var ccSt3 = document.getElementById('blog-model-cc-status');
if (ccSt3) { ccSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=claude-code setzen'; ccSt3.style.color = 'var(--text-dim)'; ccSt3.style.fontWeight = '400'; }
var clSt3 = document.getElementById('blog-model-claude-status');
if (clSt3) { clSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + API-Key setzen'; clSt3.style.color = 'var(--text-dim)'; clSt3.style.fontWeight = '400'; }
}
} catch(e) {
var b = document.getElementById('blog-llm-status-badge');
if (b) { b.textContent = 'Fehler'; b.style.background = '#fee2e2'; b.style.color = '#b91c1c'; }
}
}
async function loadBlogDrafts() {
var data = await api('/api/blog');
// Check which drafts are still generating (in-progress pipelines)
var drafts = data.drafts || [];
// Fetch progress for all drafts in parallel to know which are still running
var progressMap = {};
await Promise.all(drafts.map(function(d) {
return api('/api/blog/' + d.id + '/progress').then(function(p) {
progressMap[d.id] = p;
}).catch(function() {});
}));
buildDOM(el('blog-list'), drafts.map(function(d) {
var p = progressMap[d.id] || {};
var isRunning = p.running === true;
// Status badge: generating → step X/N, review/fo-blog done → ready ✓, approved → approved, published → published, draft → draft
var statusLabel, statusClass;
if (isRunning) {
statusLabel = p.step ? 'step ' + p.step + '/' + (p.total || 14) : 'generating…';
statusClass = 'b-yellow';
} else if (d.status === 'published') {
statusLabel = 'published';
statusClass = 'b-green';
} else if (d.status === 'approved') {
statusLabel = 'approved ✓';
statusClass = 'b-green';
} else if (d.status === 'review' || d.pipeline_steps_completed >= 10 || (d.generated_by || '').includes('fo-blog')) {
statusLabel = 'ready ✓';
statusClass = 'b-green';
} else {
statusLabel = 'draft';
statusClass = 'b-blue';
}
var gen = (d.generated_by || '').replace('tip-blog-engine-', '');
var gc = gen.includes('fo-blog') ? 'b-green' : gen === 'template-fallback' ? 'b-yellow' : 'b-neutral';
// Review tracking: 'reviewed' tag stored in d.review_tag (set via toggleBlogReviewed)
var isReviewed = d.review_tag === 'reviewed';
return '<div class="ri" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" data-reviewed="' + (isReviewed ? '1' : '0') + '" onclick="openBlogDetail(\'' + esc(d.id) + '\')" style="' + (isReviewed ? 'border-left:3px solid #1a7a3a;' : '') + '">'
+ '<div style="display:flex;justify-content:space-between;align-items:center">'
+ '<div class="ri-title">' + esc(d.title) + '</div>'
+ '<div style="display:flex;align-items:center;gap:8px">'
+ '<span class="b ' + statusClass + ' blog-status-badge">' + statusLabel + '</span>'
+ '<span title="' + (isReviewed ? 'Reviewed — klicken zum Zurücksetzen' : 'Noch nicht reviewed — klicken zum Markieren') + '" onclick="event.stopPropagation();toggleBlogReviewed(\'' + esc(d.id) + '\',this)" style="cursor:pointer;font-size:1rem;opacity:' + (isReviewed ? '1' : '0.3') + '" class="blog-review-star">✅</span>'
+ '<span class="blog-del-btn" data-blog-id="' + esc(d.id) + '" data-blog-title="' + esc(d.title || '') + '" title="Delete" style="color:#c1121f;cursor:pointer;font-size:0.9rem;padding:2px 6px;border-radius:4px" onclick="event.stopPropagation();blogDeleteClick(this)">✕</span>'
+ '</div>'
+ '</div>'
+ '<div class="ri-meta">'
+ '<span class="b b-purple">' + esc(d.topic) + '</span>'
+ '<span class="b b-neutral">' + esc(d.target_audience) + '</span>'
+ '<span class="b ' + gc + '">' + esc(gen || 'template') + '</span>'
+ '<span class="mono">' + esc(d.word_count) + ' words</span>'
+ (d.linkedin_post ? '<span class="b b-blue" title="LinkedIn post ready (' + (d.linkedin_char_count || d.linkedin_post.length) + ' chars)">&#128100; LI</span>' : '')
+ (isReviewed ? '<span class="b b-green" style="background:#1a7a3a22;color:#1a7a3a;border-color:#1a7a3a44">✓ reviewed</span>' : '')
+ '<span>' + new Date(d.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) + '</span>'
+ '</div></div>';
}).join('') || '<div class="loading">No drafts yet &mdash; click a card above to generate</div>');
// Auto-start polling for any drafts currently running
drafts.forEach(function(d) {
if (progressMap[d.id] && progressMap[d.id].running) {
pollBlogLlm(d.id, 0);
}
});
}
async function openBlogDetail(id) {
openPanel('<div class="loading pulse">Loading...</div>');
try {
var data = await api('/api/blog/' + id);
var d = data.draft;
var outline = d.outline || {};
var genMethod = (d.generated_by || '').replace('tip-blog-engine-', '');
var methodBadge = genMethod === 'llm' ? 'b-green' : genMethod === 'template-fallback' ? 'b-yellow' : 'b-neutral';
var h = '<div class="panel-title">' + esc(d.title) + '</div>';
h += '<div class="panel-sub">';
h += '<span class="b b-purple">' + esc(d.topic) + '</span> ';
h += '<span class="b b-neutral">' + esc(d.target_audience) + '</span> ';
h += '<span class="b ' + methodBadge + '">' + esc(genMethod || 'template') + '</span> ';
h += '<span class="mono dim">' + esc(d.word_count) + ' words</span>';
h += '</div>';
var hasQualityIssues = outline.quality_issues && outline.quality_issues.length > 0;
if (hasQualityIssues) {
h += '<div style="margin:0.5rem 0;padding:0.4rem 0.8rem;background:var(--yellow-light);border:1px solid rgba(212,163,115,0.4);border-radius:var(--radius-md);font-size:0.8rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">';
h += '<div><strong style="color:#b8860b">Quality issues:</strong> ' + outline.quality_issues.map(esc).join(', ') + '</div>';
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="white-space:nowrap;color:#b8860b;border-color:rgba(212,163,115,0.5);font-size:0.75rem;padding:3px 10px">&#x1F504; Regenerate</button>';
h += '</div>';
}
// ── Blog Article ─────────────────────────────────────
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin:1.25rem 0 0.6rem">';
h += '<div class="panel-section" style="margin:0;flex:1">&#128196; Blog Article</div>';
h += '<span class="b b-neutral" style="font-size:0.7rem;margin-right:0.4rem">' + esc(d.word_count || 0) + ' words</span>';
h += '<button class="btn-copy" id="copy-btn-' + esc(d.id) + '" onclick="event.stopPropagation();copyBlogContent(\'' + esc(d.id) + '\')">&#128203; Copy</button>';
h += '</div>';
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.8;max-height:55vh;overflow-y:auto;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + mdToHtml(d.draft_content || '') + '</div>';
// ── LinkedIn Post ─────────────────────────────────────
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin:1.5rem 0 0.6rem">';
h += '<div class="panel-section" style="margin:0;flex:1">&#128100; LinkedIn Post</div>';
if (d.linkedin_post) {
var liChars = (d.linkedin_char_count || d.linkedin_post.length);
var liColor = liChars > 2800 ? '#c1121f' : liChars > 2200 ? '#b8860b' : 'var(--green)';
h += '<span class="b b-neutral" style="font-size:0.7rem;color:' + liColor + ';margin-right:0.4rem">' + liChars + ' / 3000 chars</span>';
h += '<button class="btn-copy" onclick="event.stopPropagation();copyLinkedInPost(\'' + esc(d.id) + '\')">&#128203; Copy</button>';
}
h += '</div>';
if (d.linkedin_post) {
h += '<div data-linkedin-text="' + esc(d.linkedin_post).replace(/"/g, '&quot;') + '" style="font-size:0.85rem;color:var(--text);line-height:1.7;white-space:pre-wrap;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + esc(d.linkedin_post) + '</div>';
} else {
h += '<div style="font-size:0.8rem;color:var(--text-dim);padding:0.8rem;background:var(--surface2);border-radius:var(--radius-md);border:1px solid var(--border)">No LinkedIn post yet — regenerate to produce one.</div>';
}
// ── SEO / Hashtags ────────────────────────────────────
h += '<div class="panel-section">SEO Keywords / Hashtags</div>';
h += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap">' + (d.seo_keywords || []).map(function(k) {
var tag = '#' + k.replace(/\s+/g, '');
return '<span class="b b-neutral" style="cursor:pointer;user-select:all" title="Click to copy hashtag" onclick="navigator.clipboard.writeText(\'' + esc(tag) + '\').then(function(){showToast(\'Copied\',\'' + esc(tag) + '\')})">' + esc(tag) + '</span>';
}).join('') + '</div>';
h += '<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
if (d.status === 'review' || hasQualityIssues) {
h += '<button class="btn-ghost" onclick="event.stopPropagation();regenerateBlog(\'' + esc(d.id) + '\')" style="color:#b8860b;border-color:rgba(212,163,115,0.5)">&#x1F504; Neu generieren</button>';
}
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'review\')" style="color:#b8860b;border-color:rgba(212,163,115,0.4)">Mark Review</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
h += '</div>';
// Publishing actions
h += '<div style="margin-top:0.75rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
h += '<button class="btn-ghost" onclick="postToGhost(\'' + esc(d.id) + '\')" style="color:#2D7A50;border-color:rgba(45,122,80,0.4);font-weight:600">&#9997; Post on blog.fichtmueller.org</button>';
if (d.linkedin_post) {
h += '<button class="btn-ghost" onclick="openLinkedInPost(\'' + esc(d.id) + '\')" style="color:#0A66C2;border-color:rgba(10,102,194,0.4);font-weight:600">&#x1F517; Post on LinkedIn</button>';
}
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
async function updateBlogStatus(id, status) {
try {
var data = await fetch(API + '/api/blog/' + id + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: status })
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Updated', 'Status: ' + status);
openBlogDetail(id);
loadBlogDrafts();
} else showToast('Failed', data.error, true);
} catch(e) { showToast('Error', e.message, true); }
}
async function postToGhost(id) {
try {
showToast('Publishing…', 'Posting to blog.fichtmueller.org');
var data = await api('/api/blog/' + id + '/publish-ghost', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (data.success) {
showToast('Published!', 'Live at: ' + (data.url || 'blog.fichtmueller.org'));
updateBlogStatus(id, 'published');
} else {
showToast('Failed', data.error || 'Ghost publish failed', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
function openLinkedInPost(id) {
// Find cached blog data or fetch it
var panel = el('panel-content');
// Extract linkedin_post from the panel DOM (it's rendered there already)
var liSection = panel.querySelector('[data-linkedin-text]');
var liText = liSection ? liSection.getAttribute('data-linkedin-text') : '';
if (!liText) {
// Fallback: fetch from API
api('/api/blog/' + id).then(function(d) {
if (d && d.linkedin_post) showLinkedInModal(d.linkedin_post, d.title);
else showToast('No LinkedIn text', 'Generate the blog first', true);
});
return;
}
showLinkedInModal(liText, '');
}
function showLinkedInModal(text, title) {
// Remove existing modal if any
var existing = document.getElementById('linkedin-modal');
if (existing) existing.remove();
var modal = document.createElement('div');
modal.id = 'linkedin-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
var box = document.createElement('div');
box.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.5rem;max-width:520px;width:90%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.3)';
var header = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">'
+ '<div style="display:flex;align-items:center;gap:0.5rem"><span style="color:#0A66C2;font-size:1.2rem">&#x1F517;</span><span style="font-weight:700;color:var(--text-bright)">LinkedIn Post</span></div>'
+ '<button onclick="document.getElementById(\'linkedin-modal\').remove()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.2rem">&times;</button>'
+ '</div>';
var textarea = '<textarea id="linkedin-textarea" style="width:100%;min-height:250px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;color:var(--text);font-family:var(--body);font-size:0.85rem;resize:vertical;line-height:1.5" readonly>' + text.replace(/</g, '&lt;') + '</textarea>';
var charCount = '<div style="text-align:right;font-size:0.7rem;color:var(--text-dim);margin-top:0.3rem">' + text.length + ' / 3,000 chars</div>';
var buttons = '<div style="display:flex;gap:0.5rem;margin-top:0.75rem">'
+ '<button onclick="navigator.clipboard.writeText(document.getElementById(\'linkedin-textarea\').value).then(function(){showToast(\'Copied\',\'LinkedIn text copied to clipboard\')})" style="flex:1;padding:0.5rem;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer;font-size:0.8rem">&#128203; Copy Text</button>'
+ '<button onclick="window.open(\'https://www.linkedin.com/feed/?shareActive=true\',\'_blank\')" style="flex:1;padding:0.5rem;background:#0A66C2;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:0.8rem;font-weight:600">Open LinkedIn</button>'
+ '</div>';
box.innerHTML = header + textarea + charCount + buttons;
modal.appendChild(box);
document.body.appendChild(modal);
}
async function regenerateBlog(id) {
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
try {
var token = window.loadToken ? window.loadToken() : '';
var data = await fetch(API + '/api/blog/' + id + '/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); });
if (data.success) {
showToast('Gestartet', 'LLM läuft Status wird aktualisiert');
loadBlogDrafts();
// Poll for completion
pollBlogLlm(id, 0);
} else {
showToast('Fehler', data.error || 'Regenerierung fehlgeschlagen', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
// ─────────────────────────────────────────────────────────────────
// SLL v1.0 — Self-Learning Loop
// ─────────────────────────────────────────────────────────────────
async function loadSLLInsights() {
var token = window.loadToken ? window.loadToken() : '';
var el = document.getElementById('sll-insights-content');
var badge = document.getElementById('sll-status-badge');
if (!el) return;
try {
var r = await fetch(API + '/api/blog/sll/insights', { headers: { 'Authorization': 'Bearer ' + token } });
var d = await r.json();
if (!d.success) { el.innerHTML = '<span style="color:#c1121f">Error loading SLL data</span>'; return; }
var stats = d.stats;
var ready = d.sll_ready;
badge.textContent = ready ? 'Active — ' + stats.total_posts + ' posts' : 'Needs data — ' + stats.total_posts + '/5 posts';
badge.style.background = ready ? 'rgba(34,197,94,0.2)' : 'rgba(234,179,8,0.2)';
badge.style.color = ready ? '#4ade80' : '#fbbf24';
var h = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-bottom:0.75rem">';
h += '<div style="text-align:center;padding:6px;background:rgba(212,163,115,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:var(--accent2)">' + (stats.best_score || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">Best Score</div></div>';
h += '<div style="text-align:center;padding:6px;background:rgba(255,215,0,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:#ffd700">' + (stats.tiers.gold || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">🥇 Gold</div></div>';
h += '<div style="text-align:center;padding:6px;background:rgba(192,192,192,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:#c0c0c0">' + (stats.tiers.silver || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">🥈 Silver</div></div>';
h += '<div style="text-align:center;padding:6px;background:rgba(100,100,100,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:var(--text-dim)">' + (stats.tiers.miss || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">Miss</div></div>';
h += '</div>';
var winners = d.learned_patterns.winners || [];
var losers = d.learned_patterns.losers || [];
if (winners.length > 0) {
h += '<div style="margin-bottom:0.4rem"><span style="color:#4ade80;font-size:0.75rem;font-weight:600">✔ WHAT WORKS</span></div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.6rem">';
winners.forEach(function(p) {
h += '<span style="background:rgba(34,197,94,0.12);border:1px solid rgba(34,197,94,0.3);color:#4ade80;padding:2px 8px;border-radius:10px;font-size:0.7rem">[' + p.pattern_type + '] ' + p.pattern_value + '</span>';
});
h += '</div>';
}
if (losers.length > 0) {
h += '<div style="margin-bottom:0.4rem"><span style="color:#f87171;font-size:0.75rem;font-weight:600">✗ WHAT FAILS</span></div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.6rem">';
losers.forEach(function(p) {
h += '<span style="background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.25);color:#f87171;padding:2px 8px;border-radius:10px;font-size:0.7rem">[' + p.pattern_type + '] ' + p.pattern_value + '</span>';
});
h += '</div>';
}
if (winners.length === 0 && losers.length === 0) {
h += '<div style="color:var(--text-dim);font-size:0.75rem">' + d.note + '</div>';
}
if (d.top_posts && d.top_posts.length > 0) {
h += '<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">TOP PERFORMERS: ';
h += d.top_posts.slice(0,3).map(function(p) { return '<span style="color:var(--accent2)">' + (p.title || '?').slice(0,40) + ' (' + p.engagement_score + ')</span>'; }).join(' · ');
h += '</div>';
}
el.innerHTML = h;
// Also populate blog select for performance form
populateSLLBlogSelect();
} catch(e) {
el.innerHTML = '<span style="color:var(--text-dim);font-size:0.75rem">SLL data unavailable</span>';
}
}
async function populateSLLBlogSelect() {
var token = window.loadToken ? window.loadToken() : '';
var sel = document.getElementById('sll-blog-select');
if (!sel) return;
try {
var r = await fetch(API + '/api/blog?limit=50', { headers: { 'Authorization': 'Bearer ' + token } });
var d = await r.json();
var drafts = (d.drafts || d.data || []);
sel.innerHTML = '<option value="">Select blog post…</option>';
drafts.forEach(function(b) {
var opt = document.createElement('option');
opt.value = b.id;
opt.textContent = (b.title || 'Untitled').slice(0, 60) + ' (' + (b.status || '?') + ')';
sel.appendChild(opt);
});
} catch(e) { /* ignore */ }
}
function showSLLPerformanceForm() {
var form = document.getElementById('sll-perf-form');
if (!form) return;
form.style.display = form.style.display === 'none' ? 'block' : 'none';
if (form.style.display === 'block') populateSLLBlogSelect();
}
async function submitSLLPerformance() {
var token = window.loadToken ? window.loadToken() : '';
var blogId = document.getElementById('sll-blog-select').value;
if (!blogId) { showToast('Fehler', 'Bitte Blog-Post auswählen', true); return; }
var comments = parseInt(document.getElementById('sll-comments').value) || 0;
var shares = parseInt(document.getElementById('sll-shares').value) || 0;
var saves = parseInt(document.getElementById('sll-saves').value) || 0;
var impressions = parseInt(document.getElementById('sll-impressions').value) || null;
try {
var r = await fetch(API + '/api/blog/' + blogId + '/performance', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ comments: comments, shares: shares, saves: saves, impressions: impressions })
});
var d = await r.json();
if (d.success) {
var tier = d.tier;
var tierEmoji = tier === 'gold' ? '🥇' : tier === 'silver' ? '🥈' : tier === 'bronze' ? '🥉' : '📉';
showToast('Gespeichert ' + tierEmoji, 'Score: ' + d.engagement_score + ' (' + tier + ')');
document.getElementById('sll-perf-form').style.display = 'none';
document.getElementById('sll-comments').value = '0';
document.getElementById('sll-shares').value = '0';
document.getElementById('sll-saves').value = '0';
document.getElementById('sll-impressions').value = '';
loadSLLInsights();
} else {
showToast('Fehler', d.error || 'Unbekannter Fehler', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
async function sllAnalyze() {
var token = window.loadToken ? window.loadToken() : '';
var btn = document.getElementById('sll-analyze-btn');
if (btn) { btn.textContent = '⏳ Analyzing…'; btn.disabled = true; }
try {
var r = await fetch(API + '/api/blog/sll/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
});
var d = await r.json();
if (d.success) {
showToast('SLL Analysis Complete', (d.posts_analyzed || 0) + ' posts · ' + (d.patterns_saved || 0) + ' patterns · "' + (d.key_insight || '') + '"');
loadSLLInsights();
} else {
showToast('Fehler', d.error || 'Analysis failed', true);
}
} catch(e) {
showToast('Error', e.message, true);
} finally {
if (btn) { btn.textContent = '⚡ Analyze Patterns'; btn.disabled = false; }
}
}
// ══════════════════════════════════════════════════════
// POSTING TIME — Umami + SLL combined recommendation
// ══════════════════════════════════════════════════════
async function loadPostingTime() {
var token = window.loadToken ? window.loadToken() : '';
var el = document.getElementById('posting-time-content');
var badge = document.getElementById('posting-time-badge');
if (!el) return;
try {
var r = await fetch(API + '/api/blog/sll/posting-time', { headers: { 'Authorization': 'Bearer ' + token } });
var d = await r.json();
if (!d.success) { el.innerHTML = '<span style="color:#c1121f">Fehler beim Laden</span>'; return; }
var rec = d.recommended;
var top = d.top_slots || [];
var ds = d.data_sources || {};
// Badge
badge.textContent = rec ? rec.label : 'keine Daten';
badge.style.background = 'rgba(99,102,241,0.2)';
badge.style.color = '#a5b4fc';
var h = '';
// Recommended slot big display
if (rec) {
h += '<div style="display:flex;align-items:center;gap:1rem;padding:0.6rem 0.75rem;border-radius:8px;background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.25);margin-bottom:0.75rem">';
h += '<div style="font-size:1.6rem;font-weight:800;color:var(--accent)">' + rec.label + '</div>';
h += '<div>';
h += '<div style="font-size:0.72rem;color:var(--text-dim)">Optimaler Zeitslot · Score ' + rec.score + '/100</div>';
h += '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">';
if (rec.umami_sessions > 0) h += '📊 ' + rec.umami_sessions + ' Umami-Sessions &nbsp;';
if (rec.sll_avg_engagement !== null) h += '🧠 SLL ⌀' + rec.sll_avg_engagement + ' Score';
h += '</div>';
h += '</div>';
h += '</div>';
}
// Top 5 slots bar chart
if (top.length > 0) {
var maxScore = top[0].score || 1;
h += '<div style="margin-bottom:0.5rem"><span style="font-size:0.7rem;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em">Top Zeitslots</span></div>';
h += '<div style="display:flex;flex-direction:column;gap:4px">';
top.slice(0, 5).forEach(function(slot, i) {
var pct = Math.round((slot.score / maxScore) * 100);
var isTop = i === 0;
h += '<div style="display:flex;align-items:center;gap:0.5rem">';
h += '<div style="min-width:72px;font-size:0.75rem;font-weight:' + (isTop ? '700' : '400') + ';color:' + (isTop ? 'var(--accent)' : 'var(--text)') + '">' + slot.label + '</div>';
h += '<div style="flex:1;height:6px;background:rgba(255,255,255,0.07);border-radius:3px;overflow:hidden">';
h += '<div style="height:100%;width:' + pct + '%;background:' + (isTop ? 'var(--accent)' : 'rgba(99,102,241,0.4)') + ';border-radius:3px"></div>';
h += '</div>';
h += '<div style="min-width:28px;text-align:right;font-size:0.7rem;color:var(--text-dim)">' + slot.score + '</div>';
var srcIcons = (slot.data_sources || []).map(function(s) { return s === 'umami' ? '📊' : '🧠'; }).join('');
h += '<div style="font-size:0.65rem;min-width:20px">' + srcIcons + '</div>';
h += '</div>';
});
h += '</div>';
}
// Data sources footnote
h += '<div style="margin-top:0.6rem;font-size:0.67rem;color:var(--text-dim)">';
h += '📊 Umami: ' + (ds.umami_sessions_analyzed || 0) + ' Sessions (90d)';
if (ds.umami_cache_age_min !== null && ds.umami_cache_age_min !== undefined) h += ' · Cache: ' + ds.umami_cache_age_min + ' min alt';
h += ' &nbsp;|&nbsp; 🧠 SLL: ' + (ds.sll_posts_with_time || 0) + ' Posts mit Zeit';
h += '</div>';
if (d.note) {
h += '<div style="margin-top:0.3rem;font-size:0.67rem;color:var(--text-dim);font-style:italic">' + d.note + '</div>';
}
el.innerHTML = h;
// Store recommended globally for use in showPostingTimeForBlog
window._lastPostingTimeRec = rec;
} catch(e) {
if (el) el.innerHTML = '<span style="color:var(--text-dim);font-size:0.75rem">Posting-Zeit-Daten nicht verfügbar</span>';
}
}
function showPostingTimeForBlog() {
var rec = window._lastPostingTimeRec;
var highlight = document.getElementById('posting-time-highlight');
var recEl = document.getElementById('posting-time-recommended');
var reasonEl = document.getElementById('posting-time-reason');
if (!highlight || !recEl) return;
if (rec) {
recEl.textContent = '📅 ' + rec.label;
var parts = [];
if (rec.umami_sessions > 0) parts.push(rec.umami_sessions + ' Umami-Sessions');
if (rec.sll_avg_engagement !== null) parts.push('SLL ⌀' + rec.sll_avg_engagement);
reasonEl.textContent = parts.length > 0 ? 'Basis: ' + parts.join(' · ') + ' · Score ' + rec.score + '/100' : 'Score ' + rec.score + '/100';
highlight.style.display = 'block';
} else {
// Re-fetch in case it wasn't loaded yet
loadPostingTime().then(function() {
if (window._lastPostingTimeRec) showPostingTimeForBlog();
});
}
}
async function syncUmami() {
var token = window.loadToken ? window.loadToken() : '';
var btn = document.getElementById('umami-sync-btn');
if (btn) { btn.textContent = '⏳ Syncing…'; btn.disabled = true; }
try {
var r = await fetch(API + '/api/blog/sll/sync-umami', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
});
var d = await r.json();
if (d.success) {
showToast('Umami synced ✓', d.total_sessions + ' Sessions aus ' + d.slots_loaded + ' Slots geladen');
loadPostingTime();
} else {
showToast('Fehler', d.message || 'Umami nicht erreichbar', true);
}
} catch(e) {
showToast('Error', e.message, true);
} finally {
if (btn) { btn.textContent = '↻ Umami'; btn.disabled = false; }
}
}
// TABLE SORTING
function makeSortable(table) {
if (!table) return;
// Prevent duplicate binding — only attach listeners once per table
if (table.dataset.sortBound) return;
table.dataset.sortBound = '1';
var headers = table.querySelectorAll('thead th');
headers.forEach(function(th, colIdx) {
th.addEventListener('click', function(e) {
// Don't sort if clicking inside a badge or link
if (e.target.tagName === 'A') return;
var tbody = table.querySelector('tbody');
if (!tbody) return;
// Toggle direction
var isAsc = th.classList.contains('sort-asc');
// Reset all headers in this table
headers.forEach(function(h) { h.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add(isAsc ? 'sort-desc' : 'sort-asc');
var dir = isAsc ? -1 : 1;
// Get rows and sort
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function(a, b) {
var cellA = a.children[colIdx];
var cellB = b.children[colIdx];
if (!cellA || !cellB) return 0;
var valA = (cellA.textContent || '').trim();
var valB = (cellB.textContent || '').trim();
// Try numeric comparison (handles "23%", "2026", "3y", "$1,234", "12.5 Tbps" etc.)
var numA = parseFloat(valA.replace(/[^0-9.\-]/g, ''));
var numB = parseFloat(valB.replace(/[^0-9.\-]/g, ''));
if (!isNaN(numA) && !isNaN(numB)) {
return (numA - numB) * dir;
}
// Handle "—" as last
if (valA === '—' && valB !== '—') return 1;
if (valB === '—' && valA !== '—') return -1;
return valA.localeCompare(valB, undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
// Re-append in order
rows.forEach(function(row) { tbody.appendChild(row); });
});
});
}
// Initialize sorting on all tables after DOM ready
function initAllSorting() {
document.querySelectorAll('.table-wrap table').forEach(makeSortable);
}
// Run once now and also after each table rebuild
initAllSorting();
// Observer to re-init sorting when tbody content changes
var sortObserver = new MutationObserver(function() { initAllSorting(); });
document.querySelectorAll('tbody').forEach(function(tb) {
sortObserver.observe(tb, { childList: true });
});
// Close compare overlay on backdrop click
el('compare-overlay').addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('visible');
});
// ─── CHANGELOG ───────────────────────────────────────────────────────────────
var changelogEntries = [];
var changelogExpanded = false;
async function loadChangelog() {
try {
var d = await api('/api/changelog');
changelogEntries = d.entries || [];
el('changelog-total').textContent = changelogEntries.length + ' entries';
renderChangelog();
} catch(e) {
el('changelog-list').innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Not available in preview — runs on production server.</div>';
}
}
function toggleChangelog() {
changelogExpanded = !changelogExpanded;
el('changelog-toggle-btn').textContent = changelogExpanded ? 'Show recent' : 'Show all';
renderChangelog();
}
function renderChangelog() {
var entries = changelogExpanded ? changelogEntries : changelogEntries.slice(0, 8);
el('changelog-list').innerHTML = entries.map(function(e) {
return '<div class="cl-entry">'
+ '<span class="cl-date">' + esc(e.d) + '</span>'
+ '<span class="cl-type cl-' + esc(e.t) + '">' + esc(e.t) + '</span>'
+ '<span class="cl-msg">' + esc(e.m) + '</span>'
+ '</div>';
}).join('');
}
// ─── PROCUREMENT INTEL ───────────────────────────────────────────────────────
var procCurrentSignalFilter = '';
var procCurrentAbcFilter = '';
var procSignalsData = [];
var procAbcData = [];
function showProcSection(name) {
['signals','abc','market','lifecycle'].forEach(function(s) {
var sec = el('proc-section-' + s);
var btn = el('proc-btn-' + s);
if (sec) sec.style.display = s === name ? '' : 'none';
if (btn) { btn.classList.toggle('proc-btn-active', s === name); }
});
}
async function loadProcurement() {
await Promise.all([
loadProcSignals(),
loadProcAbc(),
loadProcMarketIntel(),
loadProcLifecycle(),
]);
}
async function loadProcSignals() {
var container = el('proc-signals-grid');
container.innerHTML = '<div class="loading pulse">Loading signals...</div>';
try {
var d = await api('/api/procurement/signals?limit=100');
procSignalsData = d.data || [];
renderSignals(procCurrentSignalFilter);
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem">No reorder signals yet — run the scraper to populate.</div>';
}
}
function filterSignal(sig) {
procCurrentSignalFilter = sig;
renderSignals(sig);
}
function renderSignals(filterSig) {
var data = filterSig ? procSignalsData.filter(function(r) { return r.signal === filterSig; }) : procSignalsData;
var container = el('proc-signals-grid');
if (!data.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No signals for this filter.</div>';
return;
}
var signalIcon = { buy_now:'🔴', wait:'🟡', hold:'🟢', monitor:'🔵' };
var signalLabel = { buy_now:'Buy Now', wait:'Wait', hold:'Hold', monitor:'Monitor' };
var demoBadgeHtml = '<span title="Demo data — inserted as sample data, not real market intelligence." style="font-size:0.6rem;padding:1px 5px;border-radius:3px;background:#f0e4ff;color:#7c3aed;font-weight:700;margin-left:4px;vertical-align:middle">Demo Data</span>';
container.innerHTML = data.map(function(r) {
var reasons = [];
try { reasons = JSON.parse(r.reasons || '[]'); } catch(e) {}
var sigClass = 'signal-' + (r.signal || 'monitor').replace('_','-');
var badgeClass = 'sig-badge-' + (r.signal || 'monitor').replace('_now','').replace('_','');
var abcTitles = { A:'Class A — high turnover product, top 20% by value. Prioritize stock availability.', B:'Class B — medium turnover. Standard replenishment cycle.', C:'Class C — low turnover. Order on demand only.' };
var abcBadge = r.abc_class ? '<span class="abc-' + r.abc_class.toLowerCase() + '" title="' + (abcTitles[r.abc_class] || '') + '">' + r.abc_class + '</span>' : '';
var strengthPct = Math.round((r.signal_strength || 0) * 100);
var productName = r.standard_name || r.part_number || r.slug || '—';
var imgHtml = '';
if (r.image_r2_key) {
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
}
return '<div class="signal-card ' + sigClass + '">'
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
+ imgHtml
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:700;font-size:0.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(productName) + (r.is_demo_data || r.is_demo ? demoBadgeHtml : '') + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(r.form_factor || '') + (r.speed_gbps ? ' · ' + r.speed_gbps + 'G' : '') + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div>'
+ '</div>'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-bottom:0.6rem;flex-wrap:wrap">'
+ '<span class="intel-badge ' + badgeClass + '" title="Procurement signal: Buy Now = act immediately (supply tightening or price rising). Wait = better prices expected. Hold = no action needed. Monitor = track closely.">' + (signalIcon[r.signal] || '') + ' ' + (signalLabel[r.signal] || r.signal) + '</span>'
+ abcBadge
+ (r.supply_risk ? '<span title="Supply chain risk: low = widely available, medium = some constraints, high = single-source or shortage risk" style="font-size:0.65rem;padding:2px 6px;border-radius:3px;background:var(--surface2);color:var(--text-dim)">' + esc(r.supply_risk) + ' risk</span>' : '')
+ '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.5rem">'
+ (reasons.length ? reasons.map(function(r2) { return '→ ' + esc(r2); }).join('<br>') : 'Insufficient data')
+ '</div>'
+ '<div style="display:flex;gap:1rem;font-size:0.7rem;color:var(--text-dim)">'
+ (r.stock_trend ? '<span title="Stock trend based on price observation frequency and vendor listing changes">Stock: <b style="color:var(--text)">' + r.stock_trend + '</b></span>' : '')
+ (r.price_trend ? '<span title="Price trend over last 30 days: rising/falling/stable">Price: <b style="color:var(--text)">' + r.price_trend + '</b></span>' : '')
+ (r.lead_time_weeks ? '<span title="Estimated supplier lead time in weeks until delivery">Lead: <b style="color:var(--text)">' + r.lead_time_weeks + 'w</b></span>' : '')
+ '</div>'
+ '<div title="Signal strength (0100%): confidence in the procurement recommendation, based on data volume, price history consistency, and compatibility coverage." style="margin-top:0.6rem;background:var(--surface2);border-radius:3px;height:4px">'
+ '<div style="height:4px;border-radius:3px;width:' + strengthPct + '%;background:var(--accent)"></div>'
+ '</div>'
+ '<div style="font-size:0.65rem;color:var(--text-dim);text-align:right;margin-top:2px">Signal strength: ' + strengthPct + '%</div>'
+ '</div>';
}).join('');
}
async function loadProcAbc() {
try {
var d = await api('/api/procurement/abc?limit=200');
procAbcData = d.data || [];
renderAbcTable(procCurrentAbcFilter);
} catch(e) {
el('abc-tbody').innerHTML = '<tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">No ABC data yet — run compute:abc job.</td></tr>';
}
}
function filterAbc(cls) {
procCurrentAbcFilter = cls;
renderAbcTable(cls);
}
function renderAbcTable(filterCls) {
var data = filterCls ? procAbcData.filter(function(r) { return r.abc_class === filterCls; }) : procAbcData;
var sigIcon = { buy_now:'🔴', wait:'🟡', hold:'🟢', monitor:'🔵' };
el('abc-tbody').innerHTML = data.map(function(r) {
var abcEl = '<span class="abc-' + (r.abc_class || 'c').toLowerCase() + '">' + (r.abc_class || '—') + '</span>';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:7px 6px">' + abcEl + '</td>'
+ '<td style="padding:7px 6px"><div style="font-weight:600">' + esc(r.standard_name || r.part_number || '—') + (r.is_demo ? '<span title="Demo data — sample entry, not real market data." style="font-size:0.6rem;padding:1px 5px;border-radius:3px;background:#f0e4ff;color:#7c3aed;font-weight:700;margin-left:4px;vertical-align:middle">Demo</span>' : '') + '</div><div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.vendor_name || '') + '</div></td>'
+ '<td style="padding:7px 6px;font-family:var(--mono);font-size:0.75rem">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.demand_score ? parseFloat(r.demand_score).toFixed(0) : '—') + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.compat_count || 0) + '</td>'
+ '<td style="padding:7px 6px;text-align:right;font-family:var(--mono)">' + (r.vendor_count || 0) + '</td>'
+ '<td style="padding:7px 6px;font-size:0.75rem;color:' + (r.supply_risk === 'high' ? 'var(--red)' : r.supply_risk === 'medium' ? 'var(--yellow)' : 'var(--green)') + '">' + esc(r.supply_risk || '—') + '</td>'
+ '<td style="padding:7px 6px">' + (r.signal ? (sigIcon[r.signal] || '') + ' ' + r.signal.replace('_',' ') : '—') + '</td>'
+ '</tr>';
}).join('') || '<tr><td colspan="8" style="padding:1rem;color:var(--text-dim)">No data for this filter.</td></tr>';
}
async function loadProcMarketIntel() {
var container = el('proc-market-grid');
try {
var d = await api('/api/procurement/market-intel?days=180&limit=50');
var items = d.data || [];
if (!items.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No market intelligence yet.</div>';
return;
}
var typeIcon = {
capex_cycle:'💰', trade_show:'🎪', standard_ratified:'📋',
standard_draft:'📝', distributor_lead_time:'🚚', supply_chain:'🏭', tender:'📑'
};
var typeDesc = {
capex_cycle:'Capital expenditure cycle event — customer budget release, fiscal year start, major infrastructure spend',
trade_show:'Trade show or conference (OFC, ECOC, MWC, IEEE) — often signals new product launches and technology shifts',
standard_ratified:'IEEE/MSA standard officially ratified — technology is production-ready, adoption typically accelerates',
standard_draft:'Standard in draft phase — technology is emerging, early adopters phase',
distributor_lead_time:'Distributor lead time change — indicates supply chain pressure or inventory build-up',
supply_chain:'Supply chain event — factory capacity, shortage, logistics disruption',
tender:'Public or enterprise tender/RFP published — indicates near-term procurement demand'
};
container.innerHTML = items.map(function(item) {
var sig = item.buy_signal_implication || 'none';
var badgeClass = 'intel-' + sig.replace('_now','').replace('_','');
var sigLabel = { buy_now:'🔴 Buy Now', wait:'🟡 Wait', hold:'🟢 Hold', monitor:'🔵 Monitor', none:'—' };
var sigDesc = { buy_now:'Buy Now: this market event suggests immediate procurement — prices or availability will worsen', wait:'Wait: conditions suggest holding off — better pricing or availability expected soon', hold:'Hold: market stable, no urgency to act', monitor:'Monitor: track this development, not yet actionable', none:'No specific procurement implication' };
var techs = (item.technologies || []).map(function(t) {
return '<span title="Technology segment this intelligence applies to" style="font-size:0.65rem;padding:1px 6px;border-radius:3px;background:var(--surface2);color:var(--text-dim)">' + esc(t) + '</span>';
}).join(' ');
return '<div class="intel-card">'
+ '<div style="display:flex;gap:0.5rem;align-items:flex-start;margin-bottom:0.4rem">'
+ '<span title="' + esc(typeDesc[item.intel_type] || item.intel_type || '') + '" style="font-size:1.2rem;cursor:default">' + (typeIcon[item.intel_type] || '📊') + '</span>'
+ '<div style="flex:1">'
+ '<span class="intel-badge ' + badgeClass + '" title="' + esc(sigDesc[sig] || sig) + '">' + (sigLabel[sig] || sig) + '</span>'
+ '<div style="font-weight:700;font-size:0.82rem;line-height:1.3;margin-top:0.2rem">' + esc(item.title) + (item.is_demo ? '<span title="Demo data — sample entry, not real market intelligence." style="font-size:0.6rem;padding:1px 5px;border-radius:3px;background:#f0e4ff;color:#7c3aed;font-weight:700;margin-left:4px;vertical-align:middle">Demo Data</span>' : '') + '</div>'
+ '</div></div>'
+ '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.6rem;line-height:1.5">' + esc(item.summary || '') + '</div>'
+ (techs ? '<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-bottom:0.5rem">' + techs + '</div>' : '')
+ '<div style="display:flex;justify-content:space-between;font-size:0.68rem;color:var(--text-dim)">'
+ '<span title="Intelligence source">' + esc(item.source_name) + '</span>'
+ (item.impact_horizon_months ? '<span title="Estimated months until this event has measurable market impact on pricing or availability">Impact: ~' + item.impact_horizon_months + ' months</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load market intelligence.</div>';
}
}
async function loadProcLifecycle() {
var container = el('proc-lifecycle-grid');
try {
var d = await api('/api/procurement/lifecycle?days=365&limit=50');
var items = d.data || [];
if (!items.length) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No lifecycle events yet.</div>';
return;
}
var typeIcon = {
eol_announced:'⛔', eol_effective:'🚫', standard_ratified:'✅',
standard_draft:'📝', capex_peak:'💰', trade_show:'🎪',
supply_risk:'⚠️', tender:'📑', price_floor:'📉'
};
var typeDesc = {
eol_announced:'End-of-Life announced — vendor has confirmed a product or standard will be discontinued. Start planning migration.',
eol_effective:'End-of-Life effective — product is no longer manufactured or supported. Immediately find replacements.',
standard_ratified:'Standard officially ratified by IEEE or MSA — technology is mature, safe to deploy at scale.',
standard_draft:'Standard in draft — technology is emerging. Early adopters phase, compatibility not yet guaranteed.',
capex_peak:'Capital expenditure peak — major procurement wave expected. May affect availability and pricing.',
trade_show:'Trade show event (OFC, ECOC, MWC) — often triggers new product launches and price adjustments.',
supply_risk:'Supply chain risk identified — potential shortage, capacity constraint, or geopolitical factor.',
tender:'Public or enterprise tender published — indicates confirmed near-term demand from large buyer.',
price_floor:'Price floor reached — technology has hit bottom pricing. Unlikely to drop further; good time to stock up.'
};
var impactColor = { critical:'#c1121f', high:'#c1121f', medium:'var(--yellow)', low:'var(--green)' };
var impactDesc = { critical:'Critical impact — immediate action required', high:'High impact — plan response within weeks', medium:'Medium impact — monitor and prepare response', low:'Low impact — informational' };
var sigLabel = { buy_now:'🔴 Buy Now', wait:'🟡 Wait', hold:'🟢 Hold', monitor:'🔵 Monitor' };
var sigDesc = { buy_now:'Buy Now: this event signals immediate procurement urgency', wait:'Wait: better conditions expected after this event resolves', hold:'Hold: no change to current procurement strategy', monitor:'Monitor: track how this event develops before acting' };
container.innerHTML = items.map(function(item) {
var ic = impactColor[item.impact_level] || 'var(--text-dim)';
var productInfo = item.part_number ? esc(item.part_number) + (item.form_factor ? ' · ' + esc(item.form_factor) : '') : '';
var dateStr = item.effective_date ? new Date(item.effective_date).toLocaleDateString('de-DE') : '';
return '<div class="intel-card" style="border-left:3px solid ' + ic + '" title="' + esc(impactDesc[item.impact_level] || '') + '">'
+ '<div style="display:flex;gap:0.5rem;align-items:flex-start;margin-bottom:0.4rem">'
+ '<span title="' + esc(typeDesc[item.event_type] || item.event_type || '') + '" style="font-size:1.2rem;cursor:default">' + (typeIcon[item.event_type] || '📌') + '</span>'
+ '<div style="flex:1">'
+ (item.buy_signal ? '<span class="intel-badge intel-' + item.buy_signal.replace('_now','').replace('_','') + '" title="' + esc(sigDesc[item.buy_signal] || item.buy_signal) + '">' + (sigLabel[item.buy_signal] || item.buy_signal) + '</span>' : '')
+ '<div style="font-weight:700;font-size:0.82rem;line-height:1.3;margin-top:0.2rem">' + esc(item.title) + (item.is_demo ? '<span title="Demo data — sample entry, not a real lifecycle event." style="font-size:0.6rem;padding:1px 5px;border-radius:3px;background:#f0e4ff;color:#7c3aed;font-weight:700;margin-left:4px;vertical-align:middle">Demo Data</span>' : '') + '</div>'
+ '</div></div>'
+ (item.description ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.5rem;line-height:1.5">' + esc(item.description.substring(0, 200)) + (item.description.length > 200 ? '…' : '') + '</div>' : '')
+ '<div style="display:flex;justify-content:space-between;font-size:0.68rem;color:var(--text-dim)">'
+ '<span>' + esc(item.source_name || '') + (productInfo ? ' · ' + productInfo : '') + '</span>'
+ (dateStr ? '<span title="Effective date of this lifecycle event" style="color:' + ic + ';font-weight:600">' + dateStr + '</span>' : '')
+ '</div>'
+ '</div>';
}).join('');
} catch(e) {
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load lifecycle events.</div>';
}
}
// INIT
loadOverview();
loadChangelog();
searchTransceivers(); // pre-load transceivers table on startup
// Review stats: only after confirming auth (loadOverview sets up auth state)
setTimeout(function() {
if (window.loadToken && window.loadToken()) loadReviewStats().catch(function() {});
}, 1500);
// ── SELFLEARNING TRAINING ───────────────────────────────────────────
function selflearningMetric(label, value, color) {
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.6rem;text-align:center">'
+ '<div style="font-size:1rem;font-weight:800;color:' + (color || 'var(--accent)') + ';font-family:var(--mono)">' + esc(value == null ? '-' : value) + '</div>'
+ '<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.06em;margin-top:2px">' + esc(label) + '</div></div>';
}
function renderSelflearningLane(key, data, laneInfo) {
var metricsEl = el(key === 'tip_llm' ? 'sl-tip-metrics' : 'sl-blog-metrics');
var stateEl = el(key === 'tip_llm' ? 'sl-tip-state' : 'sl-blog-state');
var datasetEl = el(key === 'tip_llm' ? 'sl-tip-dataset' : 'sl-blog-dataset');
if (datasetEl) datasetEl.textContent = (laneInfo && laneInfo.dataset) || '-';
if (!metricsEl) return;
if (!data) {
metricsEl.innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.75rem">Noch kein Manifest. Erst Build Pool starten.</div>';
if (stateEl) { stateEl.textContent = 'needs build'; stateEl.className = 'b b-yellow'; }
return;
}
var pairs = data.training_pairs || 0;
metricsEl.innerHTML = [
selflearningMetric('Pairs', pairs.toLocaleString('de-DE'), '#22c55e'),
selflearningMetric('Train', (data.train_pairs || 0).toLocaleString('de-DE'), 'var(--accent)'),
selflearningMetric('Eval', (data.eval_pairs || 0).toLocaleString('de-DE'), '#60a5fa'),
selflearningMetric('Dedupe', (data.duplicates_removed || 0).toLocaleString('de-DE'), '#f59e0b')
].join('');
if (stateEl) {
stateEl.textContent = pairs > 0 ? 'ready' : 'empty';
stateEl.className = pairs > 0 ? 'b b-green' : 'b b-yellow';
}
}
async function loadSelflearning() {
var banner = el('selflearning-status-banner');
try {
var d = await api('/api/selflearning/status');
var manifest = d.manifest || null;
renderSelflearningLane('tip_llm', manifest && manifest.lanes && manifest.lanes.tip_llm, d.lanes && d.lanes.tip_llm);
renderSelflearningLane('blog_llm', manifest && manifest.lanes && manifest.lanes.blog_llm, d.lanes && d.lanes.blog_llm);
if (banner) {
var parts = [
'RunPod Endpoint: ' + (d.runpod && d.runpod.endpoint_configured ? 'ok' : 'fehlt'),
'RunPod API: ' + (d.runpod && d.runpod.api_key_configured ? 'ok' : 'fehlt'),
'HF Token: ' + (d.huggingface && d.huggingface.token_configured ? 'ok' : 'fehlt'),
'Local: ' + (d.local && d.local.ready ? 'konfiguriert' : 'noch kein TIP_LOCAL_TRAIN_COMMAND')
];
banner.innerHTML = '<strong>Status:</strong> ' + parts.map(esc).join(' · ')
+ (manifest ? '<br><span style="font-size:0.72rem">Manifest: ' + esc(manifest.version || '-') + ' · ' + esc(manifest.generated_at || '-') + '</span>' : '<br><span style="font-size:0.72rem">Noch kein Manifest vorhanden.</span>');
}
} catch(e) {
if (banner) banner.innerHTML = '<span style="color:#f87171">Selflearning status failed: ' + esc(e.message) + '</span>';
}
}
function setSelflearningLog(text) {
var log = el('selflearning-log');
if (log) log.textContent = text;
}
async function buildSelflearningPool() {
setSelflearningLog('Build Pool laeuft...');
try {
var d = await api('/api/selflearning/build', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
setSelflearningLog(JSON.stringify(d.manifest || d, null, 2));
showToast('Learning Pool gebaut', 'Deduplizierte TIP_LLM und Blog_LLM Datasets sind aktualisiert.');
loadSelflearning();
} catch(e) {
setSelflearningLog(e.message + (e.body ? '\n' + JSON.stringify(e.body, null, 2) : ''));
showToast('Build fehlgeschlagen', e.message, true);
}
}
async function publishSelflearningHF() {
setSelflearningLog('HF Publish laeuft...');
try {
var d = await api('/api/selflearning/publish-hf', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
setSelflearningLog((d.stdout || '') + (d.stderr ? '\nSTDERR:\n' + d.stderr : ''));
showToast('HF Sync fertig', 'Private TIP_LLM und Blog_LLM Datasets sind publiziert.');
loadSelflearning();
} catch(e) {
setSelflearningLog(e.message + (e.body ? '\n' + JSON.stringify(e.body, null, 2) : ''));
showToast('HF Sync fehlgeschlagen', e.message, true);
}
}
async function startSelflearningTrain(lane, provider, seedOnly) {
var label = lane + ' / ' + provider + (seedOnly ? ' / seed' : ' / full');
setSelflearningLog('Training Start: ' + label);
try {
var d = await api('/api/selflearning/train', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane: lane, provider: provider, seed_only: seedOnly, max_steps: seedOnly ? 200 : 2000 })
});
setSelflearningLog(JSON.stringify(d, null, 2));
showToast('Training gestartet', label);
} catch(e) {
setSelflearningLog(e.message + (e.body ? '\n' + JSON.stringify(e.body, null, 2) : ''));
showToast('Training fehlgeschlagen', e.message, true);
}
}
// ── CRAWLER INTELLIGENCE ────────────────────────────────────────────
async function loadCrawlerStatus() {
loadCrawlerJobs(); // load live job queue in parallel
var token = (window.loadToken ? window.loadToken() : '') || '';
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 += '<div style="margin-bottom:1.5rem"><div style="font-size:0.75rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-bottom:0.6rem">' + catLabel[cat] + '</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.5rem">';
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 += '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;display:flex;gap:0.75rem;align-items:flex-start">'
+ '<div style="width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-top:4px;flex-shrink:0;box-shadow:0 0 6px ' + dot + '"></div>'
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:700;font-size:0.82rem;color:var(--text-bright)">' + esc(s.label) + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:2px">'
+ (s.records ? '<span style="color:var(--blue);font-weight:600">' + s.records.toLocaleString() + ' records</span> · ' : '<span style="color:#64748b">0 records</span> · ')
+ 'Last: ' + lastRun
+ '</div>'
+ '<div style="font-size:0.68rem;color:var(--text-dim)">First seen: ' + firstSeen + '</div>'
+ '</div></div>';
}
html += '</div></div>';
}
document.getElementById('cr-scraper-list').innerHTML = html || '<div style="color:var(--text-dim)">No scraper data available.</div>';
// LLM Insights — Hot Topics
var topics = (insights && insights.hotTopics) || [];
var buyColors = { bullish: 'var(--green)', bearish: '#ef4444', neutral: 'var(--text-dim)', opportunity: '#f59e0b' };
var topicsHtml = topics.length ? topics.map(function(t) {
var scoreVal = t.trend_score != null ? Math.round(Number(t.trend_score) * 100) : null;
var buyColor = buyColors[t.buy_signal_implication] || 'var(--text-dim)';
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;margin-bottom:0.5rem">'
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">'
+ '<div style="font-weight:700;font-size:0.82rem;color:var(--text-bright);flex:1;min-width:200px">' + esc(t.title || '') + '</div>'
+ '<div style="display:flex;gap:0.4rem;flex-shrink:0;flex-wrap:wrap">'
+ (t.category ? '<span style="font-size:0.68rem;background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px">' + esc(t.category.replace(/_/g,' ')) + '</span>' : '')
+ (t.buy_signal_implication ? '<span style="font-size:0.68rem;background:rgba(0,0,0,0.2);color:' + buyColor + ';border-radius:4px;padding:2px 6px;font-weight:600">' + esc(t.buy_signal_implication) + '</span>' : '')
+ (scoreVal != null ? '<span style="font-size:0.68rem;background:rgba(59,130,246,0.15);color:var(--blue);border-radius:4px;padding:2px 6px">Score: ' + scoreVal + '%</span>' : '')
+ '</div>'
+ '</div>'
+ (t.summary ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:4px;line-height:1.5">' + esc(t.summary.substring(0,220)) + (t.summary.length > 220 ? '…' : '') + '</div>' : '')
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:4px">' + esc(t.source || '') + (t.published_at ? ' · ' + new Date(t.published_at).toLocaleDateString('de-DE') : '') + '</div>'
+ '</div>';
}).join('') : '<div style="color:var(--text-dim);padding:1rem">No LLM insights yet — run scrapers first.</div>';
document.getElementById('cr-topics').innerHTML = topicsHtml;
// Knowledge Base entries — grouped by intel_type from market_intelligence
var kb = (insights && insights.knowledgeBase) || [];
var typeLabels = {
capex_cycle: '📈 CapEx Cycle', supply_chain: '🏭 Supply Chain',
distributor_lead_time: '📦 Lead Times', standard_draft: '📋 Draft Standards',
standard_ratified: '✅ Ratified Standards', trade_show: '🎪 Trade Shows',
tender: '📑 Tenders', market_share: '📊 Market Share',
technology_launch: '🚀 Technology Launch', price_movement: '💶 Price Movement'
};
var kbHtml = kb.length ? '<table style="width:100%;border-collapse:collapse;font-size:0.78rem"><thead><tr style="background:var(--surface2)">'
+ '<th style="padding:0.5rem;text-align:left;color:var(--text-dim)">Intelligence Type</th>'
+ '<th style="padding:0.5rem;text-align:right;color:var(--text-dim)">Items</th>'
+ '<th style="padding:0.5rem;text-align:right;color:var(--text-dim)">Top Relevance</th>'
+ '<th style="padding:0.5rem;text-align:right;color:var(--text-dim)">Latest</th>'
+ '</tr></thead><tbody>'
+ kb.map(function(k) {
var label = typeLabels[k.category] || k.category || '—';
var relScore = k.top_relevance != null ? Math.round(Number(k.top_relevance) * 100) + '%' : '—';
var latest = k.latest ? new Date(k.latest).toLocaleDateString('de-DE') : '—';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:0.5rem;color:var(--text-bright)">' + esc(label) + '</td>'
+ '<td style="padding:0.5rem;text-align:right;color:var(--blue);font-weight:600">' + esc(String(k.count || 0)) + '</td>'
+ '<td style="padding:0.5rem;text-align:right;color:var(--green)">' + relScore + '</td>'
+ '<td style="padding:0.5rem;text-align:right;color:var(--text-dim)">' + latest + '</td>'
+ '</tr>';
}).join('')
+ '</tbody></table>'
: '<div style="color:var(--text-dim);padding:1rem">No market intelligence data yet — scrapers running.</div>';
document.getElementById('cr-kb-entries').innerHTML = kbHtml;
}
/* ── Crawler Jobs (Live Queue) ──────────────────────────────────────────── */
async function loadCrawlerJobs() {
var token = (window.loadToken ? window.loadToken() : '') || '';
var data = null;
try {
var r = await fetch('/api/scrapers/jobs', { headers: { 'Authorization': 'Bearer ' + token } });
data = await r.json();
} catch(e) {}
var active = (data && data.active) || [];
var recent = (data && data.recent) || [];
var dotEl = el('cr-live-dot');
var countEl = el('cr-active-jobs-count');
if (active.length > 0) {
if (dotEl) { dotEl.style.background = '#22c55e'; dotEl.style.boxShadow = '0 0 8px #22c55e'; }
if (countEl) countEl.textContent = active.length + ' job' + (active.length !== 1 ? 's' : '') + ' running';
} else {
if (dotEl) { dotEl.style.background = '#64748b'; dotEl.style.boxShadow = 'none'; }
if (countEl) countEl.textContent = 'Idle — waiting for next schedule';
}
var stateColor = { completed: '#22c55e', failed: '#ef4444', cancelled: '#f59e0b' };
var liveEl = el('cr-live-jobs');
if (liveEl) {
if (active.length > 0) {
var liveRows = active.map(function(j) {
var since = (j.started_on || j.startedon) ? Math.round((Date.now() - new Date(j.started_on || j.startedon).getTime()) / 1000) + 's' : '—';
var row = document.createElement('div');
row.style.cssText = 'background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.3);border-radius:6px;padding:0.6rem 0.9rem;display:flex;align-items:center;gap:0.75rem;margin-bottom:0.3rem';
var dot = document.createElement('span');
dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:#22c55e;flex-shrink:0';
var name = document.createElement('span');
name.style.cssText = 'font-size:0.82rem;font-weight:600;color:var(--text-bright);flex:1';
name.textContent = j.name;
var dur = document.createElement('span');
dur.style.cssText = 'font-size:0.72rem;color:var(--text-dim)';
dur.textContent = 'running ' + since;
row.appendChild(dot); row.appendChild(name); row.appendChild(dur);
return row;
});
liveEl.replaceChildren.apply(liveEl, liveRows);
} else {
liveEl.textContent = 'No jobs currently active.';
liveEl.style.color = 'var(--text-dim)';
liveEl.style.fontSize = '0.82rem';
}
}
var recentEl = el('cr-recent-jobs');
if (recentEl) {
if (recent.length > 0) {
var rows = recent.slice(0, 20).map(function(j) {
var when = (j.completed_on || j.completedon) ? new Date(j.completed_on || j.completedon).toLocaleTimeString('de-DE') : '—';
var color = stateColor[j.state] || '#64748b';
var dur = j.duration_sec != null ? j.duration_sec + 's' : '';
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:0.6rem;font-size:0.75rem;padding:0.35rem 0.6rem;border-radius:4px;background:var(--surface2);border:1px solid var(--border);margin-bottom:0.25rem';
var dot = document.createElement('span');
dot.style.cssText = 'width:7px;height:7px;border-radius:50%;background:' + color + ';flex-shrink:0';
var name = document.createElement('span');
name.style.cssText = 'flex:1;color:var(--text-bright);font-weight:500';
name.textContent = j.name;
var durSpan = document.createElement('span');
durSpan.style.color = 'var(--text-dim)';
durSpan.textContent = dur;
var state = document.createElement('span');
state.style.cssText = 'color:' + color + ';font-weight:600;min-width:70px;text-align:right';
state.textContent = j.state;
var whenSpan = document.createElement('span');
whenSpan.style.cssText = 'color:var(--text-dim);min-width:55px;text-align:right';
whenSpan.textContent = when;
row.appendChild(dot); row.appendChild(name); row.appendChild(durSpan);
row.appendChild(state); row.appendChild(whenSpan);
return row;
});
recentEl.replaceChildren.apply(recentEl, rows);
} else {
recentEl.textContent = 'No recent completions in the last 2 hours.';
}
}
}
/* ── 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);
/* ── Proxy Network ───────────────────────────────────────────────────────── */
var proxyNetworkLoaded = false;
async function loadProxyNetwork() {
if (proxyNetworkLoaded) return;
proxyNetworkLoaded = true;
try {
var stats = await fetch('/api/proxy/stats').then(function(r) { return r.json(); });
if (stats.success) {
var n = stats.network;
el('pn-online').textContent = n.onlineNodes;
el('pn-countries').textContent = n.countries;
el('pn-gb').textContent = n.totalGbProxied.toFixed(2) + ' GB';
el('pn-requests').textContent = n.totalRequests.toLocaleString();
}
} catch (e) {
console.warn('[proxy] stats fetch failed:', e);
}
loadProxyNodes();
}
async function loadProxyNodes() {
var tbody = el('pn-node-table');
try {
// Public stats to show online nodes (admin token for full list)
var stats = await fetch('/api/proxy/stats').then(function(r) { return r.json(); });
var countries = stats.countries || [];
if (!countries.length) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:1rem;color:var(--text-dim);text-align:center">No nodes registered yet. Be the first!</td></tr>';
return;
}
// Country table (public view — no sensitive IPs shown)
tbody.innerHTML = countries.map(function(c) {
var flag = c.country_code ? countryFlag(c.country_code) : '🌍';
var statusDot = Number(c.online) > 0
? '<span style="color:var(--green)">&#9679;</span>'
: '<span style="color:var(--text-dim)">&#9675;</span>';
return '<tr>'
+ '<td style="padding:0.5rem 0.75rem">' + flag + ' ' + (c.country_code || '??') + '</td>'
+ '<td style="padding:0.5rem 0.75rem;color:var(--text-dim)">—</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:center">' + statusDot + ' ' + c.online + ' online</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:right;color:var(--text-dim)">—</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:right;color:var(--text-dim)">—</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:right">' + c.nodes + '</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:right;color:var(--text-dim)">—</td>'
+ '<td style="padding:0.5rem 0.75rem;text-align:right;color:var(--text-dim)">—</td>'
+ '</tr>';
}).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" style="padding:1rem;color:var(--red);text-align:center">Failed to load nodes</td></tr>';
}
}
function countryFlag(code) {
if (!code || code.length !== 2) return '🌍';
var codePoints = code.toUpperCase().split('').map(function(c) {
return 0x1F1E6 - 65 + c.charCodeAt(0);
});
return String.fromCodePoint(codePoints[0], codePoints[1]);
}
async function generateProxyToken() {
var btn = el('pn-gen-token-btn');
btn.disabled = true;
btn.textContent = 'Generating…';
try {
var resp = await fetch('/api/proxy/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Dashboard Node' })
}).then(function(r) { return r.json(); });
if (resp.success && resp.token) {
el('pn-token-val').textContent = resp.token;
el('pn-token-result').style.display = 'block';
// Update install command
el('pn-install-cmd').textContent = 'npx @tip/proxy-agent start --token ' + resp.token;
btn.textContent = 'Token Generated!';
proxyNetworkLoaded = false;
setTimeout(function() { loadProxyNetwork(); }, 500);
} else {
btn.textContent = 'Failed — retry?';
btn.disabled = false;
}
} catch (e) {
btn.textContent = 'Error — retry?';
btn.disabled = false;
}
}
function copyInstallCmd(el) {
var text = document.getElementById('pn-install-cmd').textContent;
navigator.clipboard.writeText(text).then(function() {
var orig = el.style.borderColor;
el.style.borderColor = 'var(--accent)';
setTimeout(function() { el.style.borderColor = orig; }, 1000);
}).catch(function() {});
}
// ══════════════════════════════════════════════════════════════════════════════
// REVIEW TAB — Manual equivalence review queue
// ══════════════════════════════════════════════════════════════════════════════
var reviewState = { filter: 'pending', page: 1, total: 0, loaded: false };
async function loadReview() {
if (!reviewState.loaded) {
await loadReviewStats();
reviewState.loaded = true;
}
await loadReviewPage(1);
}
async function loadReviewStats() {
try {
var s = await api('/api/review/equivalences/stats');
var stats = s.stats || {};
// Update pending badge in tab nav
var badge = el('review-pending-badge');
if (badge) {
if (stats.pending > 0) {
badge.textContent = stats.pending;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
// Stat pills inside review tab
buildDOM(el('review-stat-pills'), [
{ label: 'Pending', count: stats.pending, color: '#f97316' },
{ label: 'Auto-Approved', count: stats.auto_approved, color: '#22c55e' },
{ label: 'Approved', count: stats.approved, color: '#6366f1' },
{ label: 'Rejected', count: stats.rejected, color: '#ef4444' },
{ label: 'Re-Research', count: stats.needs_research, color: '#f59e0b' },
].map(function(p) {
return '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:3px 12px;font-size:0.72rem;color:var(--text-dim)">'
+ '<span style="color:' + p.color + ';font-weight:700">' + (p.count||0) + '</span> ' + p.label + '</span>';
}).join(''));
// Update Re-Research filter badge
var nrBadge = el('needs-research-badge');
if (nrBadge) {
var nrCount = stats.needs_research || 0;
nrBadge.textContent = nrCount;
nrBadge.style.display = nrCount > 0 ? '' : 'none';
}
} catch(e) {}
}
async function loadReviewPage(page) {
reviewState.page = page;
var list = el('review-list');
var empty = el('review-empty');
var more = el('review-load-more');
if (page === 1) buildDOM(list, '');
try {
var data = await api('/api/review/equivalences?status=' + reviewState.filter + '&page=' + page + '&limit=30');
var items = data.data || [];
reviewState.total = data.total || 0;
if (items.length === 0 && page === 1) {
if (empty) empty.style.display = '';
if (more) more.style.display = 'none';
return;
}
if (empty) empty.style.display = 'none';
var html = items.map(function(eq) {
return buildReviewCard(eq);
}).join('');
list.insertAdjacentHTML('beforeend', html);
var shown = (page - 1) * 30 + items.length;
if (more) more.style.display = shown < reviewState.total ? '' : 'none';
} catch(e) {
if (list) list.innerHTML = '<div style="color:var(--err);padding:1rem">Error loading review queue: ' + esc(String(e)) + '</div>';
}
}
function buildReviewCard(eq) {
var conf = parseFloat(eq.confidence || 0);
var confPct = Math.round(conf * 100);
var confColor = conf >= 0.85 ? '#22c55e' : conf >= 0.65 ? '#f97316' : '#ef4444';
var statusColor = { pending: '#f97316', approved: '#6366f1', auto_approved: '#22c55e', rejected: '#ef4444' };
var sc = statusColor[eq.status] || 'var(--text-dim)';
var basis = (eq.match_basis || []).join(' · ');
var fxUrl = eq.fx_url ? '<a href="' + esc(eq.fx_url) + '" target="_blank" style="color:var(--indigo);font-size:0.7rem">↗ product page</a>' : '';
var cpUrl = eq.cp_url ? '<a href="' + esc(eq.cp_url) + '" target="_blank" style="color:var(--indigo);font-size:0.7rem">↗ product page</a>' : '';
var cpPrice = eq.cp_latest_price
? '<span style="color:#22c55e;font-weight:700">' + parseFloat(eq.cp_latest_price).toFixed(2) + ' ' + (eq.cp_latest_currency||'') + '</span>'
: '<span style="color:var(--text-dim)">no price</span>';
var reResearchBadge = eq.re_research_due_at
? '<span style="font-size:0.65rem;background:#f59e0b;color:#fff;border-radius:4px;padding:1px 6px;margin-left:6px;font-weight:600">⏳ Re-Research</span>'
: '';
var actionBtns = '';
if (eq.status === 'pending') {
actionBtns = '<div style="display:flex;gap:0.5rem;margin-top:0.75rem">'
+ '<button onclick="approveEquivalence(\'' + eq.id + '\',this)" style="flex:1;padding:6px;background:#22c55e;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.78rem;font-weight:600">✓ Approve</button>'
+ '<button onclick="rejectEquivalence(\'' + eq.id + '\',this)" style="flex:1;padding:6px;background:#ef4444;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.78rem;font-weight:600">✕ Reject</button>'
+ '<button onclick="editEquivNotes(\'' + eq.id + '\',this)" style="padding:6px 12px;background:var(--surface3);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:0.78rem">✎ Note</button>'
+ '</div>';
} else if (eq.status === 'approved' || eq.status === 'auto_approved') {
var reResearchInfo = eq.re_research_due_at
? '<span style="font-size:0.65rem;color:#f59e0b">⏳ Due ' + new Date(eq.re_research_due_at).toLocaleDateString() + (eq.re_researched_at ? ' · last checked ' + new Date(eq.re_researched_at).toLocaleDateString() : '') + '</span>'
: '';
actionBtns = '<div style="margin-top:0.75rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">'
+ '<span style="font-size:0.72rem;color:#22c55e">✓ ' + (eq.status === 'auto_approved' ? 'Auto-approved' : 'Approved by ' + esc(eq.reviewed_by||'—')) + '</span>'
+ reResearchInfo
+ '<button onclick="rejectEquivalence(\'' + eq.id + '\',this)" style="padding:3px 10px;background:none;color:#ef4444;border:1px solid #ef4444;border-radius:6px;cursor:pointer;font-size:0.7rem">Revoke</button>'
+ '</div>';
} else if (eq.status === 'rejected') {
actionBtns = '<div style="margin-top:0.75rem;display:flex;gap:0.5rem;align-items:center">'
+ '<span style="font-size:0.72rem;color:#ef4444">✕ Rejected' + (eq.reject_reason ? ': ' + esc(eq.reject_reason) : '') + '</span>'
+ '<button onclick="approveEquivalence(\'' + eq.id + '\',this)" style="padding:3px 10px;background:none;color:#22c55e;border:1px solid #22c55e;border-radius:6px;cursor:pointer;font-size:0.7rem">Re-approve</button>'
+ '</div>';
}
return '<div id="eq-card-' + eq.id + '" style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1rem">'
// Header: status + confidence
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">'
+ '<div style="display:flex;align-items:center">'
+ '<span style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.05em;color:' + sc + ';font-weight:700;border:1px solid ' + sc + ';border-radius:4px;padding:1px 7px">' + esc(eq.status.replace('_',' ')) + '</span>'
+ reResearchBadge
+ '</div>'
+ '<div style="display:flex;align-items:center;gap:0.5rem">'
+ '<span style="font-size:0.72rem;color:var(--text-dim)">Confidence</span>'
+ '<span style="font-size:1rem;font-weight:700;color:' + confColor + ';font-family:var(--mono)">' + confPct + '%</span>'
+ '<div style="width:60px;height:6px;background:var(--surface3);border-radius:3px"><div style="width:' + confPct + '%;height:100%;background:' + confColor + ';border-radius:3px"></div></div>'
+ '</div></div>'
// Two-column comparison
+ '<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:0.75rem;align-items:start">'
// Flexoptix side
+ '<div style="background:var(--surface3);border-radius:8px;padding:0.65rem">'
+ '<div style="font-size:0.62rem;color:#6366f1;text-transform:uppercase;font-weight:700;margin-bottom:0.35rem">Flexoptix</div>'
+ '<div style="font-weight:700;color:var(--text-bright);font-size:0.88rem;font-family:var(--mono)">' + esc(eq.fx_part_number||'—') + '</div>'
+ (eq.fx_standard_name ? '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem">' + esc(eq.fx_standard_name) + '</div>' : '')
+ '<div style="margin-top:0.4rem;font-size:0.7rem;color:var(--text-dim);line-height:1.6">'
+ '<span class="b b-blue" style="font-size:0.62rem">' + esc(eq.fx_form_factor||'') + '</span> '
+ '<span class="b b-neutral" style="font-size:0.62rem">' + esc(eq.fx_speed||'') + '</span> '
+ (eq.fx_reach_label ? '<span style="font-size:0.7rem">' + esc(eq.fx_reach_label) + '</span>' : '')
+ (eq.fx_fiber_type ? ' · ' + esc(eq.fx_fiber_type) : '')
+ '</div>'
+ (fxUrl ? '<div style="margin-top:0.3rem">' + fxUrl + '</div>' : '')
+ '</div>'
// Arrow
+ '<div style="text-align:center;color:var(--text-dim);font-size:1.1rem;padding-top:1.5rem">↔</div>'
// Competitor side
+ '<div style="background:var(--surface3);border-radius:8px;padding:0.65rem">'
+ '<div style="font-size:0.62rem;color:#22c55e;text-transform:uppercase;font-weight:700;margin-bottom:0.35rem">' + esc(eq.cp_vendor||'Competitor') + '</div>'
+ '<div style="font-weight:700;color:var(--text-bright);font-size:0.88rem;font-family:var(--mono)">' + esc(eq.cp_part_number||'—') + '</div>'
+ (eq.cp_standard_name ? '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem">' + esc(eq.cp_standard_name) + '</div>' : '')
+ '<div style="margin-top:0.4rem;font-size:0.7rem;color:var(--text-dim);line-height:1.6">'
+ '<span class="b b-blue" style="font-size:0.62rem">' + esc(eq.cp_form_factor||'') + '</span> '
+ '<span class="b b-neutral" style="font-size:0.62rem">' + esc(eq.cp_speed||'') + '</span> '
+ (eq.cp_reach_label ? '<span style="font-size:0.7rem">' + esc(eq.cp_reach_label) + '</span>' : '')
+ (eq.cp_fiber_type ? ' · ' + esc(eq.cp_fiber_type) : '')
+ '</div>'
+ '<div style="margin-top:0.3rem;font-size:0.7rem">Price: ' + cpPrice + '</div>'
+ (cpUrl ? '<div style="margin-top:0.2rem">' + cpUrl + '</div>' : '')
+ '</div>'
+ '</div>'
// Match basis
+ '<div style="margin-top:0.6rem;font-size:0.68rem;color:var(--text-dim)">Match basis: <span style="color:var(--text)">' + esc(basis||'—') + '</span></div>'
// Notes
+ (eq.match_notes ? '<div id="eq-notes-' + eq.id + '" style="margin-top:0.4rem;font-size:0.7rem;color:var(--text-dim);font-style:italic">' + esc(eq.match_notes) + '</div>' : '<div id="eq-notes-' + eq.id + '"></div>')
// Action buttons
+ actionBtns
+ '</div>';
}
function setReviewFilter(f) {
reviewState.filter = f;
reviewState.loaded = false;
document.querySelectorAll('.review-filter-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.rfilter === f);
b.style.background = b.dataset.rfilter === f ? 'var(--indigo)' : 'var(--surface2)';
b.style.color = b.dataset.rfilter === f ? '#fff' : 'var(--text)';
b.style.borderColor= b.dataset.rfilter === f ? 'var(--indigo)' : 'var(--border)';
});
loadReviewPage(1).then(function() {
buildDOM(el('review-list'), '');
loadReviewPage(1);
});
}
async function approveEquivalence(id, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
var r = await api('/api/review/equivalences/' + id + '/approve', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({reviewer: 'dashboard'}) });
var card = el('eq-card-' + id);
if (card) {
card.style.borderColor = '#22c55e';
card.style.opacity = '0.7';
setTimeout(function() { if (card) card.remove(); }, 800);
}
await loadReviewStats();
if (r.fully_verified_earned) {
console.log('[review] ★ Fully Verified earned for transceiver!');
}
} catch(e) {
btn.disabled = false;
btn.textContent = '✓ Approve';
}
}
async function rejectEquivalence(id, btn) {
var reason = prompt('Rejection reason (optional):') ?? '';
btn.disabled = true;
btn.textContent = '…';
try {
await api('/api/review/equivalences/' + id + '/reject', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({reason: reason, reviewer: 'dashboard'}) });
var card = el('eq-card-' + id);
if (card) {
card.style.borderColor = '#ef4444';
card.style.opacity = '0.7';
setTimeout(function() { if (card) card.remove(); }, 800);
}
await loadReviewStats();
} catch(e) {
btn.disabled = false;
btn.textContent = '✕ Reject';
}
}
async function editEquivNotes(id, btn) {
var notesEl = el('eq-notes-' + id);
var current = notesEl ? notesEl.textContent.trim() : '';
var newNotes = prompt('Edit notes:', current);
if (newNotes === null) return;
try {
await api('/api/review/equivalences/' + id, { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({match_notes: newNotes}) });
if (notesEl) notesEl.textContent = newNotes;
} catch(e) {
alert('Save failed: ' + e);
}
}
async function bulkApproveHighConfidence() {
var btn = document.getElementById('bulk-approve-btn');
if (!btn || btn.disabled) return;
btn.disabled = true;
btn.textContent = '⏳ Approving…';
try {
var r = await api('/api/review/equivalences/bulk-approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threshold: 0.73, reviewer: 'bulk-dashboard' })
});
btn.textContent = '✓ ' + r.approved + ' approved!';
if (r.fully_verified_earned > 0) {
showToast('Bulk Approve', r.approved + ' matches approved · ' + r.fully_verified_earned + ' × ★ Fully Verified earned', false);
} else {
showToast('Bulk Approve', r.approved + ' matches ≥74% approved', false);
}
reviewState.loaded = false;
await loadReview();
setTimeout(function() {
btn.disabled = false;
btn.textContent = '✓ Bulk-Approve ≥73%';
}, 3000);
} catch(e) {
showToast('Bulk Approve fehlgeschlagen', e.message || 'Fehler', true);
btn.disabled = false;
btn.textContent = '✓ Bulk-Approve ≥73%';
}
}
async function approveAll() {
var btn = document.getElementById('approve-all-btn');
if (!btn || btn.disabled) return;
var pending = reviewState.filter === 'pending' ? reviewState.total : '?';
if (!confirm('Approve ALL pending equivalences? Low-confidence matches (<73%) will be flagged for re-research.')) return;
btn.disabled = true;
btn.textContent = '⏳ Approving…';
try {
var r = await api('/api/review/equivalences/approve-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer: 'approve-all-dashboard' })
});
btn.textContent = '✓ ' + r.approved + ' approved!';
var msg = r.approved + ' matches approved';
if (r.scheduled_re_research > 0) msg += ' · ' + r.scheduled_re_research + ' scheduled for re-research';
if (r.fully_verified_earned > 0) msg += ' · ' + r.fully_verified_earned + ' × ★ Fully Verified';
showToast('Approve All', msg, false);
reviewState.loaded = false;
await loadReview();
setTimeout(function() {
btn.disabled = false;
btn.textContent = '⚡ Approve All Pending';
}, 4000);
} catch(e) {
showToast('Approve All fehlgeschlagen', e.message || 'Fehler', true);
btn.disabled = false;
btn.textContent = '⚡ Approve All Pending';
}
}
async function runEquivalenceMatcher() {
var btn = document.querySelector('[onclick="runEquivalenceMatcher()"]');
if (btn) { btn.disabled = true; btn.textContent = 'Queued ✓'; }
try {
await api('/api/review/run-matcher', { method: 'POST' });
setTimeout(function() {
if (btn) { btn.disabled = false; btn.textContent = '▶ Run Matcher Now'; }
reviewState.loaded = false;
loadReview();
}, 3000);
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '▶ Run Matcher Now'; }
}
}
// ─── STOCK TAB ────────────────────────────────────────────────────────────────
var stockLoaded = false;
async function loadStock() {
if (stockLoaded) return; // already loaded — Refresh button resets stockLoaded=false first
try {
var data = await api('/api/stock/summary');
if (!data.success) return;
var d = data.data;
var t = d.totals;
// ── Helpers ─────────────────────────────────────────────────────────────
function setEl(id, v) { var e = el(id); if (e) e.textContent = v; }
/** Returns a confidence badge HTML string based on avg_confidence value */
function confBadge(avgConf) {
var c = parseFloat(avgConf) || 0;
if (c >= 2.5) return '<span title="L3: per-warehouse breakdown" style="background:#166534;color:#86efac;font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">🟢 L3</span>';
if (c >= 1.5) return '<span title="L2: aggregated global count" style="background:#713f12;color:#fde68a;font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">🟡 L2</span>';
return '<span title="L1: boolean in-stock only" style="background:var(--surface2);color:var(--text-dim);font-size:0.65rem;font-weight:700;padding:2px 6px;border-radius:10px;white-space:nowrap">⚪ L1</span>';
}
/** Format price with currency symbol */
function fmtPrice(net, currency) {
if (net == null) return '—';
var sym = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : (currency || '') + ' ';
return sym + Number(net).toFixed(2);
}
// ── Stat cards ───────────────────────────────────────────────────────────
setEl('stock-stat-skus', Number(t.unique_transceivers || 0).toLocaleString());
setEl('stock-stat-instock', Number(t.in_stock_count || 0).toLocaleString());
setEl('stock-stat-de', Number(t.total_de_qty || 0).toLocaleString());
setEl('stock-stat-global', Number(t.total_global_qty || 0).toLocaleString());
setEl('stock-stat-backorder', Number(t.total_backorder_qty || 0).toLocaleString());
setEl('stock-stat-multiv', Number(t.multi_vendor_skus || 0).toLocaleString());
// ── Top sellers table ────────────────────────────────────────────────────
var tbody = el('stock-top-sellers-body');
if (tbody) {
if (d.top_sellers && d.top_sellers.length > 0) {
tbody.innerHTML = d.top_sellers.map(function(r) {
var pn = r.product_url
? '<a href="' + esc(r.product_url) + '" target="_blank" style="color:var(--indigo);text-decoration:none;font-family:monospace;font-size:0.72rem">' + esc(r.part_number) + '</a>'
: '<span style="font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</span>';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px">' + pn + '</td>'
+ '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#f59e0b;font-weight:600">' + Number(r.units_sold || 0).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.6;font-weight:400">demo</span></td>'
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.5;font-weight:400">demo</span>' : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.5;font-weight:400">demo</span>' : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_net, r.price_currency) + '</td>'
+ '</tr>';
}).join('');
} else {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet — waiting for first scrape run</td></tr>';
}
}
// ── Vendor breakdown (with confidence badge) ─────────────────────────────
var vbody = el('stock-vendor-body');
if (vbody) {
if (d.vendor_breakdown && d.vendor_breakdown.length > 0) {
vbody.innerHTML = d.vendor_breakdown.map(function(r) {
var lastScraped = r.last_scraped ? new Date(r.last_scraped).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '—';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px"><a href="' + esc(r.vendor_website || '#') + '" target="_blank" style="color:var(--indigo);text-decoration:none">' + esc(r.vendor_name) + '</a></td>'
+ '<td style="padding:5px 8px;text-align:right">' + Number(r.product_count).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#22c55e">' + Number(r.in_stock_count).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + Number(r.total_de_qty || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + Number(r.total_global_qty || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#f59e0b">' + Number(r.total_backorder || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:center">' + confBadge(r.avg_confidence) + '</td>'
+ '<td style="padding:5px 8px;color:var(--text-dim);font-size:0.7rem">' + lastScraped + '</td>'
+ '</tr>';
}).join('');
} else {
vbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>';
}
}
// ── Recently restocked ───────────────────────────────────────────────────
var recentEl = el('stock-recent');
if (recentEl) {
if (d.recently_updated && d.recently_updated.length > 0) {
recentEl.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;padding:0.25rem 0">'
+ d.recently_updated.map(function(r) {
var timeStr = r.observed_at ? new Date(r.observed_at).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'}) : '';
return '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-size:0.73rem">'
+ '<span style="font-family:monospace;color:var(--text-bright)">' + esc(r.part_number) + '</span>'
+ ' <span style="color:var(--text-dim)">' + esc(r.form_factor || '') + '</span>'
+ ' <span style="color:#6366f1">DE:' + (r.warehouse_de_qty || 0) + '</span>'
+ ' <span style="color:#06b6d4">GL:' + (r.warehouse_global_qty || 0) + '</span>'
+ (timeStr ? ' <span style="color:var(--text-dim);font-size:0.65rem">@' + timeStr + '</span>' : '')
+ '</span>';
}).join('')
+ '</div>';
} else {
recentEl.textContent = 'No restock events in the last 24 hours';
}
}
// ── Multi-vendor price comparison ────────────────────────────────────────
var pcbody = el('stock-price-compare-body');
if (pcbody) {
if (d.price_comparison && d.price_comparison.length > 0) {
pcbody.innerHTML = d.price_comparison.slice(0, 20).map(function(r) {
var spread = r.price_max && r.price_min
? ' <span style="color:var(--text-dim);font-size:0.68rem">(Δ' + (Number(r.price_max) - Number(r.price_min)).toFixed(2) + ')</span>'
: '';
var vendorList = (r.vendor_names || []).map(function(vn, i) {
var p = r.prices && r.prices[i] != null ? fmtPrice(r.prices[i], r.currencies && r.currencies[i]) : '';
return '<span style="background:var(--surface2);border-radius:4px;padding:1px 5px;font-size:0.68rem">' + esc(vn) + (p ? ' <b>' + p + '</b>' : '') + '</span>';
}).join(' ');
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</td>'
+ '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;font-weight:600">' + (r.vendor_count || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#22c55e">' + fmtPrice(r.price_min, r.currencies && r.currencies[0]) + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#ef4444">' + fmtPrice(r.price_max, r.currencies && r.currencies[0]) + spread + '</td>'
+ '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_avg, r.currencies && r.currencies[0]) + '</td>'
+ '<td style="padding:5px 8px">' + vendorList + '</td>'
+ '</tr>';
}).join('');
} else {
pcbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No multi-vendor data yet</td></tr>';
}
}
stockLoaded = true;
// ── Flexoptix Internal Demand (real data) ────────────────────────────────
try {
var [demandBySpeed, demandVelocity] = await Promise.all([
api('/api/internal/demand/by-speed').catch(function() { return null; }),
api('/api/internal/demand/velocity').catch(function() { return null; })
]);
if (demandBySpeed && demandBySpeed.success && demandBySpeed.data) {
var rows = demandBySpeed.data;
// Stat summary
var totalDemand12 = rows.reduce(function(s, r) { return s + Number(r.total_demand_12m || 0); }, 0);
var totalDemand3 = rows.reduce(function(s, r) { return s + Number(r.total_demand_3m || 0); }, 0);
var totalSkus = rows.reduce(function(s, r) { return s + Number(r.sku_count || 0); }, 0);
var overallMomentum = totalDemand12 > 0 ? totalDemand3 / totalDemand12 : 1;
setEl('foxd-stat-skus', totalSkus.toLocaleString());
setEl('foxd-stat-demand12', totalDemand12.toLocaleString() + ' Stk');
setEl('foxd-stat-demand3', totalDemand3.toLocaleString() + ' Stk');
var momEl = document.getElementById('foxd-stat-momentum');
if (momEl) {
var momPct = Math.round((overallMomentum - 1) * 100);
momEl.textContent = (overallMomentum >= 1 ? '▲ ' : '▼ ') + Math.abs(momPct) + '%';
momEl.style.color = overallMomentum >= 1.05 ? '#22c55e' : overallMomentum >= 0.95 ? '#f59e0b' : '#ef4444';
}
// By-speed table
var fbody = document.getElementById('foxd-by-speed-body');
if (fbody) {
fbody.innerHTML = rows.slice(0, 20).map(function(r) {
var momentum = Number(r.momentum_ratio || 1);
var momPct = Math.round((momentum - 1) * 100);
var momColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
var trendArrow = momentum >= 1.1 ? '▲▲' : momentum >= 1.02 ? '▲' : momentum >= 0.98 ? '→' : momentum >= 0.9 ? '▼' : '▼▼';
var trendColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
var tech = (r.speed_gbps || '?') + 'G ' + (r.form_factor || '');
var fastBadge = Number(r.fast_movers || 0) > 0
? '<span style="background:#6366f122;color:#818cf8;border-radius:10px;padding:1px 7px;font-size:0.65rem;font-weight:700">' + r.fast_movers + '</span>'
: '<span style="color:var(--text-dim)">—</span>';
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px;font-weight:600;color:var(--text-bright)">' + esc(tech) + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:var(--text-dim)">' + Number(r.sku_count || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1;font-weight:600">' + Number(r.total_demand_12m || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + Number(r.total_demand_3m || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:' + momColor + ';font-weight:600">'
+ (momPct >= 0 ? '+' : '') + momPct + '%'
+ '</td>'
+ '<td style="padding:5px 8px;text-align:center;font-size:0.85rem;color:' + trendColor + ';font-weight:700">' + trendArrow + '</td>'
+ '<td style="padding:5px 8px;text-align:center">' + fastBadge + '</td>'
+ '</tr>';
}).join('');
}
}
// Velocity class bar
if (demandVelocity && demandVelocity.success && demandVelocity.data) {
var vbar = document.getElementById('foxd-velocity-bar');
if (vbar) {
var classColors = { fast_mover: '#22c55e', regular: '#6366f1', slow_mover: '#f59e0b', dead_stock: '#6b7280' };
var classLabels = { fast_mover: '🚀 Fast (≥100)', regular: '📦 Regular (1099)', slow_mover: '🐢 Slow (19)', dead_stock: '💤 Dead (0)' };
vbar.innerHTML = demandVelocity.data.classes.map(function(c) {
var col = classColors[c.velocity_class] || '#6b7280';
var lbl = classLabels[c.velocity_class] || c.velocity_class;
return '<span style="display:inline-flex;align-items:center;gap:4px;background:' + col + '18;border:1px solid ' + col + '44;border-radius:20px;padding:3px 10px;font-size:0.7rem">'
+ '<span style="color:' + col + ';font-weight:700">' + lbl + '</span>'
+ '<span style="color:var(--text-dim)">' + Number(c.sku_count).toLocaleString() + ' SKUs</span>'
+ '<span style="color:' + col + ';font-weight:600">(' + c.share_pct + '%)</span>'
+ '</span>';
}).join('');
}
}
} catch(demandErr) {
// Internal demand endpoint not available (e.g. external access)
var foxdTable = document.getElementById('foxd-by-speed-body');
if (foxdTable) foxdTable.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:1rem;color:var(--text-dim);font-size:0.72rem">⚠ Demand-Daten nur intern verfügbar</td></tr>';
}
} catch(e) {
console.error('loadStock error', e);
}
}
async function lookupStock() {
var input = el('stock-lookup-input');
var resultEl = el('stock-lookup-result');
if (!input || !resultEl) return;
var q = (input.value || '').trim();
if (!q) return;
resultEl.textContent = 'Looking up…';
try {
var data = await api('/api/stock/' + encodeURIComponent(q) + '?days=7&limit=5');
if (!data.success) { resultEl.textContent = 'Not found: ' + q; return; }
var obs = data.data.observations;
var tx = data.data.transceiver;
if (!obs || obs.length === 0) {
resultEl.innerHTML = '<b>' + esc(tx.part_number) + '</b> found but no stock observations in last 7 days.';
return;
}
var latest = obs[0];
resultEl.innerHTML = '<b>' + esc(tx.part_number) + '</b> — '
+ esc(tx.form_factor || '') + ' ' + esc(tx.speed || '') + '<br>'
+ '<span style="color:#6366f1">DE-Lager: ' + (latest.warehouse_de_qty != null ? latest.warehouse_de_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · '
+ '<span style="color:#06b6d4">Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · '
+ '<span style="color:#f59e0b">Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · '
+ (latest.price_net != null ? '€' + Number(latest.price_net).toFixed(2) + ' (net)' : '')
+ (latest.units_sold != null ? ' · <b>' + latest.units_sold + '×</b> verkauft <span style="font-size:0.65rem;color:#f59e0b">[demo]</span>' : '')
+ '<br><span style="color:var(--text-dim);font-size:0.7rem">via ' + esc(latest.vendor_name) + ' · ' + new Date(latest.time).toLocaleString('de-DE') + '</span>'
+ (obs.length > 1 ? ' <span style="color:var(--text-dim)">(' + obs.length + ' observations this week)</span>' : '');
} catch(e) {
resultEl.textContent = 'Error: ' + e.message;
}
}
// ── Price Comparison ──────────────────────────────────────────────────────────
var pricesLoaded = false;
async function loadPriceComparison() {
if (pricesLoaded) return;
try {
// Load summary + top SKUs in parallel
var [sumData, listData] = await Promise.all([
api('/api/price-comparison/summary'),
api('/api/price-comparison')
]);
// ── Stat cards ──────────────────────────────────────────────────────────
if (sumData.success && sumData.data) {
var s = sumData.data;
setEl('pc-stat-skus', Number(s.total_skus_tracked || 0).toLocaleString());
setEl('pc-stat-vendors', Number(s.active_vendor_count || 0).toLocaleString());
setEl('pc-stat-obs', Number(s.total_observations || 0).toLocaleString());
setEl('pc-stat-avg', s.overall_avg_price != null
? 'USD ' + Number(s.overall_avg_price).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2})
: '—');
// ── Form factor table ─────────────────────────────────────────────────
var ffBody = el('pc-ff-body');
if (ffBody) {
var ffs = s.by_form_factor || [];
if (ffs.length === 0) {
ffBody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;color:var(--text-dim)">No data yet</td></tr>';
} else {
ffBody.innerHTML = ffs.map(function(r) {
var cur = r.currency || 'USD';
return '<tr style="border-top:1px solid var(--border)">'
+ '<td style="padding:5px 8px;font-weight:600;color:var(--text-bright)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right">' + Number(r.sku_count).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right">' + Number(r.vendor_count).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#22c55e">' + (r.min_price != null ? cur + '\u00a0' + Number(r.min_price).toFixed(2) : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:var(--text-bright)">' + (r.avg_price != null ? cur + '\u00a0' + Number(r.avg_price).toFixed(2) : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#f87171">' + (r.max_price != null ? cur + '\u00a0' + Number(r.max_price).toFixed(2) : '—') + '</td>'
+ '</tr>';
}).join('');
}
}
}
// ── Top SKUs table ────────────────────────────────────────────────────────
var topBody = el('pc-top-body');
if (topBody && listData.success && Array.isArray(listData.data)) {
var rows = listData.data;
if (rows.length === 0) {
topBody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">No price data yet — waiting for first scrape run</td></tr>';
} else {
topBody.innerHTML = rows.map(function(r) {
var spread = r.spread_pct != null ? Number(r.spread_pct).toFixed(1) + '%' : '—';
var spreadColor = r.spread_pct != null && r.spread_pct > 30 ? '#f87171' : r.spread_pct > 10 ? '#f59e0b' : '#22c55e';
var cur = r.currency || 'USD';
return '<tr style="border-top:1px solid var(--border);cursor:pointer" onclick="el(\'pc-lookup-input\').value=\'' + esc(r.standard_name) + '\';lookupPriceComparison()">'
+ '<td style="padding:5px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.standard_name || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:center;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:center;color:var(--text-dim)">' + esc(r.speed || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right"><span style="background:var(--indigo);color:#fff;border-radius:10px;padding:1px 7px;font-size:0.68rem">' + r.vendor_count + '</span></td>'
+ '<td style="padding:5px 8px;text-align:right;color:#22c55e">' + (r.min_price != null ? cur + '\u00a0' + Number(r.min_price).toFixed(2) : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:var(--text-bright)">' + (r.avg_price != null ? cur + '\u00a0' + Number(r.avg_price).toFixed(2) : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:' + spreadColor + ';font-weight:600">' + spread + '</td>'
+ '</tr>';
}).join('');
}
}
pricesLoaded = true;
} catch(e) {
console.error('loadPriceComparison error', e);
}
}
async function lookupPriceComparison() {
var input = el('pc-lookup-input');
var resultEl = el('pc-lookup-result');
if (!input || !resultEl) return;
var q = (input.value || '').trim();
if (!q) return;
resultEl.innerHTML = '<span style="color:var(--text-dim)">Looking up…</span>';
try {
var data = await api('/api/price-comparison/' + encodeURIComponent(q));
if (!data.success || !data.transceiver) {
resultEl.textContent = 'Not found: ' + q;
return;
}
var tx = data.transceiver;
var stats = data.stats || {};
var prices = data.prices || [];
var cur = (prices[0] && prices[0].currency) ? prices[0].currency : 'USD';
var statsHtml = '<div style="margin-bottom:0.75rem">'
+ '<b style="color:var(--text-bright)">' + esc(tx.standard_name) + '</b>'
+ ' &nbsp;·&nbsp; ' + esc(tx.form_factor || '') + ' ' + esc(tx.speed || '')
+ (tx.fiber_type ? ' &nbsp;·&nbsp; ' + esc(tx.fiber_type) : '')
+ (tx.reach_label ? ' &nbsp;·&nbsp; ' + esc(tx.reach_label) : '')
+ '</div>'
+ '<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.75rem;font-size:0.8rem">'
+ '<span>📊 <b>' + stats.vendor_count + '</b> vendors</span>'
+ (stats.min != null ? '<span style="color:#22c55e">Min: <b>' + cur + '\u00a0' + Number(stats.min).toFixed(2) + '</b></span>' : '')
+ (stats.avg != null ? '<span>Avg: <b>' + cur + '\u00a0' + Number(stats.avg).toFixed(2) + '</b></span>' : '')
+ (stats.max != null ? '<span style="color:#f87171">Max: <b>' + cur + '\u00a0' + Number(stats.max).toFixed(2) + '</b></span>' : '')
+ (stats.spread_pct != null ? '<span style="color:#f59e0b">Spread: <b>' + Number(stats.spread_pct).toFixed(1) + '%</b></span>' : '')
+ '</div>';
var tableHtml = '';
if (prices.length > 0) {
tableHtml = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem;margin-top:0.5rem">'
+ '<thead><tr style="background:var(--surface2)">'
+ '<th style="padding:5px 8px;text-align:left;color:var(--text-dim);font-weight:500">Vendor</th>'
+ '<th style="padding:5px 8px;text-align:right;color:var(--text-dim);font-weight:500">Price</th>'
+ '<th style="padding:5px 8px;text-align:center;color:var(--text-dim);font-weight:500">Stock</th>'
+ '<th style="padding:5px 8px;text-align:center;color:var(--text-dim);font-weight:500">Observed</th>'
+ '</tr></thead><tbody>'
+ prices.map(function(p, i) {
var stock = p.stock_level || '—';
var stockColor = /in.stock|available/i.test(stock) ? '#22c55e' : /out|unavail/i.test(stock) ? '#f87171' : 'var(--text-dim)';
var vendorHtml = p.url
? '<a href="' + esc(p.url) + '" target="_blank" style="color:var(--indigo);text-decoration:none">' + esc(p.vendor) + '</a>'
: esc(p.vendor);
var rowBg = i % 2 === 0 ? '' : 'background:var(--surface2)';
return '<tr style="border-top:1px solid var(--border);' + rowBg + '">'
+ '<td style="padding:5px 8px">' + vendorHtml + '</td>'
+ '<td style="padding:5px 8px;text-align:right;font-weight:600;color:var(--text-bright)">' + (p.price != null ? esc(p.currency || cur) + '\u00a0' + Number(p.price).toFixed(2) : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:center;color:' + stockColor + ';font-size:0.7rem">' + esc(stock) + '</td>'
+ '<td style="padding:5px 8px;text-align:center;color:var(--text-dim);font-size:0.7rem">' + (p.observed_at ? new Date(p.observed_at).toLocaleDateString('en-US') : '—') + '</td>'
+ '</tr>';
}).join('')
+ '</tbody></table>';
} else {
tableHtml = '<p style="color:var(--text-dim)">No price observations found.</p>';
}
resultEl.innerHTML = '<div class="card" style="padding:1rem;margin-top:0.5rem">' + statsHtml + tableHtml + '</div>';
} catch(e) {
resultEl.textContent = 'Error: ' + e.message;
}
}
</script>
<script src="/dashboard/hot-topics.js"></script>
</body>
</html>