E Buy-Now Intel 211k precomputed reorder signals surfaced,
filterable by form factor, signal strength bars
A Arbitrage 59k equivalence pairs + price data, FX vs comp
normalized to USD, sorted by savings %
B Switch Compat search 429 switches → compatible transceivers
with prices; 58k compatibility rows
C Supply Squeeze 4-signal detector: price momentum (30d vs 60d),
hype phase, AI cluster demand, stock pressure
D Dead Stock 7,297 dead-stock SKUs matched against ascending
hype phases (revival candidates)
5 new API endpoints: /api/procurement/reorder-top, /arbitrage,
/switch-compat, /supply-squeeze, /dead-stock-revival
9314 lines
559 KiB
HTML
9314 lines
559 KiB
HTML
<!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">—</span> active products</span>
|
||
<span data-goto="vendors"><span class="val" id="stat-vendors">—</span> vendors</span>
|
||
<span data-goto="switches"><span class="val" id="stat-switches">—</span> switches</span>
|
||
<span data-goto="standards"><span class="val" id="stat-standards">—</span> standards</span>
|
||
<span data-goto="news"><span class="val" id="stat-news">—</span> articles</span>
|
||
<span data-goto="stock"><span class="val" id="stat-stock-obs">—</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">🌐 Network</div>
|
||
<div class="tab" data-tab="review" id="tab-review-nav">✎ 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</div>
|
||
<div class="tab" data-tab="prices">💲 Price Comparison</div>
|
||
<div class="tab" data-tab="equivalences">🔀 Equivalences</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">⚙</div>
|
||
<div class="stat-label">Active Products</div>
|
||
<div class="stat-val" id="ov-transceivers">—</div>
|
||
<div class="stat-sub" id="ov-transceivers-total" style="font-size:0.68rem;color:var(--text-dim);margin-top:0.2rem">—</div>
|
||
</div>
|
||
<div class="stat-card" data-goto="transceivers">
|
||
<div class="stat-icon green">★</div>
|
||
<div class="stat-label">Vendors</div>
|
||
<div class="stat-val" id="ov-vendors">—</div>
|
||
</div>
|
||
<div class="stat-card" data-goto="switches">
|
||
<div class="stat-icon orange">⚙</div>
|
||
<div class="stat-label">Switches</div>
|
||
<div class="stat-val" id="ov-switches">—</div>
|
||
</div>
|
||
<div class="stat-card" data-goto="hype">
|
||
<div class="stat-icon purple">⚗</div>
|
||
<div class="stat-label">Standards</div>
|
||
<div class="stat-val" id="ov-standards">—</div>
|
||
</div>
|
||
<div class="stat-card" data-goto="news">
|
||
<div class="stat-icon cyan">✎</div>
|
||
<div class="stat-label">News Articles</div>
|
||
<div class="stat-val" id="ov-news">—</div>
|
||
</div>
|
||
</div>
|
||
<!-- RESEARCH STATUS -->
|
||
<div class="card mb" id="verification-card">
|
||
<div class="card-label">Data Research 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 — Adoption & Composite Score sind mathematische Schätzungen, keine echten Marktdaten — 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>
|
||
<!-- Market Context cards — loaded dynamically from market-signals API -->
|
||
<div id="hype-market-context" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));margin-bottom:1.25rem">
|
||
<div class="loading pulse" style="grid-column:1/-1;padding:0.75rem">Loading market signals…</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="Composite Market Signal Score (0–100). Blends hype score + hyperscaler capex trend + price observation activity + AI cluster demand + eBay secondary market + internal demand velocity. Higher = stronger real-world demand signal.">Market Signal <span style="font-size:0.58rem;color:#16a34a;font-weight:600">● LIVE</span><span class="sort-arrow"></span></th>
|
||
<th class="tip" data-tip="Data-driven buy recommendation based on hype phase + multi-source market signals.">Recommendation<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.">R²<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:#16a34a;font-weight:700">● LIVE</span> = Real market data: hyperscaler capex + price activity + AI cluster demand + eBay velocity + internal demand</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> — Rate of adoption driven by external influence (marketing, industry events). Typically 0.01–0.05 for B2B networking equipment.</li>
|
||
<li><strong>q (imitation coefficient)</strong> — Rate of adoption driven by word-of-mouth and peer influence. Typically 0.3–0.5 for optical networking.</li>
|
||
<li><strong>m (market potential)</strong> — Total addressable market size, estimated from port shipment forecasts (LightCounting, Dell’Oro).</li>
|
||
<li><strong>τ (introduction time)</strong> — 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–16%), <span class="b" style="background:#FFa03018;color:#FFa030;border:1px solid #FFa03033">Peak of Inflated Expectations</span> (16–35%), <span class="b" style="background:#c1121f18;color:#c1121f;border:1px solid #c1121f33">Trough of Disillusionment</span> (35–55%), <span class="b" style="background:#55555518;color:#555;border:1px solid #55555533">Slope of Enlightenment</span> (55–80%), and <span class="b" style="background:#00000018;color:#000;border:1px solid #00000033">Plateau of Productivity</span> (80–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–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>
|
||
|
||
<!-- Hyperscaler CapEx panel -->
|
||
<div class="card mt" style="border-left:3px solid #2563eb">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||
<div>
|
||
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">Hyperscaler CapEx <span style="font-size:0.72rem;font-weight:700;background:#2563eb22;color:#2563eb;border:1px solid #2563eb44;border-radius:3px;padding:1px 6px">SEC Filings</span></div>
|
||
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Quarterly infrastructure spend — primary demand driver for high-speed transceivers</div>
|
||
</div>
|
||
<div id="capex-avg-badge" style="font-size:0.82rem;font-weight:700;padding:4px 12px;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;color:#16a34a"></div>
|
||
</div>
|
||
<div id="capex-table-container">
|
||
<div class="loading pulse">Loading capex data…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- eBay Marketplace panel -->
|
||
<div class="card mt" style="border-left:3px solid #f97316">
|
||
<div style="margin-bottom:0.75rem">
|
||
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">eBay Secondary Market <span style="font-size:0.72rem;font-weight:700;background:#f9731622;color:#f97316;border:1px solid #f9731644;border-radius:3px;padding:1px 6px">Demand Signal</span></div>
|
||
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Secondary market sell-through rates — real end-buyer demand by form factor</div>
|
||
</div>
|
||
<div id="ebay-velocity-container">
|
||
<div class="loading pulse">Loading marketplace 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">×</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>
|
||
|
||
<!-- Sub-tabs: Standards | Formfaktoren -->
|
||
<div style="display:flex;gap:0;margin-bottom:1.25rem;border-bottom:2px solid var(--border)">
|
||
<button id="std-sub-btn-standards" onclick="switchStdSubtab('standards')"
|
||
style="padding:8px 20px;font-size:0.85rem;font-weight:600;border:none;background:none;color:var(--accent);border-bottom:2px solid var(--accent);margin-bottom:-2px;cursor:pointer">
|
||
Standards
|
||
</button>
|
||
<button id="std-sub-btn-formfaktoren" onclick="switchStdSubtab('formfaktoren')"
|
||
style="padding:8px 20px;font-size:0.85rem;font-weight:600;border:none;background:none;color:var(--text-dim);border-bottom:2px solid transparent;margin-bottom:-2px;cursor:pointer">
|
||
Formfaktoren
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Sub-tab: Standards -->
|
||
<div id="std-subtab-standards">
|
||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
|
||
<input type="text" id="std-search" placeholder="Suche: 400G, QSFP-DD, ZR, Kurzstrecke…"
|
||
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="">Alle Speeds</option>
|
||
<option value="1">1G</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 style="min-width:160px">Standard · Beschreibung</th><th>Speed</th><th>Bauform(en)</th>
|
||
<th>Max Reichweite</th><th>Faser</th><th>Wellenlänge</th>
|
||
<th>IEEE Ref</th><th>Org · Jahr</th><th>Status</th><th>Transceiver</th>
|
||
</tr></thead>
|
||
<tbody id="std-table"><tr><td colspan="10" class="loading pulse">Loading…</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sub-tab: Formfaktoren -->
|
||
<div id="std-subtab-formfaktoren" class="hidden">
|
||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">
|
||
<input type="text" id="ff-search" placeholder="Suche: QSFP28, SFP+, 100G, Aktuell…"
|
||
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="filterFormFactors()">
|
||
<select id="ff-family-filter" onchange="filterFormFactors()"
|
||
style="padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.8rem">
|
||
<option value="">Alle Familien</option>
|
||
<option value="SFP family">SFP-Familie</option>
|
||
<option value="QSFP family">QSFP-Familie</option>
|
||
<option value="OSFP family">OSFP-Familie</option>
|
||
<option value="CFP family">CFP-Familie</option>
|
||
<option value="legacy">Legacy / Veraltet</option>
|
||
</select>
|
||
<select id="ff-status-filter" onchange="filterFormFactors()"
|
||
style="padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);color:var(--text);font-size:0.8rem">
|
||
<option value="">Alle Status</option>
|
||
<option value="current">Aktuell</option>
|
||
<option value="emerging">Neu / Emerging</option>
|
||
<option value="legacy">Legacy</option>
|
||
<option value="obsolete">Veraltet</option>
|
||
</select>
|
||
</div>
|
||
<div id="ff-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:0.75rem">
|
||
<div class="card" style="padding:1rem;text-align:center;color:var(--text-dim);font-size:0.85rem">
|
||
<span class="loading pulse">Lade Bauformen…</span>
|
||
</div>
|
||
</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">🖼</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" onclick="switchBlogLlm('claude-code')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
|
||
<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" onclick="switchBlogLlm('anthropic')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
|
||
<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_BlogLLM (dynamically discovered current version via Magatama lane state) -->
|
||
<div id="blog-model-card-fo" data-fo-model="fo-blog-v10" onclick="switchBlogLlm('ollama', document.getElementById('blog-model-card-fo').dataset.foModel)" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
|
||
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
|
||
<div>
|
||
<div id="blog-model-fo-name" style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v10</div>
|
||
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Adapter Bridge / 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">✓ Neu trainierte Mac Studio Version</div>
|
||
<div style="color:#1a7a3a;font-weight:500">✓ Lokal / keine API-Kosten</div>
|
||
<div style="color:#b45309;font-weight:500">⚠ Qualitaet laeuft schon, Feinschliff folgt</div>
|
||
</div>
|
||
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
|
||
<code id="blog-model-fo-env" 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-v10</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" onclick="switchBlogLlm('ollama','qwen2.5:14b')" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface);cursor:pointer;transition:opacity 0.15s" onmouseenter="this.style.opacity='0.85'" onmouseleave="this.style.opacity='1'">
|
||
<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 (Fallback)</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-/Fallback-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 -->
|
||
|
||
<!-- Switch feedback -->
|
||
<div id="blog-llm-switch-msg" style="display:none;margin-top:0.85rem;padding:0.55rem 0.85rem;border-radius:6px;font-size:0.75rem;font-weight:600"></div>
|
||
<div style="margin-top:0.6rem;padding:0.45rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.7rem;color:var(--text-dim)">
|
||
💡 Karte anklicken zum Aktivieren — wechselt sofort ohne Neustart
|
||
</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. 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 -->
|
||
|
||
<!-- URL → BLOG PANEL -->
|
||
<div class="card" style="margin-bottom:1.25rem;border:1px solid rgba(16,185,129,0.35);background:var(--surface2)">
|
||
<div style="font-size:0.85rem;font-weight:700;color:var(--text-bright);margin-bottom:0.1rem">🔗 Blog aus URL generieren</div>
|
||
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.75rem">Link eingeben → Inhalt wird automatisch extrahiert → BlogLLM schreibt einen Artikel daraus</div>
|
||
<div style="display:grid;grid-template-columns:1fr auto;gap:0.6rem;margin-bottom:0.65rem;align-items:end">
|
||
<div>
|
||
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">URL</label>
|
||
<input type="url" id="blog-from-url-input" placeholder="https://example.com/article-about-400g-transceivers"
|
||
style="width:100%;background:var(--surface);border:1px solid rgba(16,185,129,0.4);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem;box-sizing:border-box"
|
||
onkeydown="if(event.key==='Enter')generateBlogFromUrl()">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">Blog-Typ</label>
|
||
<select id="blog-from-url-topic" style="background:var(--surface);border:1px solid rgba(16,185,129,0.4);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem;height:32px">
|
||
<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="display:flex;align-items:center;gap:0.75rem">
|
||
<button onclick="generateBlogFromUrl()" id="blog-from-url-btn"
|
||
style="background:rgba(16,185,129,0.85);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:0.82rem;font-weight:600">
|
||
🔗 Aus URL generieren
|
||
</button>
|
||
<span id="blog-from-url-status" style="font-size:0.75rem;color:var(--text-dim)"></span>
|
||
</div>
|
||
</div><!-- end url→blog panel -->
|
||
|
||
<!-- 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>
|
||
|
||
<!-- LinkedIn Distribution Status -->
|
||
<div class="card mt" style="border-left:3px solid #0a66c2">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;flex-wrap:wrap;gap:0.5rem">
|
||
<span style="font-weight:600;font-size:0.9rem">🔵 LinkedIn Distribution</span>
|
||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||
<span id="linkedin-dry-run-badge" style="font-size:0.72rem;padding:2px 8px;border-radius:10px;background:#f97316;color:#fff">DRY RUN</span>
|
||
<button onclick="loadLinkedinHistory()" style="background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.72rem">↻ Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div id="linkedin-stats" style="display:flex;gap:1.2rem;font-size:0.78rem;color:var(--text-dim);margin-bottom:0.75rem;flex-wrap:wrap"></div>
|
||
<div id="linkedin-history" style="font-size:0.78rem;color:var(--text-dim)">Loading…</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('reorder-top')" id="proc-btn-reorder-top" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">🟢 Buy-Now Intel</button>
|
||
<button onclick="showProcSection('arbitrage')" id="proc-btn-arbitrage" class="proc-btn" style="background:rgba(59,130,246,0.08);border-color:rgba(59,130,246,0.3);color:#3b82f6">💰 Arbitrage</button>
|
||
<button onclick="showProcSection('switch-compat')" id="proc-btn-switch-compat" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#818cf8">🖥 Switch Compat</button>
|
||
<button onclick="showProcSection('supply-squeeze')" id="proc-btn-supply-squeeze" class="proc-btn" style="background:rgba(239,68,68,0.08);border-color:rgba(239,68,68,0.3);color:#ef4444">⚠️ Supply Squeeze</button>
|
||
<button onclick="showProcSection('dead-stock')" id="proc-btn-dead-stock" class="proc-btn" style="background:rgba(245,158,11,0.08);border-color:rgba(245,158,11,0.3);color:#f59e0b">🪦 Dead Stock Revival</button>
|
||
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
|
||
<button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button>
|
||
<button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button>
|
||
<button onclick="showProcSection('marketplace')" id="proc-btn-marketplace" class="proc-btn" style="background:rgba(249,115,22,0.08);border-color:rgba(249,115,22,0.3);color:#f97316">🛒 eBay Market</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>
|
||
|
||
<!-- E: Buy-Now Intel -->
|
||
<div id="proc-section-reorder-top" style="display:none">
|
||
<div id="proc-reorder-top-summary" style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||
<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem;flex-wrap:wrap">
|
||
<span style="font-size:0.75rem;color:var(--text-dim)">Form Factor:</span>
|
||
<select id="reorder-ff-filter" onchange="reloadReorderTop()" style="font-size:0.75rem;padding:3px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text)">
|
||
<option value="">All</option>
|
||
<option>SFP+</option><option>QSFP28</option><option>QSFP-DD</option>
|
||
<option>OSFP</option><option>SFP28</option><option>QSFP+</option>
|
||
</select>
|
||
<button onclick="reloadReorderTop()" 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">↻</button>
|
||
</div>
|
||
<div id="proc-reorder-top-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||
</div>
|
||
|
||
<!-- A: Arbitrage -->
|
||
<div id="proc-section-arbitrage" style="display:none">
|
||
<div style="padding:0.6rem 0.9rem;background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.25);border-radius:8px;font-size:0.75rem;color:#93c5fd;margin-bottom:1.25rem">
|
||
💡 Zeigt Flexoptix-Preis vs. günstigster verfügbarer Preis für das gleiche Transceiver-Äquivalent. Preise normalisiert auf USD (EUR×1.08, GBP×1.27). Nur Paare mit Preisdaten auf beiden Seiten.
|
||
</div>
|
||
<div id="proc-arbitrage-stats" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||
<div id="proc-arbitrage-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||
</div>
|
||
|
||
<!-- B: Switch Compat -->
|
||
<div id="proc-section-switch-compat" style="display:none">
|
||
<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1.25rem;flex-wrap:wrap">
|
||
<input id="switch-search-input" type="text" placeholder="Switch suchen: z.B. C9300, QFX5120, ACX7100…"
|
||
onkeydown="if(event.key==='Enter')loadSwitchCompat()"
|
||
style="flex:1;min-width:220px;padding:0.5rem 0.75rem;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:0.82rem">
|
||
<button onclick="loadSwitchCompat()" style="padding:0.5rem 1rem;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-size:0.82rem;cursor:pointer;font-weight:600">Suchen</button>
|
||
</div>
|
||
<div id="proc-switch-stats" style="margin-bottom:1.25rem"></div>
|
||
<div id="proc-switch-results"><div style="color:var(--text-dim)">Switch-Modell eingeben um kompatible Transceiver zu sehen.</div></div>
|
||
</div>
|
||
|
||
<!-- C: Supply Squeeze -->
|
||
<div id="proc-section-supply-squeeze" style="display:none">
|
||
<div id="proc-squeeze-summary" style="margin-bottom:1.25rem"></div>
|
||
<div id="proc-squeeze-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||
</div>
|
||
|
||
<!-- D: Dead Stock Revival -->
|
||
<div id="proc-section-dead-stock" style="display:none">
|
||
<div id="proc-deadstock-summary" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1.25rem"></div>
|
||
<div id="proc-deadstock-list"><div style="color:var(--text-dim)">Loading…</div></div>
|
||
</div>
|
||
|
||
<!-- Reorder Signals section -->
|
||
<div id="proc-section-signals">
|
||
<div style="padding:0.5rem 0.75rem;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;font-size:0.72rem;color:#16a34a;margin-bottom:0.75rem">ℹ Reorder Signals basieren auf <strong>ABC-Klassifizierung + Preis-Observations-Frequenz</strong>. Echte Verkaufsmengendaten → <button onclick="showProcSection('demand')" style="background:none;border:none;color:#16a34a;text-decoration:underline;cursor:pointer;font-size:0.72rem;padding:0">📦 Internal Demand</button> Tab.</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:#16a34a11;border:1px solid #16a34a33;border-radius:6px;font-size:0.72rem;color:#16a34a;margin-bottom:0.75rem">ℹ ABC-Klassifizierung kombiniert Preis-Observations-Frequenz, Compatibility-Einträge und Vendor-Anzahl. Echte SKU-Nachfragedaten → <button onclick="showProcSection('demand')" style="background:none;border:none;color:#16a34a;text-decoration:underline;cursor:pointer;font-size:0.72rem;padding:0">📦 Internal Demand</button> Tab.</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 (0–100). 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>
|
||
|
||
<!-- Internal Demand section -->
|
||
<div id="proc-section-demand" style="display:none">
|
||
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="demand-summary-cards">
|
||
<div class="loading pulse" style="width:100%">Loading demand data...</div>
|
||
</div>
|
||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
|
||
<button onclick="filterVelocity('')" id="vel-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="filterVelocity('fast_mover')" id="vel-fast" style="background:rgba(22,163,74,0.1);border:1px solid rgba(22,163,74,0.3);color:#16a34a;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🚀 Fast Movers</button>
|
||
<button onclick="filterVelocity('regular')" id="vel-regular" style="background:rgba(37,99,235,0.1);border:1px solid rgba(37,99,235,0.3);color:#2563eb;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">📦 Regular</button>
|
||
<button onclick="filterVelocity('slow_mover')" id="vel-slow" style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🐢 Slow Movers</button>
|
||
<button onclick="filterVelocity('dead_stock')" id="vel-dead" style="background:rgba(136,136,136,0.1);border:1px solid #ddd;color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">💀 Dead Stock</button>
|
||
<div style="flex:1"></div>
|
||
<span style="font-size:0.72rem;color:var(--text-dim)">Real Flexoptix internal demand data</span>
|
||
</div>
|
||
<div class="card" style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:0.8rem" id="demand-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 style="text-align:left;padding:8px 6px">Velocity</th>
|
||
<th style="text-align:left;padding:8px 6px">SKU / Product</th>
|
||
<th style="text-align:left;padding:8px 6px">Form Factor</th>
|
||
<th class="tip" data-tip="Units demanded in last 12 months" style="text-align:right;padding:8px 6px">Demand 12M</th>
|
||
<th class="tip" data-tip="Units demanded in last 3 months" style="text-align:right;padding:8px 6px">Demand 3M</th>
|
||
<th class="tip" data-tip="Demand trend vs prior period (positive = growing)" style="text-align:right;padding:8px 6px">Trend</th>
|
||
</tr></thead>
|
||
<tbody id="demand-tbody"><tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">Loading...</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- eBay Marketplace section -->
|
||
<div id="proc-section-marketplace" style="display:none">
|
||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;align-items:center;flex-wrap:wrap">
|
||
<span style="font-size:0.8rem;font-weight:700;color:var(--text)">Secondary Market Demand (eBay)</span>
|
||
<div style="flex:1"></div>
|
||
<span style="font-size:0.72rem;color:var(--text-dim)">Higher sell-through = real end-buyer demand, price floor signal</span>
|
||
</div>
|
||
<div id="proc-marketplace-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
|
||
<div class="loading pulse">Loading marketplace data...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI Clusters section -->
|
||
<div id="proc-section-ai-clusters" style="display:none">
|
||
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats">
|
||
<div class="loading pulse" style="width:100%">Loading AI cluster data...</div>
|
||
</div>
|
||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
|
||
<button onclick="filterAiClusters(0)" id="ai-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All Announcements</button>
|
||
<button onclick="filterAiClusters(1)" id="ai-demand" 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">📡 With Transceiver Demand</button>
|
||
<div style="flex:1"></div>
|
||
<select id="ai-days-select" onchange="reloadAiClusters()" style="font-size:0.75rem;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text)">
|
||
<option value="30">Last 30 days</option>
|
||
<option value="90" selected>Last 90 days</option>
|
||
<option value="180">Last 180 days</option>
|
||
<option value="365">Last 12 months</option>
|
||
</select>
|
||
</div>
|
||
<div id="ai-cluster-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(420px,1fr))">
|
||
<div class="loading pulse">Loading...</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;margin-bottom:2rem">
|
||
<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>
|
||
|
||
<!-- Data Quality Panel -->
|
||
<div id="cr-data-quality-panel">
|
||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem">
|
||
<h3 style="font-size:0.9rem;font-weight:700;color:var(--text-bright)">🔬 Data Quality & Verification Coverage</h3>
|
||
<button onclick="loadDataQuality()" 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-data-quality"><div style="color:var(--text-dim)">Loading…</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 id="sl-local-btn-tip" onclick="startSelflearningTrain('tip_llm','local',true)" style="background:var(--surface2);color:var(--text-dim);border:1px solid var(--border);padding:6px 10px;border-radius:6px;cursor:not-allowed;font-size:0.75rem;font-weight:700;opacity:0.5" disabled title="Lokales Training nicht konfiguriert — TIP_LOCAL_TRAIN_COMMAND fehlt">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 id="sl-local-btn-blog" onclick="startSelflearningTrain('blog_llm','local',true)" style="background:var(--surface2);color:var(--text-dim);border:1px solid var(--border);padding:6px 10px;border-radius:6px;cursor:not-allowed;font-size:0.75rem;font-weight:700;opacity:0.5" disabled title="Lokales Training nicht konfiguriert — TIP_LOCAL_TRAIN_COMMAND fehlt">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">🌐 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">📡</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">🌎</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">⚡</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">📈</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 & 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">✓</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">✓</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">✓</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">✓</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)">✎ 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 & Lagermengen: real (scraped from fs.com · QSFPTEK · NADDOD & more) · Abverkauf: <span style="color:#22c55e;font-weight:600">✅ echte Flexoptix-Verkaufszahlen (intern)</span> · <span style="color:#06b6d4">ℹ Scraper-Lagermengen: Wettbewerber-Marktdaten</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:#06b6d4;font-weight:500">FS.com</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:#06b6d4;font-weight:500">FS.com</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:#06b6d4;font-weight:500">FS.com</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>
|
||
|
||
<!-- 📦 Demand vs Live Stock Analysis (real combined) -->
|
||
<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;flex-wrap:wrap">
|
||
📦 Demand × Live Stock Analyse — Nachbestellungsbedarf
|
||
<span style="font-size:0.65rem;font-weight:700;background:#16a34a22;color:#22c55e;border:1px solid #22c55e66;border-radius:3px;padding:1px 6px">REAL DATA</span>
|
||
<span style="margin-left:auto;font-size:0.68rem;color:var(--text-dim);font-weight:400">Interner Bedarf × Webshop-Lager (kontinuierlich)</span>
|
||
</div>
|
||
<!-- Summary chips -->
|
||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;padding:0.6rem 1rem;border-bottom:1px solid var(--border)" id="stock-analysis-chips">
|
||
<span style="color:var(--text-dim);font-size:0.75rem">Wird analysiert…</span>
|
||
</div>
|
||
<!-- Table -->
|
||
<div style="overflow-x:auto;max-height:340px;overflow-y:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:0.74rem" id="stock-analysis-table">
|
||
<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">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">Bedarf/Mo (12M)</th>
|
||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Bedarf/Mo (3M)</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">Deckung (Tage)</th>
|
||
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Status</th>
|
||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Trend</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="stock-analysis-body">
|
||
<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Analyse…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</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:#06b6d4;opacity:0.8">FS.com</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:#06b6d4;opacity:0.8">FS.com</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:#06b6d4;opacity:0.8">FS.com</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:#06b6d422;color:#06b6d4;border:1px solid #06b6d466;border-radius:3px;padding:1px 6px">SCRAPER 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 2–8h · 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 -->
|
||
|
||
<!-- EQUIVALENCES TAB -->
|
||
<div id="tab-equivalences" class="hidden fade-in">
|
||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">
|
||
<h2 style="margin:0;font-size:1.1rem">🔀 Cross-Brand Equivalences</h2>
|
||
<div id="equiv-stats" style="font-size:0.78rem;color:var(--text-dim)">Loading…</div>
|
||
</div>
|
||
|
||
<!-- Search bar -->
|
||
<div class="card mb" style="padding:0.85rem">
|
||
<div style="display:flex;gap:0.6rem;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="equiv-q" placeholder="Part number, e.g. GLC-LH-SMD, SFP-10G-LR, QSFP-100G-PSM4…"
|
||
style="flex:1;min-width:200px;background:var(--surface);border:1px solid var(--border);color:var(--text);
|
||
padding:7px 12px;border-radius:6px;font-size:0.85rem"
|
||
oninput="debounceEquiv()" onkeydown="if(event.key==='Enter')searchEquivalences()">
|
||
<input type="text" id="equiv-vendor" placeholder="Vendor filter (optional)"
|
||
style="width:160px;background:var(--surface);border:1px solid var(--border);color:var(--text);
|
||
padding:7px 12px;border-radius:6px;font-size:0.85rem"
|
||
oninput="debounceEquiv()">
|
||
<button onclick="searchEquivalences()" style="background:var(--accent);color:#fff;border:none;
|
||
padding:7px 16px;border-radius:6px;cursor:pointer;font-size:0.82rem;white-space:nowrap">Search</button>
|
||
</div>
|
||
<div id="equiv-top-vendors" style="margin-top:0.7rem;display:flex;flex-wrap:wrap;gap:0.4rem"></div>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<div class="card" style="padding:0">
|
||
<div id="equiv-results" style="padding:1rem;color:var(--text-dim);font-size:0.85rem">
|
||
Enter a part number to find Flexoptix equivalents or competitor matches.
|
||
</div>
|
||
</div>
|
||
</div><!-- end tab-equivalences -->
|
||
|
||
</div>
|
||
|
||
</div><!-- .app -->
|
||
|
||
<!-- DETAIL PANEL -->
|
||
<div id="detail-panel" class="panel">
|
||
<button class="panel-close" id="panel-close">×</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');
|
||
var activeTransceivers = h.verification?.total || h.database.stats.transceiver_count;
|
||
animateValue(el('stat-transceivers'), activeTransceivers, 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'), activeTransceivers, 1000);
|
||
var totalRows = Number(h.database.stats.transceiver_count || 0);
|
||
var inactiveRows = Math.max(0, totalRows - Number(activeTransceivers || 0));
|
||
el('ov-transceivers-total').textContent = totalRows.toLocaleString() + ' total rows · ' + inactiveRows.toLocaleString() + ' archived/quarantined';
|
||
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);
|
||
|
||
// Research status section. Strict verified counts remain source-backed, but
|
||
// the overview must show whether research work is actually still open.
|
||
if (h.verification) {
|
||
var v = h.verification;
|
||
var total = v.total || 1;
|
||
function statusCount(bucket, keys) {
|
||
if (!bucket) return 0;
|
||
return keys.reduce(function(sum, k) { return sum + Number(bucket[k] || 0); }, 0);
|
||
}
|
||
var priceResolved = statusCount(v.price_status, ['public_price', 'no_public_price', 'ambiguous']);
|
||
var imageResolved = statusCount(v.image_status, ['public_image', 'no_public_image', 'ambiguous']);
|
||
var detailsResolved = statusCount(v.details_status, ['public_details', 'no_public_details', 'ambiguous']);
|
||
var competitorResolved = statusCount(v.competitor_status, ['matched', 'no_valid_match', 'ambiguous']);
|
||
var items = [
|
||
{
|
||
label: 'Price Resolved',
|
||
count: priceResolved,
|
||
pct: Math.round(priceResolved / total * 100),
|
||
color: '#22c55e',
|
||
note: (v.price_status?.public_price || 0).toLocaleString() + ' public / ' + (v.price_status?.no_public_price || 0).toLocaleString() + ' no public',
|
||
},
|
||
{
|
||
label: 'Image Resolved',
|
||
count: imageResolved,
|
||
pct: Math.round(imageResolved / total * 100),
|
||
color: '#3b82f6',
|
||
note: (v.image_status?.public_image || 0).toLocaleString() + ' public / ' + (v.image_status?.no_public_image || 0).toLocaleString() + ' no public',
|
||
},
|
||
{
|
||
label: 'Details Resolved',
|
||
count: detailsResolved,
|
||
pct: Math.round(detailsResolved / total * 100),
|
||
color: '#a855f7',
|
||
note: (v.details_status?.public_details || 0).toLocaleString() + ' public / ' + (v.details_status?.no_public_details || 0).toLocaleString() + ' no public',
|
||
},
|
||
{
|
||
label: 'Competitor Resolved',
|
||
count: competitorResolved,
|
||
pct: Math.round(competitorResolved / total * 100),
|
||
color: '#f97316',
|
||
note: (v.competitor_status?.matched || 0).toLocaleString() + ' matched / ' + (v.competitor_status?.ambiguous || 0).toLocaleString() + ' ambiguous',
|
||
},
|
||
{
|
||
label: 'All Research Resolved',
|
||
count: v.research_resolved || 0,
|
||
pct: v.research_resolved_pct || Math.round((v.research_resolved || 0) / total * 100),
|
||
color: '#14b8a6',
|
||
note: (v.fully_verified || 0).toLocaleString() + ' strict fully verified',
|
||
},
|
||
];
|
||
buildDOM(el('verification-overview'), items.map(function(item) {
|
||
return '<div '
|
||
+ 'style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem 1rem;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="' + item.label + '">'
|
||
+ '<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="display:flex;justify-content:space-between;gap:0.5rem;font-size:0.7rem;color:var(--text-dim);margin-top:0.3rem">'
|
||
+ '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (item.note || '') + '</span>'
|
||
+ '<span style="font-family:var(--mono);white-space:nowrap">' + (item.pct || 0) + '% of ' + total.toLocaleString() + '</span>'
|
||
+ '</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')); });
|
||
});
|
||
|
||
// Load market signals in parallel and enrich the table
|
||
var marketSignals = {};
|
||
var globalCtx = {};
|
||
try {
|
||
var msData = await api('/api/hype-cycle/market-signals');
|
||
if (msData.success && msData.technologies) {
|
||
msData.technologies.forEach(function(ms) { marketSignals[ms.technology] = ms; });
|
||
globalCtx = msData.globalContext || {};
|
||
renderHypeMarketContext(globalCtx, msData.technologies);
|
||
}
|
||
} catch(e) {
|
||
var ctxEl = el('hype-market-context');
|
||
if (ctxEl) ctxEl.innerHTML = '';
|
||
}
|
||
|
||
buildDOM(el('hype-table'), techs.map(function(t) {
|
||
var color = PC[t.phase] || '#8888a4';
|
||
var ms = marketSignals[t.technology];
|
||
var signalHtml = '—';
|
||
var recHtml = '—';
|
||
if (ms) {
|
||
var sc = ms.marketSignalScore;
|
||
var scColor = sc >= 70 ? '#16a34a' : sc >= 50 ? '#ca8a04' : sc >= 30 ? '#f97316' : '#94a3b8';
|
||
var driversTip = ms.drivers && ms.drivers.length ? ms.drivers.join(' · ') : 'No signal data';
|
||
signalHtml = '<span class="tip" data-tip="' + esc(driversTip) + '" style="font-weight:700;color:' + scColor + ';font-family:monospace">' + sc + '</span>'
|
||
+ '<div style="width:60px;height:4px;background:var(--surface2);border-radius:2px;margin-top:3px;display:inline-block;vertical-align:middle;margin-left:6px"><div style="width:' + sc + '%;height:100%;background:' + scColor + ';border-radius:2px"></div></div>';
|
||
if (ms.recommendation) {
|
||
recHtml = '<span class="tip" data-tip="' + esc(ms.recommendation.detail) + '" style="font-size:0.72rem;font-weight:700;white-space:nowrap;color:' + ms.recommendation.color + '">' + esc(ms.recommendation.label) + '</span>';
|
||
}
|
||
}
|
||
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>' + signalHtml + '</td>'
|
||
+ '<td>' + recHtml + '</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();
|
||
|
||
// Render Hyperscaler CapEx + eBay panels
|
||
loadHypeCapexAndEbay();
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
// Static fallback (only used if API fails)
|
||
var seedFallback = [
|
||
{ 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 = seedFallback;
|
||
// Try real price observation counts per form_factor+speed_gbps (live DB data)
|
||
try {
|
||
var priceCountRes = await api('/api/procurement/signals?limit=200');
|
||
var sigItems = priceCountRes.data || [];
|
||
if (sigItems.length > 0) {
|
||
// Build a map from form_factor+speed_gbps → {obs, avg_price}
|
||
var realMap = {};
|
||
sigItems.forEach(function(row) {
|
||
var key = (row.form_factor || '') + '|' + String(row.speed_gbps || '');
|
||
if (!realMap[key] || row.observation_count > realMap[key].obs) {
|
||
realMap[key] = { obs: row.observation_count || 0, avg_price: parseFloat(row.avg_price) || 0 };
|
||
}
|
||
});
|
||
// Merge into items
|
||
items = items.map(function(it) {
|
||
var key = (it.form_factor || '') + '|' + String(it.speed_gbps || '');
|
||
if (realMap[key] && realMap[key].obs > 0) {
|
||
return Object.assign({}, it, { obs: realMap[key].obs, avg_price: realMap[key].avg_price || it.avg_price });
|
||
}
|
||
return it;
|
||
});
|
||
if (meta) meta.setAttribute('data-source', 'live');
|
||
}
|
||
} 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') + ' →</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 ? ' · <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 += ' · 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 & 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() {});
|
||
|
||
// Async: load price history chart (last 30 days)
|
||
(function loadPriceHistoryPanel(txId) {
|
||
var ph = '<div class="panel-section">📈 Price History <span style="font-size:0.7rem;font-weight:400;color:var(--text-dim)">(30 days)</span></div>';
|
||
ph += '<div id="price-history-inner" style="min-height:60px;font-size:0.78rem;color:var(--text-dim)">Loading…</div>';
|
||
el('panel-content').insertAdjacentHTML('beforeend', ph);
|
||
api('/api/price-history/' + txId + '?days=30').then(function(phd) {
|
||
var inner = document.getElementById('price-history-inner');
|
||
if (!inner) return;
|
||
var series = phd.series || [];
|
||
if (!series.length) { inner.textContent = 'No price data in the last 30 days.'; return; }
|
||
|
||
// Group series by vendor
|
||
var byVendor = {};
|
||
series.forEach(function(row) {
|
||
var v = row.source_vendor || 'Unknown';
|
||
if (!byVendor[v]) byVendor[v] = [];
|
||
byVendor[v].push(row);
|
||
});
|
||
|
||
// Build mini sparklines (SVG-based, per vendor)
|
||
var vendorColors = ['#f97316','#3b82f6','#10b981','#a855f7','#f59e0b','#ef4444','#06b6d4','#84cc16'];
|
||
var vendors = Object.keys(byVendor).slice(0, 8);
|
||
|
||
// Collect all values for y-scale
|
||
var allVals = series.map(function(r) { return parseFloat(r.price_avg); }).filter(function(v) { return !isNaN(v) && v > 0; });
|
||
var yMin = Math.min.apply(null, allVals);
|
||
var yMax = Math.max.apply(null, allVals);
|
||
var yRange = yMax - yMin || 1;
|
||
|
||
// Collect all days for x-scale
|
||
var allDays = [];
|
||
series.forEach(function(r) { if (allDays.indexOf(r.day) === -1) allDays.push(r.day); });
|
||
allDays.sort();
|
||
var xCount = allDays.length || 1;
|
||
var W = 260, H = 60;
|
||
|
||
var svgLines = '';
|
||
vendors.forEach(function(vendor, vi) {
|
||
var points = byVendor[vendor];
|
||
var coords = points.map(function(p) {
|
||
var xi = allDays.indexOf(p.day);
|
||
var x = Math.round((xi / (xCount - 1 || 1)) * (W - 8) + 4);
|
||
var y = Math.round(H - 4 - ((parseFloat(p.price_avg) - yMin) / yRange) * (H - 8));
|
||
return x + ',' + y;
|
||
}).join(' ');
|
||
svgLines += '<polyline points="' + coords + '" fill="none" stroke="' + vendorColors[vi % vendorColors.length] + '" stroke-width="1.5" stroke-linejoin="round"/>';
|
||
});
|
||
|
||
var currency = series[0].currency || 'USD';
|
||
var legend = vendors.map(function(v, i) {
|
||
var last = byVendor[v][byVendor[v].length - 1];
|
||
var lastVal = last ? parseFloat(last.price_min).toFixed(2) : '—';
|
||
return '<span style="display:inline-flex;align-items:center;gap:3px;white-space:nowrap;font-size:0.68rem">'
|
||
+ '<span style="width:10px;height:2px;background:' + vendorColors[i % vendorColors.length] + ';display:inline-block;border-radius:1px"></span>'
|
||
+ esc(v) + ' ' + currency + ' ' + lastVal + '</span>';
|
||
}).join('');
|
||
|
||
var html = '<svg width="' + W + '" height="' + H + '" style="display:block;margin-bottom:6px;overflow:visible">'
|
||
+ '<rect width="' + W + '" height="' + H + '" rx="4" fill="var(--surface2)"/>'
|
||
+ svgLines + '</svg>'
|
||
+ '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.3rem">' + legend + '</div>'
|
||
+ '<div style="font-size:0.66rem;color:var(--text-dim)">' + currency + ' range: '
|
||
+ yMin.toFixed(2) + ' – ' + yMax.toFixed(2) + ' · ' + series.length + ' observations</div>';
|
||
inner.innerHTML = html;
|
||
}).catch(function() {
|
||
var inner = document.getElementById('price-history-inner');
|
||
if (inner) inner.textContent = 'Price history not available.';
|
||
});
|
||
})(t.id);
|
||
|
||
// Async: load cross-brand equivalences
|
||
(function loadEquivalencesPanel(txId) {
|
||
var ph = '<div class="panel-section">🔀 Cross-Brand Equivalences</div>';
|
||
ph += '<div id="equiv-panel-inner" style="min-height:40px;font-size:0.78rem;color:var(--text-dim)">Loading…</div>';
|
||
el('panel-content').insertAdjacentHTML('beforeend', ph);
|
||
api('/api/equivalences/transceiver/' + txId).then(function(eqd) {
|
||
var inner = document.getElementById('equiv-panel-inner');
|
||
if (!inner) return;
|
||
var rows = eqd.data || [];
|
||
if (!rows.length) { inner.textContent = 'No equivalences found for this product.'; return; }
|
||
|
||
var isFx = rows[0] && rows[0].flexoptix_id === txId;
|
||
var html = '<div style="display:flex;flex-direction:column;gap:0.4rem">';
|
||
rows.slice(0, 8).forEach(function(r) {
|
||
var conf = Math.round(parseFloat(r.confidence) * 100);
|
||
var confColor = conf >= 90 ? '#10b981' : conf >= 75 ? '#f59e0b' : '#f97316';
|
||
var basis = Array.isArray(r.match_basis) ? r.match_basis.slice(0, 3).join(', ') : '';
|
||
if (isFx) {
|
||
// Show competitor side
|
||
var url = r.competitor_url ? ' onclick="window.open(\'' + esc(r.competitor_url) + '\',\'_blank\')" style="cursor:pointer"' : '';
|
||
html += '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0.5rem;background:var(--surface2);border-radius:5px"' + url + '>'
|
||
+ '<span style="font-weight:600;font-size:0.8rem">' + esc(r.competitor_pn || r.competitor_std || '—') + '</span>'
|
||
+ '<span class="b b-blue" style="font-size:0.68rem">' + esc(r.competitor_vendor) + '</span>'
|
||
+ '<span style="margin-left:auto;font-size:0.68rem;color:' + confColor + ';font-weight:600">' + conf + '%</span>'
|
||
+ '</div>';
|
||
} else {
|
||
// Show Flexoptix alternative
|
||
var url2 = r.flexoptix_url ? ' onclick="window.open(\'' + esc(r.flexoptix_url) + '\',\'_blank\')" style="cursor:pointer"' : '';
|
||
var priceTag = r.flexoptix_price_eur ? ' <span style="color:var(--text-dim);font-size:0.7rem">€' + parseFloat(r.flexoptix_price_eur).toFixed(2) + '</span>' : '';
|
||
html += '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0.5rem;background:rgba(255,102,0,0.06);border:1px solid rgba(255,102,0,0.15);border-radius:5px"' + url2 + '>'
|
||
+ '<span style="font-weight:600;font-size:0.8rem;color:var(--accent)">FX: ' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '</span>'
|
||
+ priceTag
|
||
+ '<span style="margin-left:auto;font-size:0.68rem;color:' + confColor + ';font-weight:600">' + conf + '%</span>'
|
||
+ '</div>';
|
||
}
|
||
});
|
||
if (rows.length > 8) html += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0.4rem">+' + (rows.length - 8) + ' more equivalences</div>';
|
||
html += '</div>';
|
||
inner.innerHTML = html;
|
||
}).catch(function() {
|
||
var inner = document.getElementById('equiv-panel-inner');
|
||
if (inner) inner.textContent = 'Equivalences data not available.';
|
||
});
|
||
})(t.id);
|
||
|
||
} 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>⚙</span>\'">'
|
||
: '<span style="font-size:1.3rem;opacity:0.3;display:block;text-align:center">⚙</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)>⚙ ' + 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)">⚙ ' + 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') + ' →</a>';
|
||
}
|
||
h += '</div>';
|
||
|
||
h += '<div class="panel-title">' + esc(s.model) + '</div>';
|
||
h += '<div class="panel-sub">' + esc(s.vendor_name || '') + (s.series ? ' — ' + 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 & 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 & 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>';
|
||
|
||
// Format speed_gbps → "1.6T", "400G", "100G" etc.
|
||
function fmtSpeed(gbps) {
|
||
if (!gbps) return '?';
|
||
var n = parseFloat(gbps);
|
||
if (n >= 1000) return (n / 1000) + 'T';
|
||
return Math.round(n) + 'G';
|
||
}
|
||
|
||
// Group by speed class
|
||
var foGroups = {};
|
||
foAll.forEach(function(t) {
|
||
var key = fmtSpeed(t.speed_gbps) + ' ' + (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>' : '';
|
||
|
||
// Stock badges
|
||
var stockHtml = '';
|
||
var deQty = parseInt(t.warehouse_de_qty) || 0;
|
||
var glQty = parseInt(t.warehouse_global_qty) || 0;
|
||
var boQty = parseInt(t.backorder_qty) || 0;
|
||
if (deQty > 0 || glQty > 0 || boQty > 0) {
|
||
stockHtml += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.2rem">';
|
||
if (deQty > 0) stockHtml += '<span style="font-size:0.62rem;background:rgba(16,185,129,0.12);color:#10b981;border:1px solid rgba(16,185,129,0.3);border-radius:3px;padding:1px 5px">DE ' + deQty + ' Stk</span>';
|
||
if (glQty > 0) stockHtml += '<span style="font-size:0.62rem;background:rgba(59,130,246,0.12);color:#60a5fa;border:1px solid rgba(59,130,246,0.3);border-radius:3px;padding:1px 5px">Global ' + glQty + ' Stk</span>';
|
||
if (boQty > 0) {
|
||
var boDate = t.backorder_estimated_date ? ' bis ' + new Date(t.backorder_estimated_date).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit'}) : '';
|
||
stockHtml += '<span style="font-size:0.62rem;background:rgba(245,158,11,0.12);color:#fbbf24;border:1px solid rgba(245,158,11,0.3);border-radius:3px;padding:1px 5px">Zulauf ' + boQty + boDate + '</span>';
|
||
}
|
||
stockHtml += '</div>';
|
||
}
|
||
|
||
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">'
|
||
+ '<div><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>'
|
||
+ stockHtml
|
||
+ '</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 →</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 = [];
|
||
var _allFormFactors = [];
|
||
|
||
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="10" class="loading pulse">Loading…</td></tr>';
|
||
var data = await api('/api/standards').catch(function() { return {}; });
|
||
_allStandards = data.data || [];
|
||
}
|
||
filterStandardsTable();
|
||
}
|
||
|
||
// ── SUB-TAB SWITCHING ───────────────────────────────────────────────────────
|
||
function switchStdSubtab(tab) {
|
||
['standards','formfaktoren'].forEach(function(t) {
|
||
var content = el('std-subtab-' + t);
|
||
var btn = el('std-sub-btn-' + t);
|
||
if (!content || !btn) return;
|
||
var active = t === tab;
|
||
content.classList.toggle('hidden', !active);
|
||
btn.style.color = active ? 'var(--accent)' : 'var(--text-dim)';
|
||
btn.style.borderBottom = active ? '2px solid var(--accent)' : '2px solid transparent';
|
||
});
|
||
if (tab === 'formfaktoren') {
|
||
if (_allFormFactors.length === 0) loadFormFactors(); else renderFormFactors();
|
||
}
|
||
}
|
||
|
||
async function loadFormFactors() {
|
||
if (_allFormFactors.length > 0) { renderFormFactors(); return; }
|
||
var grid = el('ff-grid');
|
||
if (grid) grid.innerHTML = '<div class="card" style="padding:1rem;color:var(--text-dim);font-size:0.85rem;grid-column:1/-1"><span class="loading pulse">Lade Bauformen…</span></div>';
|
||
var data = await api('/api/form-factors').catch(function() { return {}; });
|
||
_allFormFactors = data.data || [];
|
||
renderFormFactors();
|
||
}
|
||
|
||
function filterFormFactors() {
|
||
var q = (el('ff-search') ? el('ff-search').value.toLowerCase() : '');
|
||
var family = (el('ff-family-filter') ? el('ff-family-filter').value : '');
|
||
var status = (el('ff-status-filter') ? el('ff-status-filter').value : '');
|
||
var filtered = _allFormFactors.filter(function(f) {
|
||
var matchQ = !q || (f.name || '').toLowerCase().includes(q)
|
||
|| (f.full_name || '').toLowerCase().includes(q)
|
||
|| (f.description || '').toLowerCase().includes(q)
|
||
|| String(f.max_speed_gbps || '').includes(q);
|
||
var matchFamily = !family || (f.family || '') === family;
|
||
var matchStatus = !status || (f.status || '') === status;
|
||
return matchQ && matchFamily && matchStatus;
|
||
});
|
||
renderFormFactors(filtered);
|
||
}
|
||
|
||
function renderFormFactors(items) {
|
||
var grid = el('ff-grid');
|
||
if (!grid) return;
|
||
var list = items || _allFormFactors;
|
||
if (!list.length) {
|
||
grid.innerHTML = '<div class="card" style="padding:1rem;color:var(--text-dim);font-size:0.85rem;grid-column:1/-1">Keine Bauformen geladen.</div>';
|
||
return;
|
||
}
|
||
var statusColors = { current: '#2d6a4f', emerging: '#e6a800', legacy: '#888', obsolete: '#c1121f' };
|
||
var statusLabels = { current: 'Aktuell', emerging: 'Neu/Emerging', legacy: 'Legacy', obsolete: 'Veraltet' };
|
||
var familyColors = { 'SFP family': '#0ea5e9', 'QSFP family': '#6366f1', 'OSFP family': '#FF8100', 'CFP family': '#2d6a4f', 'legacy': '#888' };
|
||
grid.innerHTML = list.map(function(f) {
|
||
var sCl = statusColors[f.status] || '#888';
|
||
var fCl = familyColors[f.family] || '#888';
|
||
var sLbl = statusLabels[f.status] || f.status || '';
|
||
var maxSpd = f.max_speed_gbps >= 1000 ? (f.max_speed_gbps/1000) + 'T' : (f.max_speed_gbps || '?') + 'G';
|
||
// Description: show first part (DE) if bilingual
|
||
var descFull = f.description || '';
|
||
var descDE = descFull.split('//')[0].trim();
|
||
var descEN = descFull.includes('//') ? descFull.split('//')[1].trim() : '';
|
||
var supersedes = Array.isArray(f.supersedes) ? f.supersedes.filter(Boolean) : [];
|
||
return '<div class="card" style="padding:1rem;display:flex;flex-direction:column;gap:0.5rem;cursor:pointer" onclick="openFormFactorDetail(\'' + esc(f.name) + '\')">'
|
||
// Header row
|
||
+ '<div style="display:flex;align-items:flex-start;gap:0.5rem;flex-wrap:wrap">'
|
||
+ '<span style="font-size:1rem;font-weight:700;color:var(--text-bright)">' + esc(f.name) + '</span>'
|
||
+ '<span style="background:' + fCl + '22;color:' + fCl + ';padding:1px 7px;border-radius:6px;font-size:0.68rem;font-weight:600;margin-left:auto">' + esc(f.family || '') + '</span>'
|
||
+ '</div>'
|
||
// Full name + speed badge
|
||
+ '<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap">'
|
||
+ '<span style="font-size:0.75rem;color:var(--text-dim)">' + esc(f.full_name || '') + '</span>'
|
||
+ '<span style="background:' + sCl + '22;color:' + sCl + ';padding:1px 6px;border-radius:5px;font-size:0.65rem;font-weight:600;margin-left:auto">' + esc(sLbl) + '</span>'
|
||
+ '</div>'
|
||
// Speed + channels
|
||
+ '<div style="display:flex;gap:0.5rem;align-items:center">'
|
||
+ '<span style="font-size:0.85rem;font-weight:700;color:' + fCl + '">' + maxSpd + '</span>'
|
||
+ (f.channels ? '<span style="font-size:0.72rem;color:var(--text-dim)">' + f.channels + ' Kanal' + (f.channels > 1 ? 'e' : '') + '</span>' : '')
|
||
+ (f.channel_rate_gbps ? '<span style="font-size:0.72rem;color:var(--text-dim)">je ' + f.channel_rate_gbps + 'G</span>' : '')
|
||
+ (f.year_introduced ? '<span style="font-size:0.68rem;color:var(--text-dim);margin-left:auto">seit ' + f.year_introduced + '</span>' : '')
|
||
+ '</div>'
|
||
// Description (DE)
|
||
+ (descDE ? '<div style="font-size:0.78rem;color:var(--text);line-height:1.55;border-top:1px solid var(--border);padding-top:0.5rem">' + esc(descDE) + '</div>' : '')
|
||
// Supersedes
|
||
+ (supersedes.length ? '<div style="font-size:0.68rem;color:var(--text-dim)">Ersetzt: ' + supersedes.map(function(s) { return '<code style="font-size:0.7rem;color:var(--cyan)">' + esc(s) + '</code>'; }).join(', ') + '</div>' : '')
|
||
+ (f.transceiver_count > 0 ? '<div style="font-size:0.7rem;color:var(--accent);font-weight:600">' + f.transceiver_count + ' Transceiver in Datenbank</div>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// ── FORM FACTOR HYPE CYCLE DATA (static, curated per form factor) ────────────
|
||
var FF_HYPE = {
|
||
'CXP': { phase:'LEGACY_DECLINE', pct:98, signal:'AVOID', sigCol:'#c1121f', sigLbl:'Veraltet — nicht mehr einsetzen' },
|
||
'XFP': { phase:'LEGACY_DECLINE', pct:93, signal:'AVOID', sigCol:'#c1121f', sigLbl:'Legacy — durch SFP+ vollständig ersetzt' },
|
||
'CFP': { phase:'LEGACY_DECLINE', pct:90, signal:'MIGRATE', sigCol:'#888', sigLbl:'Migration zu CFP2/CFP4 planen' },
|
||
'SFP': { phase:'PLATEAU_OF_PRODUCTIVITY', pct:88, signal:'BUY_NOW', sigCol:'#2d6a4f', sigLbl:'Stabil & günstig — jetzt kaufen' },
|
||
'SFP+': { phase:'PLATEAU_OF_PRODUCTIVITY', pct:84, signal:'BUY_NOW', sigCol:'#2d6a4f', sigLbl:'Bewährt & preislich ausgereift' },
|
||
'QSFP+': { phase:'PLATEAU_OF_PRODUCTIVITY', pct:80, signal:'BUY_NOW', sigCol:'#2d6a4f', sigLbl:'40G-Standard — stabile Preise' },
|
||
'SFP28': { phase:'PLATEAU_OF_PRODUCTIVITY', pct:77, signal:'BUY_NOW', sigCol:'#2d6a4f', sigLbl:'25G-Standard — bestes Preis-Leistungs-Verhältnis' },
|
||
'QSFP28': { phase:'PLATEAU_OF_PRODUCTIVITY', pct:74, signal:'BUY_NOW', sigCol:'#2d6a4f', sigLbl:'100G-Standard — Top Preis-Leistung' },
|
||
'CFP4': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:68, signal:'BUY_NOW', sigCol:'#4287f5', sigLbl:'Kompaktes 100G-WDM — Preise stabil' },
|
||
'CFP2': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:65, signal:'BUY_NOW', sigCol:'#4287f5', sigLbl:'Kohärent WDM — Reifephase' },
|
||
'QSFP56': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:60, signal:'CONSIDER', sigCol:'#4287f5', sigLbl:'200G — Preise fallen noch' },
|
||
'SFP56': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:56, signal:'CONSIDER', sigCol:'#4287f5', sigLbl:'50G — noch frühe Marktphase' },
|
||
'SFP56-DD': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:53, signal:'CONSIDER', sigCol:'#4287f5', sigLbl:'100G SFP-Dichte — noch teuer' },
|
||
'QSFP-DD': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:50, signal:'CONSIDER', sigCol:'#4287f5', sigLbl:'400G — stark wachsend, Preise sinken' },
|
||
'OSFP': { phase:'SLOPE_OF_ENLIGHTENMENT', pct:47, signal:'CONSIDER', sigCol:'#4287f5', sigLbl:'400G/800G — Reifung in Gang' },
|
||
'QSFP112': { phase:'TROUGH_OF_DISILLUSIONMENT', pct:40, signal:'WAIT', sigCol:'#e6a800', sigLbl:'400G QP112 — Preise noch hoch' },
|
||
'QSFP-DD800': { phase:'PEAK_OF_INFLATED_EXPECTATIONS', pct:32, signal:'WAIT', sigCol:'#FF8100', sigLbl:'800G QSFP-DD — hoher Hype, hohe Preise' },
|
||
'OSFP112': { phase:'PEAK_OF_INFLATED_EXPECTATIONS', pct:22, signal:'HOLD', sigCol:'#FF8100', sigLbl:'800G/1.6T — sehr früh, nur wenn nötig' },
|
||
'SFP112': { phase:'PEAK_OF_INFLATED_EXPECTATIONS', pct:18, signal:'HOLD', sigCol:'#FF8100', sigLbl:'100G SFP-Dichte — Pilotphase' },
|
||
'OSFP224': { phase:'INNOVATION_TRIGGER', pct:5, signal:'HOLD', sigCol:'#7c3aed', sigLbl:'1.6T — zu früh, noch kein Massenmarkt' }
|
||
};
|
||
|
||
// ── FORM FACTOR USE CASES ─────────────────────────────────────────────────────
|
||
var FF_USE_CASES = {
|
||
'SFP': ['Endgeräte: PCs, IP-Telefone, Drucker, Kameras', '1G Management-Ports an Switches & Routern', 'Out-of-Band Management (Konsolen-Server)', 'Kleine Büronetzwerke & Campus LAN', 'IoT-Geräte und Sensorknoten'],
|
||
'SFP+': ['10G Server → Top-of-Rack-Switch (klassischer Enterprise-Standard)', 'Campus-Aggregation und Enterprise-Zugangslayer', 'Storage-Netzwerke (iSCSI, NFS over 10G)', 'VMware vSphere / vSAN Cluster Fabric', 'Uplinks zwischen Switches in mittleren RZs'],
|
||
'SFP28': ['25G Server-NIC → ToR-Switch (Standard seit 2017)', 'Hyperscaler Server-zu-Switch in modernen RZs', 'NVMe-oF Storage-Fabric (25G)', 'ToR-zu-Aggregation in Leaf/Spine-Architekturen', '25G BreakOut aus QSFP28 (4×25G)'],
|
||
'SFP56': ['50G Server-NICs (selten, Nischenmarkt)', 'High-Density 50G Zugangslayer', 'BreakOut aus QSFP56 (4×50G)', 'Übergangsformat zwischen 25G und 100G'],
|
||
'SFP56-DD': ['100G in SFP-Portdichte (Hochdichte ToR)', 'Alternativer 100G-Slot ohne QSFP28-Hardware', 'Selten — QSFP28 meist bevorzugt'],
|
||
'SFP112': ['Zukünftig: 100G im kleinen SFP-Formfaktor', 'Ultra-Hochdichte Server-Fabric', 'Noch nicht weit verfügbar (2024+)'],
|
||
'XFP': ['Sehr alte 10G Switches und Router (Legacy)', '10G WAN-Verbindungen in alter Telko-Infrastruktur', 'Ersatz nur für bestehende Slots nötig — keine Neuinstallation'],
|
||
'QSFP+': ['40G Spine-Uplinks (Legacy — wird durch 100G abgelöst)', '40G Blade-Server-Anbindung', 'Bonding aus 4×10G SFP+-Verbindungen', '40G BreakOut: 4×10G SFP+ über ein Kabel'],
|
||
'QSFP28': ['100G Spine/Leaf in modernen Rechenzentren', '100G Server-Uplinks (AI/ML Cluster, Standard)', '100G Campus-Core und WAN-Edge', 'BreakOut: 4×25G SFP28 oder 2×50G SFP56', 'Dominant für 100G weltweit — bestes Preis-Leistungs-Verhältnis'],
|
||
'QSFP56': ['200G Spine-Uplinks und Aggregation', 'Hochdichte 200G Server-Fabric', '200G als BreakOut: 2×100G QSFP28', 'Übergangsformat auf dem Weg zu 400G'],
|
||
'QSFP112': ['400G mit maximaler QSFP-Portdichte', 'Next-Gen Hyperscale ToR-Switches', 'BreakOut: 4×100G mit einem Modul', 'Alternative zu QSFP-DD bei höherer Dichte'],
|
||
'QSFP-DD': ['400G Spine-Fabric in Hyperscale RZs', 'AI/ML Training-Cluster-Interconnect (400G)', '400G WAN-Edge und Carrier-Grade-Verbindungen', 'BreakOut: 4×100G QSFP28 oder 8×50G SFP56', 'Standard 400G neben OSFP — breite Unterstützung'],
|
||
'QSFP-DD800': ['800G AI/ML GPU-Cluster (NVIDIA H100, H200, Grace-Hopper)', 'Nächste Generation Hyperscale Spine-Switches', '800G BreakOut: 2×400G oder 8×100G', 'Hohes Wachstum durch AI-Infrastruktur-Boom'],
|
||
'OSFP': ['400G/800G Spine (Cisco Nexus 9000, Arista 7800)', 'Kohärente 400G/800G ZR/ZR+ WAN-Module (mehr Platz für DCO)', 'High-Power-Optiken: mehr Kühlung als QSFP-DD möglich', 'AI/ML Cluster in Cisco-basierten Setups'],
|
||
'OSFP112': ['800G und zukünftige 1.6T AI-Cluster', 'Nächste Generation GPU-Fabric (NVIDIA Scale-out)', 'Ultra-High-Bandwidth Spine für KI-Infrastruktur', '2×800G BreakOut in einem Modul'],
|
||
'OSFP224': ['1.6T AI/ML Mega-Cluster (zukünftig)', 'NVIDIA GB200 NVLink 5.0 / Spectrum-X Scale-out', 'Next-Gen 800G/1.6T Hyperscale Fabrics (2025/2026)'],
|
||
'CFP': ['Historisch: Erste 100G WAN-Verbindungen (veraltet)', 'Nur noch Ersatz für bestehende Slots'],
|
||
'CFP2': ['Kohärente 100G/200G WAN (DWDM Transponder)', 'Tunable DWDM für Metro und Long-Haul-Netze', 'Telko-Backbone und Provider-Core-Equipment', 'Pluggable Coherent für 400G-ZR (CFP2-DCO)'],
|
||
'CFP4': ['Kompaktes tunable 100G DWDM (doppelte CFP2-Dichte)', 'Provider-Edge-Equipment und Metro-Netze', 'DWDM wo mehr Ports als CFP2 nötig sind'],
|
||
'CXP': ['Praktisch nicht mehr im Einsatz', 'Nur in allerersten 100G-Pilotinstallationen (2010-2012)', 'Ersatz: QSFP28 verwenden']
|
||
};
|
||
|
||
function _ffMiniHypeSVG(pct, col) {
|
||
// Compact 240×54px hype cycle curve SVG with a dot
|
||
var W = 240, H = 52;
|
||
// Approximate Gartner curve via cubic bezier (normalized 0-1, scaled to W×H)
|
||
// key points: start(0,0.45), peak(0.28,0.05), trough(0.52,0.70), slope(0.74,0.25), plateau(1.0,0.30)
|
||
var pts = [[0,0.46],[0.14,0.35],[0.22,0.07],[0.28,0.05],[0.34,0.12],[0.44,0.60],[0.52,0.70],[0.61,0.55],[0.72,0.24],[0.80,0.22],[1.0,0.25]];
|
||
// Convert to SVG coords (Y flipped, add 4px padding)
|
||
var pad = 6;
|
||
function sx(x){ return pad + x * (W - 2*pad); }
|
||
function sy(y){ return pad + y * (H - 2*pad - 6); }
|
||
// Build smooth polyline
|
||
var d = 'M ' + sx(pts[0][0]) + ' ' + sy(pts[0][1]);
|
||
for (var i=1; i<pts.length-1; i++) {
|
||
var cx = (pts[i][0]+pts[i+1][0])/2, cy = (pts[i][1]+pts[i+1][1])/2;
|
||
d += ' Q ' + sx(pts[i][0]) + ' ' + sy(pts[i][1]) + ' ' + sx(cx) + ' ' + sy(cy);
|
||
}
|
||
d += ' L ' + sx(pts[pts.length-1][0]) + ' ' + sy(pts[pts.length-1][1]);
|
||
// Find dot position by interpolating the curve at pct (0-100)
|
||
var t = pct / 100;
|
||
var seg = 0;
|
||
while (seg < pts.length-2 && pts[seg+1][0] < t) seg++;
|
||
var p0 = pts[Math.min(seg, pts.length-2)], p1 = pts[Math.min(seg+1, pts.length-1)];
|
||
var localT = p1[0]===p0[0] ? 0.5 : (t - p0[0]) / (p1[0] - p0[0]);
|
||
var dx = sx(p0[0] + localT*(p1[0]-p0[0])), dy = sy(p0[1] + localT*(p1[1]-p0[1]));
|
||
return '<svg width="' + W + '" height="' + H + '" style="display:block">'
|
||
+ '<path d="' + d + '" fill="none" stroke="var(--border)" stroke-width="2.5" stroke-linecap="round"/>'
|
||
+ '<circle cx="' + dx + '" cy="' + dy + '" r="6" fill="' + col + '" stroke="#fff" stroke-width="2"/>'
|
||
+ '<circle cx="' + dx + '" cy="' + dy + '" r="10" fill="' + col + '" opacity="0.2"/>'
|
||
+ '</svg>';
|
||
}
|
||
|
||
async function openFormFactorDetail(name) {
|
||
var f = _allFormFactors.find(function(x) { return x.name === name; });
|
||
if (!f) return;
|
||
var fCl = { 'SFP family': '#0ea5e9', 'QSFP family': '#6366f1', 'OSFP family': '#FF8100', 'CFP family': '#2d6a4f', 'legacy': '#888' }[f.family] || '#888';
|
||
var descFull = f.description || '';
|
||
var descDE = descFull.split('//')[0].trim();
|
||
var descEN = descFull.includes('//') ? descFull.split('//')[1].trim() : '';
|
||
var maxSpd = f.max_speed_gbps >= 1000 ? (f.max_speed_gbps/1000) + 'T' : (f.max_speed_gbps || '?') + 'G';
|
||
var supersedes = Array.isArray(f.supersedes) ? f.supersedes.filter(Boolean) : [];
|
||
var hype = FF_HYPE[f.name] || { phase:'—', pct:50, signal:'—', sigCol:'#888', sigLbl:'Keine Daten' };
|
||
var useCases = FF_USE_CASES[f.name] || [];
|
||
var phaseLabels = { 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', LEGACY_DECLINE:'Legacy / Decline' };
|
||
|
||
// Open panel immediately with skeleton
|
||
openPanel('<div style="padding:0.5rem;color:var(--text-dim);font-size:0.85rem" class="loading pulse">Lade ' + esc(name) + '…</div>');
|
||
|
||
// Fetch Flexoptix transceivers for this form factor
|
||
var txData = await api('/api/transceivers?form_factor=' + encodeURIComponent(f.name) + '&vendor=Flexoptix&limit=10').catch(function(){ return {}; });
|
||
var txRows = txData.data || [];
|
||
var txTotal = txData.total || txRows.length;
|
||
|
||
var h = '';
|
||
|
||
// ── Header ─────────────────────────────────────────────────────────────────
|
||
h += '<div style="display:flex;align-items:flex-start;gap:0.6rem;flex-wrap:wrap;margin-bottom:0.1rem">';
|
||
h += '<div>';
|
||
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">';
|
||
h += '<span style="font-size:1.15rem;font-weight:700;color:var(--text-bright)">' + esc(f.name) + '</span>';
|
||
h += '<span style="background:' + fCl + '22;color:' + fCl + ';padding:1px 8px;border-radius:6px;font-size:0.7rem;font-weight:700">' + maxSpd + '</span>';
|
||
var sLbl = { current:'Aktuell', emerging:'Neu', legacy:'Legacy', obsolete:'Veraltet' }[f.status] || f.status || '';
|
||
var sCl = { current:'#2d6a4f', emerging:'#e6a800', legacy:'#888', obsolete:'#c1121f' }[f.status] || '#888';
|
||
h += '<span style="background:' + sCl + '22;color:' + sCl + ';padding:1px 8px;border-radius:6px;font-size:0.7rem;font-weight:600">' + esc(sLbl) + '</span>';
|
||
h += '</div>';
|
||
h += '<div style="font-size:0.78rem;color:var(--text-dim);margin-top:3px">' + esc(f.full_name || '') + '</div>';
|
||
h += '</div>';
|
||
h += '</div>';
|
||
|
||
// ── Plain-language description ─────────────────────────────────────────────
|
||
if (descDE) {
|
||
h += '<div class="card" style="padding:0.85rem 1rem;border-left:3px solid ' + fCl + ';margin:0.85rem 0">';
|
||
h += '<div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.4rem">Was ist das?</div>';
|
||
h += '<div style="font-size:0.83rem;color:var(--text);line-height:1.6">' + esc(descDE) + '</div>';
|
||
if (descEN) h += '<div style="font-size:0.73rem;color:var(--text-dim);margin-top:0.5rem;border-top:1px solid var(--border);padding-top:0.45rem;font-style:italic">' + esc(descEN) + '</div>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// ── Hype Cycle Position ────────────────────────────────────────────────────
|
||
h += '<div class="card" style="padding:0.85rem 1rem;margin-bottom:0.85rem">';
|
||
h += '<div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.6rem">Hype Cycle Position</div>';
|
||
h += '<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">';
|
||
h += '<div>' + _ffMiniHypeSVG(hype.pct, hype.sigCol) + '</div>';
|
||
h += '<div>';
|
||
h += '<div style="font-size:0.78rem;font-weight:700;color:' + hype.sigCol + '">' + esc(phaseLabels[hype.phase] || hype.phase) + '</div>';
|
||
h += '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:3px;max-width:140px;line-height:1.4">' + esc(hype.sigLbl) + '</div>';
|
||
// Buy signal badge
|
||
var bsLabel = { BUY_NOW:'✓ Jetzt kaufen', CONSIDER:'→ In Betracht ziehen', WAIT:'⏳ Warten', HOLD:'⚠ Halten', MIGRATE:'↗ Migration planen', AVOID:'✗ Vermeiden' };
|
||
h += '<div style="margin-top:0.5rem;display:inline-block;background:' + hype.sigCol + '22;color:' + hype.sigCol + ';padding:3px 10px;border-radius:8px;font-size:0.72rem;font-weight:700">' + esc(bsLabel[hype.signal] || hype.signal) + '</div>';
|
||
h += '</div>';
|
||
h += '</div>';
|
||
h += '</div>';
|
||
|
||
// ── Key Specs ──────────────────────────────────────────────────────────────
|
||
var statusLabels = { current:'Aktuell', emerging:'Neu / Emerging', legacy:'Legacy', obsolete:'Veraltet' };
|
||
var specs = [
|
||
['Max. Speed', maxSpd],
|
||
['Kanäle', f.channels ? f.channels + ' × ' + (f.channel_rate_gbps || '?') + 'G' : null],
|
||
['Stecker', f.connector_type || null],
|
||
['Hot-swap', f.hot_swap ? 'Ja — im Betrieb tauschbar' : 'Nein'],
|
||
['Auf Markt seit', f.year_introduced ? String(f.year_introduced) : null],
|
||
['Größe (B×T)', (f.physical_width_mm && f.physical_height_mm) ? f.physical_width_mm + '×' + f.physical_height_mm + 'mm' : null],
|
||
['Familie', f.family || null],
|
||
['Ersetzt durch', f.superseded_by || null]
|
||
].filter(function(sp){ return sp[1]; });
|
||
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem;margin-bottom:0.85rem">';
|
||
specs.forEach(function(sp) {
|
||
h += '<div style="background:var(--surface3);padding:0.45rem 0.65rem;border-radius:7px">'
|
||
+ '<div style="font-size:0.62rem;color:var(--text-dim);margin-bottom:1px">' + esc(sp[0]) + '</div>'
|
||
+ '<div style="font-size:0.78rem;font-weight:600;color:var(--text-bright)">' + esc(String(sp[1])) + '</div>'
|
||
+ '</div>';
|
||
});
|
||
h += '</div>';
|
||
|
||
// ── Supersedes chain ───────────────────────────────────────────────────────
|
||
if (supersedes.length) {
|
||
h += '<div style="margin-bottom:0.85rem">';
|
||
h += '<div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.4rem">Ersetzt:</div>';
|
||
h += '<div style="display:flex;gap:0.35rem;flex-wrap:wrap">';
|
||
supersedes.forEach(function(s) {
|
||
h += '<span class="b b-blue" style="font-size:0.78rem;cursor:pointer" onclick="openFormFactorDetail(\'' + esc(s) + '\')">' + esc(s) + '</span>';
|
||
});
|
||
h += '</div></div>';
|
||
}
|
||
|
||
// ── Use Cases ─────────────────────────────────────────────────────────────
|
||
if (useCases.length) {
|
||
h += '<div style="margin-bottom:0.85rem">';
|
||
h += '<div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim);margin-bottom:0.45rem">Typische Einsatzgebiete</div>';
|
||
useCases.forEach(function(uc) {
|
||
h += '<div style="font-size:0.8rem;color:var(--text);display:flex;align-items:flex-start;gap:0.45rem;margin-bottom:0.3rem">'
|
||
+ '<span style="color:' + fCl + ';flex-shrink:0;margin-top:1px">▸</span>' + esc(uc) + '</div>';
|
||
});
|
||
h += '</div>';
|
||
}
|
||
|
||
// ── Transceivers in Catalog ────────────────────────────────────────────────
|
||
h += '<div style="margin-bottom:0.85rem">';
|
||
h += '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.45rem">';
|
||
h += '<div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-dim)">Flexoptix-Lösungen</div>';
|
||
h += '<img src="https://www.flexoptix.net/favicon.ico" onerror="this.style.display=\'none\'" style="width:12px;height:12px;border-radius:2px">';
|
||
h += (txTotal > 0 ? '<span style="font-size:0.68rem;color:var(--accent);font-weight:600;margin-left:auto">' + txTotal + ' verfügbar</span>' : '');
|
||
h += '</div>';
|
||
if (txRows.length) {
|
||
var speedColors2 = { 1600:'#7c3aed', 800:'#c1121f', 400:'#FF8100', 200:'#e6a800', 100:'#2d6a4f', 40:'#4287f5', 25:'#0ea5e9', 10:'#888', 1:'#555' };
|
||
h += '<div style="display:flex;flex-direction:column;gap:0.3rem">';
|
||
txRows.forEach(function(t) {
|
||
var spCol = speedColors2[t.speed_gbps] || '#888';
|
||
var reach = t.reach_label || (t.reach_meters ? (t.reach_meters >= 1000 ? (t.reach_meters/1000)+'km' : t.reach_meters+'m') : '');
|
||
var shopUrl = 'https://www.flexoptix.net/en/search/ajax/suggest/?q=' + encodeURIComponent(t.part_number || '');
|
||
h += '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.35rem 0.6rem;background:var(--surface3);border-radius:7px;cursor:pointer" onclick="closePanel();openTxDetail(\'' + esc(t.id) + '\')">'
|
||
+ '<span style="background:' + spCol + '22;color:' + spCol + ';padding:1px 6px;border-radius:5px;font-size:0.68rem;font-weight:700;white-space:nowrap">' + (t.speed_gbps || '?') + 'G</span>'
|
||
+ '<span style="font-size:0.78rem;font-weight:600;color:var(--text-bright);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(t.part_number || '—') + '</span>'
|
||
+ (reach ? '<span style="font-size:0.68rem;color:var(--cyan);white-space:nowrap">' + esc(reach) + '</span>' : '')
|
||
+ '<a href="' + esc(shopUrl) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="font-size:0.65rem;color:var(--accent);white-space:nowrap;text-decoration:none" title="Im Webshop ansehen">Shop ↗</a>'
|
||
+ '</div>';
|
||
});
|
||
h += '</div>';
|
||
if (txTotal > 10) {
|
||
h += '<button class="btn" style="width:100%;margin-top:0.5rem;font-size:0.78rem;background:var(--surface3);color:var(--text)" '
|
||
+ 'onclick="goToTab(\'transceivers\');el(\'tx-search\').value=\'' + esc(f.name) + '\';searchTransceivers();closePanel()">'
|
||
+ 'Alle ' + txTotal + ' Flexoptix-' + esc(f.name) + '-Produkte anzeigen →</button>';
|
||
}
|
||
} else {
|
||
h += '<div style="font-size:0.8rem;color:var(--text-dim);padding:0.5rem 0;border:1px dashed var(--border);border-radius:6px;text-align:center">Noch keine Flexoptix-Produkte für ' + esc(f.name) + ' in der Datenbank.</div>';
|
||
}
|
||
h += '</div>';
|
||
|
||
// ── Action buttons ─────────────────────────────────────────────────────────
|
||
h += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap">';
|
||
h += '<a href="https://www.flexoptix.net/en/optical-transceivers/?form_factor=' + encodeURIComponent(f.name.toLowerCase().replace(/[^a-z0-9]/g,'-')) + '" target="_blank" rel="noopener" class="btn" style="flex:1;background:#FF8100;color:#fff;font-size:0.8rem;text-align:center;text-decoration:none">flexoptix.net — ' + esc(f.name) + ' ↗</a>';
|
||
h += '<button class="btn" style="font-size:0.8rem;background:var(--surface3);color:var(--text)" '
|
||
+ 'onclick="goToTab(\'transceivers\');el(\'tx-search\').value=\'' + esc(f.name) + '\';searchTransceivers();closePanel()">'
|
||
+ 'TIP DB →</button>';
|
||
h += '</div>';
|
||
|
||
// ── Technical notes ────────────────────────────────────────────────────────
|
||
if (f.notes) {
|
||
h += '<div class="card" style="margin-top:0.75rem;padding:0.7rem;font-size:0.73rem;color:var(--text-dim);line-height:1.5">'
|
||
+ '<strong style="color:var(--text)">Technisch:</strong> ' + esc(f.notes) + '</div>';
|
||
}
|
||
|
||
buildDOM(el('panel-content'), h);
|
||
}
|
||
|
||
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)
|
||
|| (s.description || '').toLowerCase().includes(q)
|
||
|| (s.notes || '').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);
|
||
// Show description (DE part before //) as subtitle
|
||
var descFull = s.description || '';
|
||
var descDE = descFull.split('//')[0].trim();
|
||
// Shorten to ~80 chars for table
|
||
var descShort = descDE.length > 85 ? descDE.slice(0, 82) + '…' : descDE;
|
||
var nameCell = '<div style="font-weight:700;color:var(--text-bright);line-height:1.2">' + esc(s.name || '—') + '</div>'
|
||
+ (descShort ? '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px;line-height:1.35;max-width:28ch;white-space:normal">' + esc(descShort) + '</div>' : '');
|
||
return '<tr style="cursor:pointer" onclick="openStandardDetail(' + sidx + ')">'
|
||
+ '<td style="min-width:180px;padding-top:5px;padding-bottom:5px">' + nameCell + '</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 || ''];
|
||
// Prefer DB description if available, fall back to generated explanation
|
||
var dbDescFull = s.description || '';
|
||
var dbDescDE = dbDescFull.split('//')[0].trim();
|
||
var dbDescEN = dbDescFull.includes('//') ? dbDescFull.split('//')[1].trim() : '';
|
||
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 — DB description preferred, generated as fallback
|
||
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>';
|
||
if (dbDescDE) {
|
||
h += '<div style="font-size:0.83rem;color:var(--text);line-height:1.65">' + esc(dbDescDE) + '</div>';
|
||
if (dbDescEN) {
|
||
h += '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.6rem;border-top:1px solid var(--border);padding-top:0.5rem;font-style:italic">' + esc(dbDescEN) + '</div>';
|
||
}
|
||
} else {
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.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 = '✓ Copied';
|
||
setTimeout(function() {
|
||
btn.classList.remove('copied');
|
||
btn.innerHTML = '📋 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 generateBlogFromUrl() {
|
||
var url = (document.getElementById('blog-from-url-input').value || '').trim();
|
||
var topic = document.getElementById('blog-from-url-topic').value || 'technology_deep_dive';
|
||
var btn = document.getElementById('blog-from-url-btn');
|
||
var status = document.getElementById('blog-from-url-status');
|
||
|
||
if (!url) { showToast('Fehler', 'Bitte eine URL eingeben', true); return; }
|
||
try { new URL(url); } catch (e) { showToast('Fehler', 'Ungültige URL', true); return; }
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Fetching…';
|
||
status.textContent = 'Seite wird abgerufen…';
|
||
|
||
var token = window.loadToken ? window.loadToken() : '';
|
||
fetch(API + '/api/blog/from-url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||
body: JSON.stringify({ url: url, topic: topic })
|
||
})
|
||
.then(function(r) { if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); } return r.json(); })
|
||
.then(function(data) {
|
||
btn.disabled = false;
|
||
btn.textContent = '🔗 Aus URL generieren';
|
||
if (data.success) {
|
||
var spaNote = data.spa_detected
|
||
? ' ⚠️ SPA erkannt — Inhalt nur via Meta-Tags (JS-gerendert)'
|
||
: ' ✓ ' + data.extracted_chars + ' Zeichen extrahiert';
|
||
status.textContent = spaNote + ' — Pipeline läuft (~10 min)';
|
||
if (data.spa_detected) {
|
||
showToast('⚠️ SPA erkannt', (data.page_title || url) + ' — JavaScript-Seite, Inhalt via Meta-Tags. Pipeline läuft.');
|
||
} else {
|
||
showToast('✓ Pipeline gestartet', (data.page_title || url) + ' — wird in ~10 min fertig');
|
||
}
|
||
document.getElementById('blog-from-url-input').value = '';
|
||
loadBlogDrafts();
|
||
pollBlogLlm(data.draft.id, 0);
|
||
} else {
|
||
status.textContent = '✗ Fehler: ' + (data.error || 'Unbekannt');
|
||
showToast('Fehler', data.error || 'Unbekannter Fehler', true);
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
btn.disabled = false;
|
||
btn.textContent = '🔗 Aus URL generieren';
|
||
status.textContent = '';
|
||
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 switchBlogLlm(providerKey, model) {
|
||
var msgEl = document.getElementById('blog-llm-switch-msg');
|
||
// Disable all cards visually while switching
|
||
['cc','claude','fo','qwen'].forEach(function(k) {
|
||
var c = document.getElementById('blog-model-card-' + k);
|
||
if (c) { c.style.pointerEvents = 'none'; c.style.opacity = '0.5'; }
|
||
});
|
||
if (msgEl) {
|
||
msgEl.style.display = 'block';
|
||
msgEl.style.background = 'var(--surface3)';
|
||
msgEl.style.color = 'var(--text-dim)';
|
||
msgEl.textContent = '↺ Wechsle zu ' + providerKey + (model ? ' (' + model + ')' : '') + '…';
|
||
}
|
||
try {
|
||
var body = { provider: providerKey };
|
||
if (model) body.model = model;
|
||
var data = await api('/api/blog/llm/switch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (data.success) {
|
||
if (msgEl) {
|
||
msgEl.style.background = '#d1fae5';
|
||
msgEl.style.color = '#065f46';
|
||
msgEl.textContent = '✓ ' + data.message;
|
||
setTimeout(function() { if (msgEl) msgEl.style.display = 'none'; }, 3000);
|
||
}
|
||
await loadBlogLLMStatus();
|
||
} else {
|
||
if (msgEl) {
|
||
msgEl.style.background = '#fee2e2';
|
||
msgEl.style.color = '#b91c1c';
|
||
msgEl.textContent = '✗ Fehler: ' + (data.error || 'Unbekannt');
|
||
}
|
||
}
|
||
} catch(e) {
|
||
if (msgEl) {
|
||
msgEl.style.background = '#fee2e2';
|
||
msgEl.style.color = '#b91c1c';
|
||
msgEl.textContent = '✗ Netzwerkfehler: ' + e.message;
|
||
}
|
||
} finally {
|
||
['cc','claude','fo','qwen'].forEach(function(k) {
|
||
var c = document.getElementById('blog-model-card-' + k);
|
||
if (c) { c.style.pointerEvents = ''; c.style.opacity = ''; }
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadBlogLLMStatus() {
|
||
try {
|
||
var data = await api('/api/blog/llm/status');
|
||
var llm = data.llm || {};
|
||
|
||
// Dynamically sync the FO_BlogLLM card to the actual current Ollama model name.
|
||
// Prevents the UI from going stale when Magatama trains a new fo-blog-vX version.
|
||
var foCard = document.getElementById('blog-model-card-fo');
|
||
var foName = document.getElementById('blog-model-fo-name');
|
||
var foEnv = document.getElementById('blog-model-fo-env');
|
||
var currentFoModel = (llm.model && /^fo-blog-/.test(llm.model)) ? llm.model : (foCard && foCard.dataset.foModel) || 'fo-blog-v10';
|
||
if (foCard) foCard.dataset.foModel = currentFoModel;
|
||
if (foName) foName.textContent = '🎯 ' + currentFoModel;
|
||
if (foEnv) foEnv.innerHTML = 'BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=' + currentFoModel;
|
||
// Expose for hot-topics.js (blog pipeline progress label)
|
||
window._activeFoBlogModel = currentFoModel;
|
||
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 — ' + (llm.model || 'fo-blog-v10') + ' erreichbar') : '⚠ Ollama/Bridge 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)">👤 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 — 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">🔄 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">📄 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) + '\')">📋 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">👤 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) + '\')">📋 Copy</button>';
|
||
}
|
||
h += '</div>';
|
||
if (d.linkedin_post) {
|
||
h += '<div data-linkedin-text="' + esc(d.linkedin_post).replace(/"/g, '"') + '" 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)">🔄 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">✍ 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">🔗 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">🔗</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">×</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, '<') + '</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">📋 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 ';
|
||
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 += ' | 🧠 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 = [];
|
||
var procDemandData = [];
|
||
var procDemandSummary = [];
|
||
var procDemandFilter = '';
|
||
var procAiClustersData = [];
|
||
var procAiClustersMinTx = 0;
|
||
|
||
function showProcSection(name) {
|
||
['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock',
|
||
'abc','demand','marketplace','ai-clusters','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); }
|
||
});
|
||
// Lazy-load on first visit
|
||
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
|
||
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
|
||
if (name === 'marketplace' && !el('proc-marketplace-grid').querySelector('.card')) loadProcMarketplace();
|
||
if (name === 'reorder-top' && !el('proc-reorder-top-list').querySelector('div.card,table')) loadReorderTop();
|
||
if (name === 'arbitrage' && !el('proc-arbitrage-list').querySelector('table')) loadArbitrage();
|
||
if (name === 'switch-compat' && !el('proc-switch-stats').innerHTML) loadSwitchCompatStats();
|
||
if (name === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze();
|
||
if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival();
|
||
}
|
||
|
||
/* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */
|
||
async function loadReorderTop() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var ff = (el('reorder-ff-filter') || {}).value || '';
|
||
var listEl = el('proc-reorder-top-list');
|
||
var summEl = el('proc-reorder-top-summary');
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Loading…</div>';
|
||
try {
|
||
var r = await fetch('/api/procurement/reorder-top?limit=60&form_factor=' + encodeURIComponent(ff), { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error || 'err');
|
||
// Summary cards
|
||
var sm = d.summary || {};
|
||
if (summEl) summEl.innerHTML = [
|
||
{ label: '🟢 Buy Now', val: (sm.buy_now||0).toLocaleString(), color: '#22c55e' },
|
||
{ label: '⏳ Wait', val: (sm.wait||0).toLocaleString(), color: '#f59e0b' },
|
||
{ label: '⏸ Hold', val: (sm.hold||0).toLocaleString(), color: '#64748b' },
|
||
{ label: '👁 Monitor', val: (sm.monitor||0).toLocaleString(), color: '#94a3b8' },
|
||
{ label: 'Ø Buy Strength', val: sm.avg_buy_strength ? Math.round(parseFloat(sm.avg_buy_strength)*100)+'%' : '—', color: '#22c55e' },
|
||
].map(function(c) {
|
||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div>'
|
||
+ '</div>';
|
||
}).join('');
|
||
// Table
|
||
if (!d.data || !d.data.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Buy-Now-Signale gefunden.</div>'; return; }
|
||
var rows = d.data.map(function(r) {
|
||
var str = Math.round(parseFloat(r.signal_strength)*100);
|
||
var strColor = str >= 70 ? '#22c55e' : str >= 50 ? '#f59e0b' : '#94a3b8';
|
||
var reasons = Array.isArray(r.reasons) ? r.reasons.join(' · ') : (r.reasons || '—');
|
||
var pt = r.price_trend === 'rising' ? '📈' : r.price_trend === 'falling' ? '📉' : '→';
|
||
var st = r.stock_trend === 'declining' ? '📉' : r.stock_trend === 'increasing' ? '📈' : '→';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;color:var(--text-bright);font-size:0.78rem">'+esc(r.vendor_name||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.72rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(r.form_factor||'')+'</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.78rem">'+esc(String(r.speed_gbps||''))+'G</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:center;font-weight:700;color:'+strColor+';font-family:monospace">'+str+'%</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem">'+pt+' Preis · '+st+' Stock</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+esc(reasons)+'">'+esc(reasons.substring(0,80))+'</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:center;color:var(--text-dim)">Stärke</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Trends</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Grund</th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||
} catch(e) {
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||
}
|
||
}
|
||
function reloadReorderTop() { loadReorderTop(); }
|
||
|
||
/* ── A: Arbitrage ──────────────────────────────────────────────────────── */
|
||
async function loadArbitrage() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var listEl = el('proc-arbitrage-list');
|
||
var statsEl = el('proc-arbitrage-stats');
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Berechne Preisvergleiche aus 63k Equivalenz-Paaren…</div>';
|
||
try {
|
||
var r = await fetch('/api/procurement/arbitrage', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error || 'err');
|
||
var st = d.stats || {};
|
||
if (statsEl) statsEl.innerHTML = [
|
||
{ label: 'Paare mit Preisdaten', val: (st.totalPairs||0).toLocaleString(), color: '#3b82f6' },
|
||
{ label: 'FX günstiger', val: (st.fxCheaper||0).toLocaleString(), color: '#22c55e' },
|
||
{ label: 'Ø Ersparnis', val: (st.avgSavingsPct||0)+'%', color: '#22c55e' },
|
||
].map(function(c) {
|
||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||
}).join('');
|
||
if (!d.pairs || !d.pairs.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Arbitrage-Paare gefunden.</div>'; return; }
|
||
var rows = d.pairs.map(function(p) {
|
||
var savColor = p.savingsPct >= 50 ? '#22c55e' : p.savingsPct >= 20 ? '#f59e0b' : '#94a3b8';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem;font-weight:600;color:var(--text-bright)">'+esc(p.fx_vendor)+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(p.fx_part||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem;color:var(--text-dim)">'+esc(p.comp_vendor)+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(p.comp_part||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 5px;font-size:0.68rem">'+esc(p.form_factor)+'</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--text-dim);font-size:0.72rem">$'+esc(String(p.fxUSD))+' <span style="color:var(--text-dim);font-size:0.65rem">('+esc(p.fx_curr)+')</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--text-dim);font-size:0.72rem">$'+esc(String(p.compUSD))+' <span style="color:var(--text-dim);font-size:0.65rem">('+esc(p.comp_curr)+')</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:800;color:'+savColor+';font-family:monospace">'+p.savingsPct+'%</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.72rem"><thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">FX Vendor</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">FX Part</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Competitor</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Comp Part</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">FX (USD)</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Comp (USD)</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Günstiger</th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||
} catch(e) {
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||
}
|
||
}
|
||
|
||
/* ── B: Switch Compatibility ───────────────────────────────────────────── */
|
||
async function loadSwitchCompatStats() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var statsEl = el('proc-switch-stats');
|
||
try {
|
||
var r = await fetch('/api/procurement/switch-compat', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error);
|
||
var st = d.stats || {};
|
||
if (statsEl) statsEl.innerHTML = '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.75rem;margin-bottom:1rem">'
|
||
+ [
|
||
{ label: 'Switches in DB', val: (st.total_switches||0).toLocaleString(), color: '#6366f1' },
|
||
{ label: 'Kompatible Transceiver', val: (st.total_transceivers||0).toLocaleString(), color: '#3b82f6' },
|
||
{ label: 'Compat-Einträge', val: (st.total_compat_rows||0).toLocaleString(), color: '#22c55e' },
|
||
].map(function(c) {
|
||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||
}).join('') + '</div>'
|
||
+ '<div style="font-size:0.72rem;color:var(--text-dim)">Top Switches nach Compat-Anzahl: '
|
||
+ (d.topSwitches||[]).slice(0,6).map(function(s) {
|
||
return '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:2px 7px;margin:0 3px;font-size:0.7rem">'+esc(s.sw_vendor||s.vendor||'')+ ' '+esc(s.sw_model||s.model||'')+'</span>';
|
||
}).join('') + '</div>';
|
||
} catch(e) {}
|
||
}
|
||
async function loadSwitchCompat() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var query = (el('switch-search-input') || {}).value || '';
|
||
var resultsEl = el('proc-switch-results');
|
||
if (!query.trim()) { if (resultsEl) resultsEl.innerHTML = '<div style="color:var(--text-dim)">Switch-Modell eingeben.</div>'; return; }
|
||
if (resultsEl) resultsEl.innerHTML = '<div style="color:var(--text-dim)">Suche…</div>';
|
||
try {
|
||
var r = await fetch('/api/procurement/switch-compat?search='+encodeURIComponent(query)+'&limit=20', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error);
|
||
var switches = d.switches || [];
|
||
var txList = d.transceivers || [];
|
||
if (!switches.length) { resultsEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Kein Switch gefunden für "'+esc(query)+'".</div>'; return; }
|
||
var html = switches.map(function(sw) {
|
||
var txForSw = txList.filter(function(t) { return t.switch_id === sw.id; });
|
||
var txRows = txForSw.map(function(t) {
|
||
var priceStr = t.min_price ? t.currency+' '+t.min_price : '—';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.4rem 0.5rem;font-weight:600;font-size:0.75rem;color:var(--text-bright)">'+esc(t.vendor_name)+'</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;font-family:monospace;font-size:0.7rem;color:var(--text-dim)">'+esc(t.part_number||'')+'</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:1px 5px;font-size:0.68rem">'+esc(t.form_factor)+'</span></td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(t.speed_gbps))+'G</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--green);font-size:0.72rem">'+esc(priceStr)+'</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:center;font-size:0.68rem;color:var(--text-dim)">'+esc(t.verification_method||'')+'</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1.25rem">'
|
||
+ '<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap">'
|
||
+ '<span style="font-weight:800;font-size:0.9rem;color:var(--text-bright)">'+esc((sw.sw_vendor||sw.vendor||'')+ ' '+( sw.sw_model||sw.model||''))+'</span>'
|
||
+ (sw.sw_series||sw.series ? '<span style="font-size:0.72rem;color:var(--text-dim)">'+esc(sw.sw_series||sw.series)+'</span>' : '')
|
||
+ '<span style="margin-left:auto;font-size:0.72rem;background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 8px">'+esc(String(sw.compat_count||txForSw.length))+' kompatibel</span>'
|
||
+ '</div>'
|
||
+ (txForSw.length ? '<table style="width:100%;border-collapse:collapse;font-size:0.72rem"><thead><tr style="background:var(--surface2)"><th style="padding:0.35rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th><th style="padding:0.35rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th><th style="padding:0.35rem 0.5rem;color:var(--text-dim)">FF</th><th style="padding:0.35rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th><th style="padding:0.35rem 0.5rem;text-align:right;color:var(--text-dim)">Preis</th><th style="padding:0.35rem 0.5rem;color:var(--text-dim)">Methode</th></tr></thead><tbody>'
|
||
+ txRows + '</tbody></table>'
|
||
: '<div style="color:var(--text-dim);font-size:0.75rem">Keine Transceiver-Preise verfügbar.</div>')
|
||
+ '</div>';
|
||
}).join('');
|
||
resultsEl.innerHTML = html;
|
||
} catch(e) {
|
||
resultsEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||
}
|
||
}
|
||
|
||
/* ── C: Supply Squeeze Detector ────────────────────────────────────────── */
|
||
async function loadSupplySqueeze() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var summEl = el('proc-squeeze-summary');
|
||
var listEl = el('proc-squeeze-list');
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Analysiere Preis- & Nachfragesignale…</div>';
|
||
try {
|
||
var r = await fetch('/api/procurement/supply-squeeze', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error);
|
||
var sigs = d.signals || [];
|
||
var crit = sigs.filter(function(s) { return s.severity === 'critical'; });
|
||
var warn = sigs.filter(function(s) { return s.severity === 'warning'; });
|
||
if (summEl) summEl.innerHTML = '<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem">'
|
||
+ (crit.length ? '<div style="padding:0.5rem 1rem;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#ef4444">🔴 '+crit.length+' Kritisch</div>' : '')
|
||
+ (warn.length ? '<div style="padding:0.5rem 1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#f59e0b">⚠️ '+warn.length+' Warnung</div>' : '')
|
||
+ (!crit.length && !warn.length ? '<div style="padding:0.5rem 1rem;background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.4);border-radius:8px;font-size:0.82rem;font-weight:700;color:#22c55e">✅ Kein akuter Engpass erkannt</div>' : '')
|
||
+ '</div>';
|
||
if (!sigs.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Squeeze-Signale — Markt stabil.</div>'; return; }
|
||
var sevColor = { critical: '#ef4444', warning: '#f59e0b', watch: '#f59e0b', ok: '#64748b' };
|
||
var sevIcon = { critical: '🔴', warning: '⚠️', watch: '👁', ok: '✅' };
|
||
var rows = sigs.slice(0,30).map(function(s) {
|
||
var col = sevColor[s.severity] || '#64748b';
|
||
var icon = sevIcon[s.severity] || '';
|
||
var mom = s.price_momentum_pct !== 0 ? (s.price_momentum_pct > 0 ? '+' : '') + s.price_momentum_pct + '%' : '—';
|
||
var momColor = s.price_momentum_pct > 10 ? '#ef4444' : s.price_momentum_pct > 0 ? '#f59e0b' : '#22c55e';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.8rem;color:'+col+'">'+icon+' '+esc(String(s.speed_gbps))+'G</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(s.form_factor||'—')+'</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+momColor+';font-family:monospace">'+esc(mom)+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim)">'+esc((s.hype_phase||'—').replace(/_/g,' '))+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-size:0.72rem">'+(s.ai_demand_tx ? s.ai_demand_tx.toLocaleString()+' tx' : '—')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:center;font-weight:800;color:'+col+'">'+esc(String(s.activeSignals))+'/4</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim);max-width:200px">'+esc((s.reasons||[]).join(' · ').substring(0,100))+'</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
listEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Technologie</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Form Factor</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Preis 30d</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Hype Phase</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">AI Demand</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:center;color:var(--text-dim)">Signale</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Gründe</th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||
} catch(e) {
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||
}
|
||
}
|
||
|
||
/* ── D: Dead Stock Revival ─────────────────────────────────────────────── */
|
||
async function loadDeadStockRevival() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var summEl = el('proc-deadstock-summary');
|
||
var listEl = el('proc-deadstock-list');
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Analyse Dead-Stock gegen Hype-Cycle…</div>';
|
||
try {
|
||
var r = await fetch('/api/procurement/dead-stock-revival', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error);
|
||
if (summEl) summEl.innerHTML = [
|
||
{ label: 'Total Dead Stock', val: (d.totalDeadStock||0).toLocaleString(), color: '#64748b' },
|
||
{ label: 'Revival Kandidaten', val: (d.revivalCount||0).toLocaleString(), color: '#f59e0b' },
|
||
{ label: 'Revival Rate', val: d.totalDeadStock ? Math.round(d.revivalCount/d.totalDeadStock*100)+'%' : '—', color: '#22c55e' },
|
||
].map(function(c) {
|
||
return '<div class="stat-card" style="border-left:3px solid '+c.color+'">'
|
||
+ '<div class="stat-label">'+esc(c.label)+'</div>'
|
||
+ '<div class="stat-val" style="color:'+c.color+'">'+esc(c.val)+'</div></div>';
|
||
}).join('');
|
||
if (!d.revivals || !d.revivals.length) { listEl.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Keine Dead-Stock-Revival-Kandidaten gefunden.</div>'; return; }
|
||
var PHASE_ICONS = { innovation_trigger:'🔬', peak_inflated_expectations:'🚀', slope_enlightenment:'📈', plateau_productivity:'✅' };
|
||
var rows = d.revivals.map(function(r) {
|
||
var icon = PHASE_ICONS[r.hype_phase] || '●';
|
||
var scoreColor = r.hype_score >= 70 ? '#22c55e' : r.hype_score >= 50 ? '#f59e0b' : '#94a3b8';
|
||
var trend = r.demand_trend_pct != null ? (parseFloat(r.demand_trend_pct) > 0 ? '+' : '') + Math.round(parseFloat(r.demand_trend_pct)) + '%' : '—';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.75rem;color:var(--text-bright)">'+esc(r.vendor_name||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 5px;font-size:0.68rem">'+esc(r.form_factor)+'</span></td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(r.speed_gbps))+'G</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem">'+icon+' '+esc((r.hype_phase||'').replace(/_/g,' '))+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+scoreColor+';font-family:monospace">'+Math.round(r.hype_score)+'</td>'
|
||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-size:0.72rem;color:'+(parseFloat(trend)>0?'#22c55e':'#64748b')+'">'+esc(trend)+'</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
listEl.innerHTML = '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.75rem">Dead-Stock-SKUs deren Speed-Klasse eine <strong>steigende Hype-Phase</strong> hat (Hype Score >30). Revival = Markt dreht sich wieder für diese Technologie.</div>'
|
||
+ '<table style="width:100%;border-collapse:collapse;font-size:0.75rem"><thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Vendor</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim)">Part</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">FF</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Speed</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;color:var(--text-dim)">Hype Phase</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Score</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">Trend</th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
||
} catch(e) {
|
||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">'+esc(e.message)+'</div>';
|
||
}
|
||
}
|
||
|
||
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 (0–100%): 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>';
|
||
}
|
||
}
|
||
|
||
// ─── INTERNAL DEMAND ─────────────────────────────────────────────────────────
|
||
|
||
async function loadInternalDemand() {
|
||
try {
|
||
var d = await api('/api/procurement/internal-demand?limit=500');
|
||
procDemandData = d.data || [];
|
||
procDemandSummary = d.summary || [];
|
||
renderDemandSummaryCards();
|
||
filterVelocity(procDemandFilter);
|
||
} catch(e) {
|
||
el('demand-tbody').innerHTML = '<tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">Could not load demand data.</td></tr>';
|
||
}
|
||
}
|
||
|
||
function renderDemandSummaryCards() {
|
||
var velConfig = {
|
||
fast_mover: { label:'Fast Movers', icon:'🚀', bg:'rgba(22,163,74,0.08)', border:'rgba(22,163,74,0.25)', color:'#16a34a' },
|
||
regular: { label:'Regular', icon:'📦', bg:'rgba(37,99,235,0.08)', border:'rgba(37,99,235,0.25)', color:'#2563eb' },
|
||
slow_mover: { label:'Slow Movers', icon:'🐢', bg:'rgba(245,158,11,0.08)', border:'rgba(245,158,11,0.25)', color:'#c07000' },
|
||
dead_stock: { label:'Dead Stock', icon:'💀', bg:'rgba(136,136,136,0.1)', border:'rgba(136,136,136,0.25)','color':'var(--text-dim)' },
|
||
};
|
||
var cards = procDemandSummary.map(function(row) {
|
||
var cfg = velConfig[row.velocity_class] || { label:row.velocity_class, icon:'📊', bg:'var(--surface2)', border:'var(--border)', color:'var(--text)' };
|
||
var trend = parseFloat(row.avg_trend_pct);
|
||
var trendHtml = isNaN(trend) ? '' : '<span style="color:' + (trend >= 0 ? '#16a34a' : '#c1121f') + ';font-size:0.75rem;font-weight:600">' + (trend >= 0 ? '▲' : '▼') + ' ' + Math.abs(trend).toFixed(1) + '%</span>';
|
||
return '<div onclick="filterVelocity(\'' + row.velocity_class + '\')" style="flex:1;min-width:140px;background:' + cfg.bg + ';border:1px solid ' + cfg.border + ';border-radius:10px;padding:0.85rem 1rem;cursor:pointer">'
|
||
+ '<div style="font-size:1.4rem;line-height:1">' + cfg.icon + '</div>'
|
||
+ '<div style="font-weight:700;font-size:0.82rem;color:' + cfg.color + ';margin-top:0.3rem">' + cfg.label + '</div>'
|
||
+ '<div style="font-size:1.5rem;font-weight:800;color:' + cfg.color + ';line-height:1.1">' + parseInt(row.cnt).toLocaleString() + '</div>'
|
||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">SKUs</div>'
|
||
+ (parseFloat(row.total_demand_12m) > 0 ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.25rem">' + parseInt(row.total_demand_12m).toLocaleString() + ' units/12M</div>' : '')
|
||
+ (trendHtml ? '<div style="margin-top:0.25rem">' + trendHtml + '</div>' : '')
|
||
+ '</div>';
|
||
});
|
||
el('demand-summary-cards').innerHTML = cards.join('');
|
||
}
|
||
|
||
function filterVelocity(cls) {
|
||
procDemandFilter = cls;
|
||
var data = cls ? procDemandData.filter(function(r) { return r.velocity_class === cls; }) : procDemandData;
|
||
var velColors = { fast_mover:'#16a34a', regular:'#2563eb', slow_mover:'#c07000', dead_stock:'var(--text-dim)' };
|
||
var velIcons = { fast_mover:'🚀', regular:'📦', slow_mover:'🐢', dead_stock:'💀' };
|
||
el('demand-tbody').innerHTML = data.slice(0, 300).map(function(r) {
|
||
var trend = parseFloat(r.demand_trend_pct);
|
||
var trendHtml = isNaN(trend) ? '—' : '<span style="color:' + (trend >= 0 ? '#16a34a' : '#c1121f') + ';font-weight:600">' + (trend >= 0 ? '+' : '') + trend.toFixed(1) + '%</span>';
|
||
var velColor = velColors[r.velocity_class] || 'var(--text-dim)';
|
||
var velIcon = velIcons[r.velocity_class] || '·';
|
||
var productName = r.standard_name || r.part_number || r.description || r.sku;
|
||
var demand12 = parseFloat(r.demand_12m);
|
||
var demand3 = parseFloat(r.demand_3m);
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:7px 6px"><span style="font-size:1rem" title="' + esc(r.velocity_class) + '">' + velIcon + '</span></td>'
|
||
+ '<td style="padding:7px 6px"><div style="font-weight:600;font-size:0.82rem">' + esc(productName || '—') + '</div>'
|
||
+ '<div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.sku) + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div></td>'
|
||
+ '<td style="padding:7px 6px;font-size:0.75rem;color:var(--text-dim)">' + esc(r.form_factor || '—') + (r.speed_gbps ? ' ' + r.speed_gbps + 'G' : '') + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + velColor + '">' + (demand12 > 0 ? demand12.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right;color:var(--text-dim)">' + (demand3 > 0 ? demand3.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right">' + trendHtml + '</td>'
|
||
+ '</tr>';
|
||
}).join('') || '<tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">No items for this filter.</td></tr>';
|
||
}
|
||
|
||
// ─── AI CLUSTERS ─────────────────────────────────────────────────────────────
|
||
|
||
async function loadAiClusters() {
|
||
var days = (el('ai-days-select') || {}).value || '90';
|
||
var container = el('ai-cluster-grid');
|
||
container.innerHTML = '<div class="loading pulse" style="grid-column:1/-1">Loading AI cluster data...</div>';
|
||
el('ai-cluster-stats').innerHTML = '<div class="loading pulse" style="width:100%">Computing stats...</div>';
|
||
try {
|
||
var d = await api('/api/procurement/ai-clusters?days=' + days + '&limit=100');
|
||
procAiClustersData = d.data || [];
|
||
var stats = d.stats || {};
|
||
renderAiClusterStats(stats, d.period_days || parseInt(days));
|
||
filterAiClusters(procAiClustersMinTx);
|
||
} catch(e) {
|
||
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load AI cluster announcements.</div>';
|
||
}
|
||
}
|
||
|
||
function reloadAiClusters() {
|
||
procAiClustersData = [];
|
||
loadAiClusters();
|
||
}
|
||
|
||
function renderAiClusterStats(stats, days) {
|
||
var totalTx = parseInt(stats.total_estimated_transceivers) || 0;
|
||
var totalMW = parseFloat(stats.total_mw) || 0;
|
||
var withEst = parseInt(stats.with_estimates) || 0;
|
||
var total = parseInt(stats.total) || 0;
|
||
var companies = parseInt(stats.distinct_companies) || 0;
|
||
el('ai-cluster-stats').innerHTML = [
|
||
statCard('📰', total.toLocaleString(), 'Announcements', 'Last ' + days + ' days'),
|
||
statCard('🏭', companies.toLocaleString(), 'Named Companies', 'Distinct organizations'),
|
||
statCard('⚡', totalMW > 0 ? totalMW.toLocaleString(undefined,{maximumFractionDigits:0}) + ' MW' : '—', 'Total Power (known)', 'Sum of MW for entries with scale data'),
|
||
statCard('📡', totalTx > 0 ? totalTx.toLocaleString() : '—', 'Est. Transceivers', withEst + ' entries with demand estimates'),
|
||
].join('');
|
||
}
|
||
|
||
function statCard(icon, value, label, sub) {
|
||
return '<div style="flex:1;min-width:130px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:0.85rem 1rem">'
|
||
+ '<div style="font-size:1.2rem">' + icon + '</div>'
|
||
+ '<div style="font-size:1.5rem;font-weight:800;color:var(--text);line-height:1.1;margin-top:0.3rem">' + value + '</div>'
|
||
+ '<div style="font-size:0.78rem;font-weight:700;color:var(--text);margin-top:0.2rem">' + label + '</div>'
|
||
+ '<div style="font-size:0.68rem;color:var(--text-dim)' + '">' + sub + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function filterAiClusters(minTx) {
|
||
procAiClustersMinTx = minTx;
|
||
var data = minTx > 0 ? procAiClustersData.filter(function(r) { return (r.estimated_transceivers || 0) >= minTx; }) : procAiClustersData;
|
||
if (!data.length) {
|
||
el('ai-cluster-grid').innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No matching announcements.</div>';
|
||
return;
|
||
}
|
||
el('ai-cluster-grid').innerHTML = data.map(function(item) {
|
||
var txCount = parseInt(item.estimated_transceivers);
|
||
var mw = parseFloat(item.scale_mw);
|
||
var hasTxEst = !isNaN(txCount) && txCount > 0;
|
||
var hasMw = !isNaN(mw) && mw > 0;
|
||
var dateStr = item.announced_date ? new Date(item.announced_date).toLocaleDateString('de-DE') : '';
|
||
var company = item.company && item.company !== 'Unknown' ? esc(item.company) : '';
|
||
var loc = item.location ? esc(item.location) : '';
|
||
var borderColor = hasTxEst ? '#7c5cfc' : (hasMw ? '#2563eb' : 'var(--border)');
|
||
var txHtml = hasTxEst
|
||
? '<div style="display:inline-flex;align-items:center;gap:0.3rem;background:rgba(124,92,252,0.1);border:1px solid rgba(124,92,252,0.3);border-radius:5px;padding:2px 8px;font-size:0.75rem;font-weight:700;color:#7c5cfc">📡 ~' + txCount.toLocaleString() + ' transceivers</div>'
|
||
: '';
|
||
var mwHtml = hasMw
|
||
? '<span style="background:rgba(37,99,235,0.1);border:1px solid rgba(37,99,235,0.3);border-radius:4px;padding:1px 6px;font-size:0.68rem;font-weight:700;color:#2563eb">⚡ ' + mw.toLocaleString() + ' MW</span>'
|
||
: '';
|
||
var speedHtml = item.network_speed
|
||
? '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:0.68rem;color:var(--text-dim)">' + esc(item.network_speed) + '</span>'
|
||
: '';
|
||
return '<div class="card" style="border-left:3px solid ' + borderColor + ';padding:0.9rem 1rem">'
|
||
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.5rem">'
|
||
+ '<div style="display:flex;flex-direction:column;gap:0.3rem">'
|
||
+ (txHtml ? txHtml + ' ' : '')
|
||
+ '<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.2rem">' + mwHtml + speedHtml + '</div>'
|
||
+ '</div>'
|
||
+ (dateStr ? '<span style="font-size:0.68rem;color:var(--text-dim);white-space:nowrap">' + dateStr + '</span>' : '')
|
||
+ '</div>'
|
||
+ '<div style="font-weight:700;font-size:0.85rem;line-height:1.4;margin-bottom:0.4rem">' + esc(item.title) + '</div>'
|
||
+ (item.summary ? '<div style="font-size:0.73rem;color:var(--text-dim);line-height:1.5;margin-bottom:0.5rem">' + esc(item.summary.substring(0,200)) + (item.summary.length > 200 ? '…' : '') + '</div>' : '')
|
||
+ '<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.68rem;color:var(--text-dim)">'
|
||
+ '<span>' + (company || '?') + (loc ? ' · ' + loc : '') + (item.source_name ? ' · ' + esc(item.source_name) : '') + '</span>'
|
||
+ (item.source_url ? '<a href="' + esc(item.source_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.68rem">→ Source</a>' : '')
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// ─── MARKET SIGNALS — HYPE CYCLE ENRICHMENT ──────────────────────────────────
|
||
|
||
function renderHypeMarketContext(ctx, technologies) {
|
||
var el2 = el('hype-market-context');
|
||
if (!el2) return;
|
||
var capexYoy = ctx.capexYoyAvg || 0;
|
||
var capexColor = capexYoy > 80 ? '#16a34a' : capexYoy > 40 ? '#ca8a04' : '#64748b';
|
||
var totalAiTx = ctx.totalAiClusterTx90d || 0;
|
||
var fastTrend = ctx.internalFastMoverTrend || 0;
|
||
|
||
// Summary stat cards
|
||
var cards = [
|
||
statCard('💰', capexYoy.toFixed(0) + '% YoY avg', 'Hyperscaler CapEx',
|
||
(ctx.capexBoom ? '🚀 Boom — above +50% threshold' : 'Moderate growth') + ' (' + (ctx.hyperscalerCapex || []).map(function(c) { return c.company; }).join(', ') + ')'),
|
||
statCard('🤖', totalAiTx > 0 ? '~' + (totalAiTx / 1000).toFixed(0) + 'k Tx' : '—', 'AI Cluster Demand (90d)',
|
||
ctx.aiClusterCount90d + ' cluster builds with transceiver demand estimates'),
|
||
statCard('📈', fastTrend > 0 ? '+' + fastTrend.toFixed(1) + '%' : fastTrend.toFixed(1) + '%', 'Internal Fast-Mover Trend',
|
||
'Average demand trend across ' + (ctx.hyperscalerCapex || []).length + ' fast-moving SKUs'),
|
||
];
|
||
|
||
// Per-technology recommendation summary
|
||
var recCards = (technologies || []).map(function(ms) {
|
||
if (!ms.recommendation) return '';
|
||
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.6rem 0.75rem;min-width:160px">'
|
||
+ '<div style="font-size:0.75rem;font-weight:700;color:var(--text)">' + esc(ms.technology) + '</div>'
|
||
+ '<div style="font-size:0.72rem;font-weight:700;color:' + ms.recommendation.color + ';margin-top:0.2rem">' + esc(ms.recommendation.label) + '</div>'
|
||
+ '<div style="font-size:0.65rem;color:var(--text-dim);margin-top:0.15rem;line-height:1.4">' + esc(ms.recommendation.detail.substring(0, 80)) + (ms.recommendation.detail.length > 80 ? '…' : '') + '</div>'
|
||
+ '<div style="margin-top:0.4rem;display:flex;align-items:center;gap:0.4rem">'
|
||
+ '<div style="flex:1;height:3px;background:var(--border);border-radius:2px"><div style="width:' + ms.marketSignalScore + '%;height:100%;background:' + ms.recommendation.color + ';border-radius:2px"></div></div>'
|
||
+ '<span style="font-size:0.65rem;font-family:monospace;font-weight:700;color:' + ms.recommendation.color + '">' + ms.marketSignalScore + '</span>'
|
||
+ '</div></div>';
|
||
}).join('');
|
||
|
||
el2.innerHTML = '<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem">' + cards.join('') + '</div>'
|
||
+ '<div style="font-size:0.72rem;font-weight:700;color:var(--text-dim);margin-bottom:0.5rem;letter-spacing:0.05em;text-transform:uppercase">Recommendations per Technology</div>'
|
||
+ '<div style="display:flex;gap:0.75rem;flex-wrap:wrap">' + recCards + '</div>';
|
||
}
|
||
|
||
// ─── CAPEX TABLE — in Hype Cycle tab ─────────────────────────────────────────
|
||
|
||
async function loadHypeCapexAndEbay() {
|
||
try {
|
||
var d = await api('/api/procurement/hyperscaler-capex');
|
||
renderCapexTable(d.data || [], d.summary || []);
|
||
} catch(e) {
|
||
var el2 = el('capex-table-container');
|
||
if (el2) el2.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">Could not load capex data.</div>';
|
||
}
|
||
try {
|
||
var e = await api('/api/procurement/marketplace-velocity');
|
||
renderEbayVelocity(e.hot || e.data || []);
|
||
renderProcMarketplace(e.hot || e.data || []);
|
||
} catch(e2) {}
|
||
}
|
||
|
||
function renderCapexTable(rows, summary) {
|
||
var badge = el('capex-avg-badge');
|
||
if (badge && summary.length) {
|
||
var avgYoy = summary.reduce(function(s, r) { return s + (parseFloat(r.latest_yoy_growth) || 0); }, 0) / summary.length;
|
||
badge.textContent = 'Avg YoY: +' + avgYoy.toFixed(0) + '% · ' + summary.length + ' hyperscalers';
|
||
badge.style.color = avgYoy > 80 ? '#16a34a' : avgYoy > 40 ? '#ca8a04' : '#2563eb';
|
||
badge.style.background = avgYoy > 80 ? '#16a34a11' : '#2563eb11';
|
||
badge.style.borderColor = avgYoy > 80 ? '#16a34a33' : '#2563eb33';
|
||
}
|
||
|
||
var container = el('capex-table-container');
|
||
if (!container || !rows.length) return;
|
||
|
||
var html = '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:0.8rem">'
|
||
+ '<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">'
|
||
+ '<th style="text-align:left;padding:8px 6px">Company</th>'
|
||
+ '<th style="text-align:left;padding:8px 6px">Period</th>'
|
||
+ '<th class="tip" data-tip="Total quarterly CapEx in millions USD (all infra)" style="text-align:right;padding:8px 6px">CapEx ($M)</th>'
|
||
+ '<th class="tip" data-tip="Estimated DC portion (≈55% of total CapEx based on analyst estimates)" style="text-align:right;padding:8px 6px">DC Est. ($M)</th>'
|
||
+ '<th class="tip" data-tip="Year-over-year growth of total CapEx vs same quarter prior year" style="text-align:right;padding:8px 6px">YoY %</th>'
|
||
+ '<th style="text-align:left;padding:8px 6px">Filing</th>'
|
||
+ '</tr></thead><tbody>';
|
||
|
||
rows.forEach(function(r) {
|
||
var yoy = parseFloat(r.yoy_growth_pct);
|
||
var yoyColor = yoy > 80 ? '#16a34a' : yoy > 40 ? '#ca8a04' : yoy > 0 ? '#2563eb' : '#ef4444';
|
||
var yoyStr = isNaN(yoy) ? '—' : (yoy > 0 ? '+' : '') + yoy.toFixed(1) + '%';
|
||
html += '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:7px 6px;font-weight:700;text-transform:capitalize">' + esc(r.company) + '</td>'
|
||
+ '<td style="padding:7px 6px;color:var(--text-dim)">' + esc(r.period_label) + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;font-weight:600">$' + parseFloat(r.capex_usd_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;color:var(--text-dim)">$' + parseFloat(r.dc_capex_est_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
|
||
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + yoyColor + '">' + yoyStr + '</td>'
|
||
+ '<td style="padding:7px 6px;font-size:0.68rem;color:var(--text-dim)">' + esc(r.filing_type || '—') + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
|
||
container.innerHTML = html + '</tbody></table></div>';
|
||
}
|
||
|
||
function renderEbayVelocity(rows) {
|
||
var container = el('ebay-velocity-container');
|
||
if (!container || !rows.length) {
|
||
if (container) container.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">No marketplace data.</div>';
|
||
return;
|
||
}
|
||
|
||
var html = '<div style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))">';
|
||
rows.forEach(function(r) {
|
||
var sold = parseInt(r.sold_count_30d) || 0;
|
||
var listings = parseInt(r.active_listings) || 0;
|
||
var sellThrough = listings > 0 ? ((sold / listings) * 100).toFixed(1) + '%' : '—';
|
||
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
|
||
var avgPrice = parseFloat(r.avg_sold_price);
|
||
html += '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.8rem">'
|
||
+ '<div style="font-size:0.82rem;font-weight:700">' + esc(r.form_factor || r.keyword || '?') + ' ' + esc(r.speed_label || '') + '</div>'
|
||
+ '<div style="display:flex;align-items:baseline;gap:0.4rem;margin-top:0.4rem">'
|
||
+ '<span style="font-size:1.5rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
|
||
+ '<span style="font-size:0.72rem;color:var(--text-dim)">sold / 30d</span>'
|
||
+ '</div>'
|
||
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem">'
|
||
+ listings.toLocaleString() + ' active listings · sell-through ' + sellThrough
|
||
+ '</div>'
|
||
+ (avgPrice > 0 ? '<div style="font-size:0.72rem;color:var(--text-dim)">Avg sold: $' + avgPrice.toLocaleString(undefined,{maximumFractionDigits:0}) + '</div>' : '')
|
||
+ '</div>';
|
||
});
|
||
container.innerHTML = html + '</div>';
|
||
}
|
||
|
||
// ─── PROC MARKETPLACE SECTION ─────────────────────────────────────────────────
|
||
|
||
async function loadProcMarketplace() {
|
||
var grid = el('proc-marketplace-grid');
|
||
try {
|
||
var d = await api('/api/procurement/marketplace-velocity');
|
||
renderProcMarketplace(d.hot || d.data || []);
|
||
} catch(e) {
|
||
if (grid) grid.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Could not load marketplace data.</div>';
|
||
}
|
||
}
|
||
|
||
function renderProcMarketplace(rows) {
|
||
var grid = el('proc-marketplace-grid');
|
||
if (!grid) return;
|
||
if (!rows.length) { grid.innerHTML = '<div style="color:var(--text-dim)">No data yet.</div>'; return; }
|
||
grid.innerHTML = rows.map(function(r) {
|
||
var sold = parseInt(r.sold_count_30d) || 0;
|
||
var listings = parseInt(r.active_listings) || 0;
|
||
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
|
||
var borderColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : 'var(--border)';
|
||
var avgP = parseFloat(r.avg_sold_price);
|
||
return '<div class="card" style="border-left:3px solid ' + borderColor + '">'
|
||
+ '<div style="font-weight:700;font-size:0.9rem">' + esc(r.form_factor || '?') + ' ' + esc(r.speed_label || '') + '</div>'
|
||
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">' + esc(r.keyword || '') + ' · ' + esc(r.marketplace || 'eBay') + '</div>'
|
||
+ '<div style="display:flex;align-items:baseline;gap:0.5rem">'
|
||
+ '<span style="font-size:1.8rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
|
||
+ '<span style="font-size:0.75rem;color:var(--text-dim)">sold / 30d</span>'
|
||
+ '</div>'
|
||
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem">'
|
||
+ listings.toLocaleString() + ' active listings'
|
||
+ (avgP > 0 ? ' · avg $' + avgP.toLocaleString(undefined,{maximumFractionDigits:0}) : '')
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// 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);
|
||
var localReady = !!(d.local && d.local.ready);
|
||
_updateLocalTrainButtons(localReady);
|
||
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: ' + (localReady ? '✓ konfiguriert' : '✗ kein TIP_LOCAL_TRAIN_COMMAND')
|
||
];
|
||
banner.innerHTML = '<strong>Status:</strong> ' + parts.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);
|
||
}
|
||
}
|
||
|
||
var _slLocalReady = false;
|
||
|
||
function _updateLocalTrainButtons(ready) {
|
||
_slLocalReady = ready;
|
||
['tip', 'blog'].forEach(function(lane) {
|
||
var btn = el('sl-local-btn-' + lane);
|
||
if (!btn) return;
|
||
if (ready) {
|
||
btn.disabled = false;
|
||
btn.style.cursor = 'pointer';
|
||
btn.style.opacity = '1';
|
||
btn.style.color = 'var(--text)';
|
||
btn.title = 'Lokales Training starten';
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.style.cursor = 'not-allowed';
|
||
btn.style.opacity = '0.45';
|
||
btn.style.color = 'var(--text-dim)';
|
||
btn.title = 'Nicht konfiguriert — setze TIP_LOCAL_TRAIN_COMMAND in ecosystem.config.js';
|
||
}
|
||
});
|
||
}
|
||
|
||
async function startSelflearningTrain(lane, provider, seedOnly) {
|
||
if (provider === 'local' && !_slLocalReady) {
|
||
setSelflearningLog(
|
||
'Lokales Training ist nicht konfiguriert.\n\n' +
|
||
'Setze TIP_LOCAL_TRAIN_COMMAND in /opt/tip/ecosystem.config.js:\n' +
|
||
' TIP_LOCAL_TRAIN_COMMAND: \'/opt/tip/scripts/local-train.sh\'\n\n' +
|
||
'Das Script wird aufgerufen als:\n' +
|
||
' /opt/tip/scripts/local-train.sh tip_llm\n' +
|
||
' /opt/tip/scripts/local-train.sh blog_llm'
|
||
);
|
||
showToast('Local Train nicht aktiv', 'TIP_LOCAL_TRAIN_COMMAND ist nicht konfiguriert.', true);
|
||
return;
|
||
}
|
||
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
|
||
loadDataQuality(); // load verification evidence quality panel 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.';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── Data Quality (Verification Evidence) ──────────────────────────────── */
|
||
async function loadDataQuality() {
|
||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||
var el = document.getElementById('cr-data-quality');
|
||
if (!el) return;
|
||
try {
|
||
var r = await fetch('/api/scrapers/data-quality', { headers: { 'Authorization': 'Bearer ' + token } });
|
||
var d = await r.json();
|
||
if (!d.success) throw new Error(d.error || 'API error');
|
||
el.innerHTML = renderDataQuality(d);
|
||
} catch(e) {
|
||
el.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">Error loading data quality: ' + esc(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function renderDataQuality(d) {
|
||
var cov = d.coverage || {};
|
||
var total = cov.total || 1;
|
||
|
||
// Coverage bars
|
||
var bars = [
|
||
{ label: 'Details / Spec', key: 'detailsPct', count: cov.details, pct: cov.detailsPct, color: '#6366f1' },
|
||
{ label: 'Image', key: 'imagePct', count: cov.image, pct: cov.imagePct, color: '#3b82f6' },
|
||
{ label: 'Price', key: 'pricePct', count: cov.price, pct: cov.pricePct, color: '#22c55e' },
|
||
{ label: 'Competitor Match', key: 'competitorPct', count: cov.competitor, pct: cov.competitorPct, color: '#f59e0b' },
|
||
];
|
||
|
||
var coverageHtml = '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1.5rem">'
|
||
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">'
|
||
+ '<span style="font-size:0.82rem;font-weight:700;color:var(--text-bright)">Coverage Overview</span>'
|
||
+ '<span style="font-size:0.72rem;color:var(--text-dim)">' + (total).toLocaleString() + ' total transceivers'
|
||
+ (cov.quarantined ? ' · <span style="color:#f59e0b">' + cov.quarantined.toLocaleString() + ' quarantined</span>' : '')
|
||
+ '</span>'
|
||
+ '</div>'
|
||
+ bars.map(function(b) {
|
||
var pct = b.pct || 0;
|
||
var bgColor = pct >= 80 ? 'rgba(34,197,94,0.08)' : pct >= 50 ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)';
|
||
return '<div style="margin-bottom:0.75rem;background:' + bgColor + ';border-radius:6px;padding:0.6rem 0.75rem">'
|
||
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem">'
|
||
+ '<span style="font-size:0.78rem;font-weight:600;color:var(--text-bright)">' + esc(b.label) + '</span>'
|
||
+ '<span style="font-size:0.72rem;color:var(--text-dim)">'
|
||
+ (b.count || 0).toLocaleString() + ' / ' + total.toLocaleString()
|
||
+ ' · <span style="color:' + b.color + ';font-weight:700">' + pct + '%</span>'
|
||
+ '</span>'
|
||
+ '</div>'
|
||
+ '<div style="width:100%;height:6px;background:var(--border);border-radius:3px;overflow:hidden">'
|
||
+ '<div style="width:' + Math.min(pct, 100) + '%;height:100%;background:' + b.color + ';border-radius:3px;transition:width 0.6s ease"></div>'
|
||
+ '</div></div>';
|
||
}).join('')
|
||
+ '</div>';
|
||
|
||
// Evidence type table
|
||
var typeIcons = {
|
||
price: '💶', price_unavailable: '💶❌', image: '🖼', image_unavailable: '🖼❌',
|
||
details: '📋', details_unavailable: '📋❌', competitor_match: '✅',
|
||
competitor_no_match: '❌', competitor_ambiguous: '⚠️', artifact_quarantine: '🚫'
|
||
};
|
||
var types = d.evidenceTypes || [];
|
||
var evidenceHtml = '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1.5rem">'
|
||
+ '<div style="font-size:0.82rem;font-weight:700;color:var(--text-bright);margin-bottom:1rem">Evidence Type Breakdown</div>'
|
||
+ '<table style="width:100%;border-collapse:collapse;font-size:0.75rem">'
|
||
+ '<thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim);font-weight:600">Type</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Evidence</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Products</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Avg Conf</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Last Seen</th>'
|
||
+ '</tr></thead><tbody>'
|
||
+ types.map(function(t, i) {
|
||
var icon = typeIcons[t.verification_type] || '●';
|
||
var conf = t.avg_confidence != null ? Math.round(Number(t.avg_confidence) * 100) + '%' : '—';
|
||
var confColor = t.avg_confidence >= 0.95 ? '#22c55e' : t.avg_confidence >= 0.8 ? '#f59e0b' : '#ef4444';
|
||
var last = t.last_seen ? new Date(t.last_seen).toLocaleDateString('de-DE') : '—';
|
||
var stripe = i % 2 === 1 ? 'background:var(--surface2)' : '';
|
||
return '<tr style="border-bottom:1px solid var(--border);' + stripe + '">'
|
||
+ '<td style="padding:0.4rem 0.5rem;color:var(--text-bright)">' + icon + ' <span style="font-weight:500">' + esc(t.verification_type.replace(/_/g,' ')) + '</span></td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-family:monospace">' + (t.cnt||0).toLocaleString() + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">' + (t.distinct_tx||0).toLocaleString() + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:' + confColor + ';font-weight:700">' + conf + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">' + last + '</td>'
|
||
+ '</tr>';
|
||
}).join('')
|
||
+ '</tbody></table></div>';
|
||
|
||
// Daily activity sparkline
|
||
var days = (d.dailyActivity || []).slice().reverse(); // oldest first
|
||
var maxActivity = Math.max.apply(null, days.map(function(x) { return x.evidence_added || 0; })) || 1;
|
||
var sparkH = 50;
|
||
var sparkW = Math.max(days.length * 22, 200);
|
||
var sparkBars = days.map(function(x, i) {
|
||
var h = Math.max(2, Math.round((x.evidence_added / maxActivity) * sparkH));
|
||
var dateStr = x.day;
|
||
var label = x.evidence_added.toLocaleString() + ' evidence\n' + (x.transceivers_processed||0).toLocaleString() + ' products\n' + dateStr;
|
||
var barColor = x.evidence_added > 10000 ? '#6366f1' : x.evidence_added > 1000 ? '#3b82f6' : x.evidence_added > 100 ? '#22c55e' : '#64748b';
|
||
return '<rect class="tip" data-tip="' + esc(label) + '" x="' + (i*22+1) + '" y="' + (sparkH - h) + '" width="18" height="' + h + '" rx="3" fill="' + barColor + '" />';
|
||
}).join('');
|
||
var sparkSvg = '<svg width="' + sparkW + '" height="' + sparkH + '" style="overflow:visible">' + sparkBars + '</svg>';
|
||
|
||
var activityHtml = '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1.5rem">'
|
||
+ '<div style="font-size:0.82rem;font-weight:700;color:var(--text-bright);margin-bottom:0.75rem">Daily Activity (last 14 days)</div>'
|
||
+ '<div style="overflow-x:auto;padding-bottom:0.5rem">' + sparkSvg + '</div>'
|
||
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.4rem">Hover bars for details. Purple = >10k, Blue = >1k, Green = >100, Gray = low activity.</div>'
|
||
+ '</div>';
|
||
|
||
// Robot table
|
||
var robots = (d.robotActivity || []);
|
||
var robotHtml = '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1.25rem">'
|
||
+ '<div style="font-size:0.82rem;font-weight:700;color:var(--text-bright);margin-bottom:1rem">Scraper / Robot Contributions</div>'
|
||
+ '<table style="width:100%;border-collapse:collapse;font-size:0.72rem">'
|
||
+ '<thead><tr style="background:var(--surface2)">'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:left;color:var(--text-dim);font-weight:600">Robot</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Evidence</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Products</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Types</th>'
|
||
+ '<th style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim);font-weight:600">Last Run</th>'
|
||
+ '</tr></thead><tbody>'
|
||
+ robots.map(function(r, i) {
|
||
var stripe = i % 2 === 1 ? 'background:rgba(255,255,255,0.02)' : '';
|
||
var isActive = r.last_run === new Date().toISOString().slice(0,10);
|
||
var dotColor = isActive ? '#22c55e' : '#64748b';
|
||
return '<tr style="border-bottom:1px solid var(--border);' + stripe + '">'
|
||
+ '<td style="padding:0.4rem 0.5rem;color:var(--text-bright);font-family:monospace;font-size:0.68rem">'
|
||
+ '<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:' + dotColor + ';margin-right:5px;vertical-align:middle"></span>'
|
||
+ esc(r.robot_name) + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700">' + (r.total_evidence||0).toLocaleString() + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">' + (r.transceivers_covered||0).toLocaleString() + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--text-dim)">' + (r.types_covered||0) + '</td>'
|
||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:' + (isActive ? '#22c55e' : 'var(--text-dim)') + '">' + esc(r.last_run || '—') + '</td>'
|
||
+ '</tr>';
|
||
}).join('')
|
||
+ '</tbody></table></div>';
|
||
|
||
return coverageHtml + '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem">'
|
||
+ '<div>' + evidenceHtml + activityHtml + '</div>'
|
||
+ '<div>' + robotHtml + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
/* ── 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)">●</span>'
|
||
: '<span style="color:var(--text-dim)">○</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() + '</td>'
|
||
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : '—') + '</td>'
|
||
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : '—') + '</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 (10–99)', slow_mover: '🐢 Slow (1–9)', 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) {
|
||
console.error('Demand load error:', demandErr);
|
||
}
|
||
|
||
// ── Stock × Demand Combined Analysis ─────────────────────────────────────
|
||
try {
|
||
var stockAnalysis = await api('/api/internal/demand/stock-analysis').catch(function() { return null; });
|
||
|
||
if (stockAnalysis && stockAnalysis.success) {
|
||
var sa = stockAnalysis.summary;
|
||
|
||
// Summary chips
|
||
var chips = document.getElementById('stock-analysis-chips');
|
||
if (chips && sa) {
|
||
var urgColors = { critical: '#ef4444', low: '#f59e0b', ok: '#22c55e', overstocked: '#06b6d4' };
|
||
chips.innerHTML =
|
||
'<span style="background:#ef444422;color:#ef4444;border:1px solid #ef444444;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">🚨 Kritisch: ' + sa.reorder_critical + '</span>'
|
||
+ '<span style="background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b44;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">⚠ Niedrig: ' + sa.reorder_low + '</span>'
|
||
+ '<span style="background:#22c55e22;color:#22c55e;border:1px solid #22c55e44;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">✅ OK: ' + sa.reorder_ok + '</span>'
|
||
+ '<span style="background:#06b6d422;color:#06b6d4;border:1px solid #06b6d444;border-radius:20px;padding:3px 12px;font-size:0.72rem;font-weight:700">📦 Überbestand: ' + sa.overstocked + '</span>'
|
||
+ '<span style="margin-left:auto;color:var(--text-dim);font-size:0.68rem">' + sa.total_active_skus + ' aktive SKUs · ' + sa.with_de_stock + ' mit DE-Lager</span>';
|
||
}
|
||
|
||
// Table
|
||
var sabody = document.getElementById('stock-analysis-body');
|
||
if (sabody && stockAnalysis.data && stockAnalysis.data.length > 0) {
|
||
var urgencyLabel = {
|
||
critical: '<span style="background:#ef444422;color:#ef4444;border:1px solid #ef444440;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">🚨 KRITISCH</span>',
|
||
low: '<span style="background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b40;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">⚠ NIEDRIG</span>',
|
||
ok: '<span style="background:#22c55e22;color:#22c55e;border:1px solid #22c55e40;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">✅ OK</span>',
|
||
overstocked: '<span style="background:#06b6d422;color:#06b6d4;border:1px solid #06b6d440;border-radius:10px;padding:1px 8px;font-size:0.65rem;font-weight:700">📦 ÜBERBESTAND</span>',
|
||
no_demand: '<span style="color:var(--text-dim);font-size:0.65rem">—</span>'
|
||
};
|
||
sabody.innerHTML = stockAnalysis.data.slice(0, 60).map(function(r) {
|
||
var mom = Number(r.momentum_ratio || 1);
|
||
var momPct = Math.round((mom - 1) * 100);
|
||
var trendColor = mom >= 1.05 ? '#22c55e' : mom >= 0.95 ? '#f59e0b' : '#ef4444';
|
||
var trendStr = (momPct >= 0 ? '+' : '') + momPct + '% ' + (mom >= 1.05 ? '▲' : mom >= 0.95 ? '→' : '▼');
|
||
var deQty = Number(r.webshop_de_qty || 0);
|
||
var covDays = r.coverage_days_de != null ? Number(r.coverage_days_de) + 'd' : '—';
|
||
var covColor = r.coverage_days_de == null ? 'var(--text-dim)'
|
||
: r.coverage_days_de < 14 ? '#ef4444'
|
||
: r.coverage_days_de < 30 ? '#f59e0b'
|
||
: '#22c55e';
|
||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:4px 8px;font-family:monospace;font-size:0.72rem;color:var(--text-bright)">' + esc(r.part_number) + '</td>'
|
||
+ '<td style="padding:4px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:right;color:#6366f1;font-weight:600">' + Number(r.demand_12m || 0).toFixed(0) + '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:right;color:#06b6d4">' + Number(r.demand_3m || 0).toFixed(0) + '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:right;color:' + (deQty > 0 ? '#22c55e' : '#ef4444') + ';font-weight:' + (deQty > 0 ? '600' : '400') + '">'
|
||
+ (deQty > 0 ? deQty.toLocaleString() : '—')
|
||
+ '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:right;color:' + covColor + ';font-weight:600">' + covDays + '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:center">' + (urgencyLabel[r.reorder_urgency] || '—') + '</td>'
|
||
+ '<td style="padding:4px 8px;text-align:right;color:' + trendColor + ';font-size:0.7rem">' + trendStr + '</td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
} else if (sabody) {
|
||
sabody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:2rem;color:var(--text-dim);font-size:0.72rem">Noch keine Stock-Daten von Flexoptix — Scraper ausführen um Lagermengen zu laden</td></tr>';
|
||
}
|
||
}
|
||
} catch(saErr) {
|
||
console.error('Stock analysis error:', saErr);
|
||
}
|
||
} 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="color:#06b6d4">Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + '</span> · '
|
||
+ '<span style="color:#f59e0b">Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + '</span> · '
|
||
+ (latest.price_net != null ? '€' + Number(latest.price_net).toFixed(2) + ' (net)' : '')
|
||
+ (latest.units_sold != null ? ' · <b>' + latest.units_sold + '×</b> verkauft' : '')
|
||
+ '<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>'
|
||
+ ' · ' + esc(tx.form_factor || '') + ' ' + esc(tx.speed || '')
|
||
+ (tx.fiber_type ? ' · ' + esc(tx.fiber_type) : '')
|
||
+ (tx.reach_label ? ' · ' + 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;
|
||
}
|
||
}
|
||
|
||
// ─── EQUIVALENCES TAB ────────────────────────────────────────────────────────
|
||
|
||
var _equivDebounceTimer = null;
|
||
function debounceEquiv() {
|
||
clearTimeout(_equivDebounceTimer);
|
||
_equivDebounceTimer = setTimeout(searchEquivalences, 420);
|
||
}
|
||
|
||
async function loadEquivStats() {
|
||
try {
|
||
var s = await api('/api/equivalences/stats');
|
||
var st = s.stats || {};
|
||
el('equiv-stats').textContent =
|
||
(st.active || 0).toLocaleString() + ' equivalences · '
|
||
+ (st.unique_competitor_products || 0).toLocaleString() + ' competitor products · '
|
||
+ (st.unique_flexoptix_products || 0).toLocaleString() + ' Flexoptix alternatives · '
|
||
+ 'Ø ' + (parseFloat(st.avg_confidence || 0) * 100).toFixed(1) + '% confidence';
|
||
|
||
var tv = await api('/api/equivalences/top-vendors');
|
||
var vendors = (tv.data || []).slice(0, 10);
|
||
var chips = vendors.map(function(v) {
|
||
return '<span onclick="el(\'equiv-vendor\').value=\'' + esc(v.vendor) + '\';searchEquivalences()" '
|
||
+ 'style="cursor:pointer;padding:3px 9px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);'
|
||
+ 'font-size:0.72rem;white-space:nowrap" title="' + v.equiv_count.toLocaleString() + ' equivalences, ' + v.products_covered + ' products">'
|
||
+ esc(v.vendor) + ' <span style="color:var(--text-dim)">' + v.equiv_count.toLocaleString() + '</span></span>';
|
||
}).join('');
|
||
el('equiv-top-vendors').innerHTML = chips;
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function searchEquivalences() {
|
||
var q = (el('equiv-q').value || '').trim();
|
||
var vendor = (el('equiv-vendor').value || '').trim();
|
||
var resultEl = el('equiv-results');
|
||
if (!q && !vendor) {
|
||
resultEl.innerHTML = '<span style="color:var(--text-dim)">Enter a part number to find Flexoptix equivalents or competitor matches.</span>';
|
||
return;
|
||
}
|
||
|
||
resultEl.innerHTML = '<span class="loading pulse">Searching…</span>';
|
||
try {
|
||
var params = new URLSearchParams({ limit: '50' });
|
||
if (q) params.set('q', q);
|
||
if (vendor) params.set('vendor', vendor);
|
||
var data = await api('/api/equivalences?' + params.toString());
|
||
var rows = data.data || [];
|
||
|
||
if (!rows.length) {
|
||
resultEl.innerHTML = '<span style="color:var(--text-dim)">No equivalences found for "' + esc(q) + '"' + (vendor ? ' (vendor: ' + esc(vendor) + ')' : '') + '.</span>';
|
||
return;
|
||
}
|
||
|
||
var vendorColors = {'approved':'#10b981','auto_approved':'#3b82f6'};
|
||
var html = '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.6rem">'
|
||
+ rows.length + ' result(s) — showing up to 50</div>';
|
||
html += '<table style="width:100%;border-collapse:collapse;font-size:0.78rem">';
|
||
html += '<thead><tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Competitor PN</th>'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Vendor</th>'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Flexoptix Alternative</th>'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Specs</th>'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Conf.</th>'
|
||
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Price</th>'
|
||
+ '</tr></thead><tbody>';
|
||
|
||
rows.forEach(function(r) {
|
||
var conf = Math.round(parseFloat(r.confidence) * 100);
|
||
var confColor = conf >= 90 ? '#10b981' : conf >= 75 ? '#f59e0b' : '#f97316';
|
||
var fxPrice = r.flexoptix_price_eur ? '€' + parseFloat(r.flexoptix_price_eur).toFixed(0) : '—';
|
||
var fxSpec = [r.flexoptix_form_factor, r.flexoptix_speed, r.flexoptix_reach].filter(Boolean).join(' · ');
|
||
var fxUrl = r.flexoptix_url ? ' onclick="window.open(\'' + esc(r.flexoptix_url) + '\',\'_blank\')" style="cursor:pointer;color:var(--accent)"' : '';
|
||
var cpUrl = r.competitor_url ? ' onclick="window.open(\'' + esc(r.competitor_url) + '\',\'_blank\')" style="cursor:pointer"' : '';
|
||
var statusColor = vendorColors[r.status] || '#666';
|
||
html += '<tr style="border-bottom:1px solid var(--border);transition:background 0.1s" onmouseenter="this.style.background=\'var(--surface2)\'" onmouseleave="this.style.background=\'\'">'
|
||
+ '<td style="padding:0.35rem 0.4rem;font-weight:600"' + cpUrl + '>' + esc(r.competitor_pn || r.competitor_std || '—') + '</td>'
|
||
+ '<td style="padding:0.35rem 0.4rem"><span class="b b-blue" style="font-size:0.68rem">' + esc(r.competitor_vendor) + '</span></td>'
|
||
+ '<td style="padding:0.35rem 0.4rem;color:var(--accent);font-weight:600"' + fxUrl + '>' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '</td>'
|
||
+ '<td style="padding:0.35rem 0.4rem;color:var(--text-dim);font-size:0.72rem">' + esc(fxSpec) + '</td>'
|
||
+ '<td style="padding:0.35rem 0.4rem;font-weight:700;color:' + confColor + '">' + conf + '%</td>'
|
||
+ '<td style="padding:0.35rem 0.4rem">' + fxPrice + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
resultEl.innerHTML = html;
|
||
} catch(e) {
|
||
resultEl.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
// ─── LINKEDIN DISTRIBUTION STATUS ────────────────────────────────────────────
|
||
|
||
async function loadLinkedinHistory() {
|
||
var histEl = el('linkedin-history');
|
||
var statsEl = el('linkedin-stats');
|
||
var badgeEl = el('linkedin-dry-run-badge');
|
||
if (!histEl) return;
|
||
try {
|
||
var data = await api('/api/blog/linkedin/history');
|
||
if (badgeEl) {
|
||
badgeEl.textContent = data.dry_run ? 'DRY RUN' : '🟢 LIVE';
|
||
badgeEl.style.background = data.dry_run ? '#f97316' : '#10b981';
|
||
}
|
||
var st = data.stats || {};
|
||
if (statsEl) {
|
||
statsEl.innerHTML = [
|
||
'<span>✅ Posted: <strong>' + (st.posted || 0) + '</strong></span>',
|
||
'<span>🧪 Dry-run: <strong>' + (st.dry_run || 0) + '</strong></span>',
|
||
'<span>⏭ Skipped: <strong>' + (st.skipped || 0) + '</strong></span>',
|
||
'<span>❌ Failed: <strong>' + (st.failed || 0) + '</strong></span>',
|
||
].join('');
|
||
}
|
||
var rows = data.history || [];
|
||
if (!rows.length) { histEl.textContent = 'No distribution history yet.'; return; }
|
||
var html = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem">';
|
||
rows.forEach(function(r) {
|
||
var stateColor = r.state === 'posted' ? '#10b981' : r.state === 'dry_run' ? '#f59e0b' : r.state === 'failed' ? '#ef4444' : '#6b7280';
|
||
var date = r.posted_at ? new Date(r.posted_at).toLocaleDateString('de-DE') : (r.created_at ? new Date(r.created_at).toLocaleDateString('de-DE') : '—');
|
||
var postLink = r.linkedin_urn ? ' <a href="https://www.linkedin.com/feed/update/' + esc(r.linkedin_urn) + '" target="_blank" style="color:var(--accent);font-size:0.68rem">View</a>' : '';
|
||
html += '<tr style="border-bottom:1px solid var(--border)">'
|
||
+ '<td style="padding:0.3rem 0.4rem"><span style="font-size:0.7rem;padding:1px 7px;border-radius:8px;background:' + stateColor + '22;color:' + stateColor + ';font-weight:600">' + esc(r.state) + '</span></td>'
|
||
+ '<td style="padding:0.3rem 0.4rem;font-weight:500">' + esc(r.title || r.ghost_slug || '—') + postLink + '</td>'
|
||
+ '<td style="padding:0.3rem 0.4rem;color:var(--text-dim)">' + date + '</td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</table>';
|
||
if (data.dry_run) {
|
||
html += '<div style="margin-top:0.6rem;font-size:0.72rem;color:#f59e0b;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:5px;padding:0.4rem 0.7rem">'
|
||
+ '⚠ DRY RUN mode — no real LinkedIn posts are being made. To go live, set DRY_RUN=false in /opt/linkedin-distributor/ecosystem.config.cjs and restart the PM2 process.</div>';
|
||
}
|
||
histEl.innerHTML = html;
|
||
} catch(e) {
|
||
if (histEl) histEl.textContent = 'Error loading LinkedIn history: ' + e.message;
|
||
}
|
||
}
|
||
|
||
// ─── Tab init for new tabs ────────────────────────────────────────────────────
|
||
var _equivTabLoaded = false;
|
||
var _linkedinTabLoaded = false;
|
||
document.querySelectorAll('.tab').forEach(function(tab) {
|
||
tab.addEventListener('click', function() {
|
||
var t = this.getAttribute('data-tab');
|
||
if (t === 'equivalences' && !_equivTabLoaded) {
|
||
_equivTabLoaded = true;
|
||
loadEquivStats();
|
||
}
|
||
if (t === 'blog' && !_linkedinTabLoaded) {
|
||
_linkedinTabLoaded = true;
|
||
loadLinkedinHistory();
|
||
}
|
||
});
|
||
});
|
||
</script>
|
||
<script src="/dashboard/hot-topics.js"></script>
|
||
</body>
|
||
</html>
|