๐ Lookup Stock by Part Number
@@ -2212,6 +2251,26 @@ async function loadOverview() {
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 '
'
+ + '
' + si.icon + '
'
+ + '
' + si.val + '
'
+ + '
' + si.label + '
'
+ + '
';
+ }).join(''));
+ }
animateValue(el('ov-transceivers'), h.database.stats.transceiver_count, 1000);
animateValue(el('ov-vendors'), h.database.stats.vendor_count, 800);
animateValue(el('ov-switches'), h.database.stats.switch_count, 600);
@@ -6313,34 +6372,54 @@ async function runEquivalenceMatcher() {
var stockLoaded = false;
async function loadStock() {
- if (stockLoaded) return; // already loaded โ use Refresh button to force reload
- stockLoaded = false; // allow reloads via Refresh button
+ 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;
- // Stat cards
+ // โโ 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 '
๐ข L3';
+ if (c >= 1.5) return '
๐ก L2';
+ return '
โช L1';
+ }
+
+ /** 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
+ // โโ 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
+ ? '
' + esc(r.part_number) + ''
+ : '
' + esc(r.part_number) + '';
return '
'
- + '| ' + esc(r.part_number) + ' | '
+ + '' + pn + ' | '
+ '' + esc(r.form_factor || 'โ') + ' | '
+ '' + Number(r.units_sold || 0).toLocaleString() + ' | '
+ '' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : 'โ') + ' | '
+ '' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : 'โ') + ' | '
- + '' + (r.price_net != null ? 'โฌ' + Number(r.price_net).toFixed(2) : 'โ') + ' | '
+ + '' + fmtPrice(r.price_net, r.price_currency) + ' | '
+ '
';
}).join('');
} else {
@@ -6348,7 +6427,7 @@ async function loadStock() {
}
}
- // Vendor breakdown
+ // โโ Vendor breakdown (with confidence badge) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
var vbody = el('stock-vendor-body');
if (vbody) {
if (d.vendor_breakdown && d.vendor_breakdown.length > 0) {
@@ -6361,15 +6440,16 @@ async function loadStock() {
+ '
' + Number(r.total_de_qty || 0).toLocaleString() + ' | '
+ '
' + Number(r.total_global_qty || 0).toLocaleString() + ' | '
+ '
' + Number(r.total_backorder || 0).toLocaleString() + ' | '
+ + '
' + confBadge(r.avg_confidence) + ' | '
+ '
' + lastScraped + ' | '
+ '';
}).join('');
} else {
- vbody.innerHTML = '
| No data yet |
';
+ vbody.innerHTML = '
| No data yet |
';
}
}
- // Recently restocked
+ // โโ Recently restocked โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
var recentEl = el('stock-recent');
if (recentEl) {
if (d.recently_updated && d.recently_updated.length > 0) {
@@ -6390,6 +6470,33 @@ async function loadStock() {
}
}
+ // โโ 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
+ ? '
(ฮ' + (Number(r.price_max) - Number(r.price_min)).toFixed(2) + ')'
+ : '';
+ 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 '
' + esc(vn) + (p ? ' ' + p + '' : '') + '';
+ }).join(' ');
+ return '
'
+ + '| ' + esc(r.part_number) + ' | '
+ + '' + esc(r.form_factor || 'โ') + ' | '
+ + '' + (r.vendor_count || 'โ') + ' | '
+ + '' + fmtPrice(r.price_min, r.currencies && r.currencies[0]) + ' | '
+ + '' + fmtPrice(r.price_max, r.currencies && r.currencies[0]) + spread + ' | '
+ + '' + fmtPrice(r.price_avg, r.currencies && r.currencies[0]) + ' | '
+ + '' + vendorList + ' | '
+ + '
';
+ }).join('');
+ } else {
+ pcbody.innerHTML = '
| No multi-vendor data yet |
';
+ }
+ }
+
stockLoaded = true;
} catch(e) {
console.error('loadStock error', e);
diff --git a/packages/scraper/src/scrapers/cisco-tmg.ts b/packages/scraper/src/scrapers/cisco-tmg.ts
index dc1c5e1..e0d71bb 100644
--- a/packages/scraper/src/scrapers/cisco-tmg.ts
+++ b/packages/scraper/src/scrapers/cisco-tmg.ts
@@ -49,22 +49,33 @@ interface TmgSearchResponse {
/** Key Nexus/Catalyst platform family IDs from the TMG API */
const PLATFORM_FAMILIES = [
- { id: 74, name: "N9300" }, // Nexus 9300 โ 8,515 entries
- { id: 77, name: "N9500" }, // Nexus 9500 โ 2,266 entries
- { id: 78, name: "N9200" }, // Nexus 9200 โ 708 entries
- { id: 661, name: "N9800" }, // Nexus 9800 โ 238 entries
- { id: 76, name: "C9300" }, // Catalyst 9300 โ 260 entries
- { id: 601, name: "C9300L" }, // Catalyst 9300L โ 720 entries
- { id: 1181, name: "C9300X" }, // Catalyst 9300X โ 413 entries
- { id: 8, name: "C9500" }, // Catalyst 9500 โ 1,141 entries
- { id: 521, name: "C9600" }, // Catalyst 9600 โ 771 entries
- { id: 7, name: "C9400" }, // Catalyst 9400 โ 561 entries
- { id: 341, name: "C9200" }, // Catalyst 9200 โ 222 entries
- { id: 83, name: "ASR9000" }, // ASR 9000 โ 3,644 entries
+ // โโ Nexus Data Center โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ { id: 74, name: "N9300" }, // Nexus 9300 โ 8,515 entries
+ { id: 77, name: "N9500" }, // Nexus 9500 โ 2,266 entries
+ { id: 78, name: "N9200" }, // Nexus 9200 โ 708 entries
+ { id: 661, name: "N9800" }, // Nexus 9800 โ 238 entries
+ // โโ Catalyst Campus โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ { id: 76, name: "C9300" }, // Catalyst 9300 โ 260 entries
+ { id: 601, name: "C9300L" }, // Catalyst 9300L โ 720 entries
+ { id: 1181, name: "C9300X" }, // Catalyst 9300X โ 413 entries
+ { id: 8, name: "C9500" }, // Catalyst 9500 โ 1,141 entries
+ { id: 521, name: "C9600" }, // Catalyst 9600 โ 771 entries
+ { id: 7, name: "C9400" }, // Catalyst 9400 โ 561 entries
+ { id: 341, name: "C9200" }, // Catalyst 9200 โ 222 entries
+ // โโ Service Provider / High-Capacity โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ { id: 83, name: "ASR9000" }, // ASR 9000 โ 3,644 entries
+ { id: 1021, name: "8000" }, // Cisco 8000 Series โ 1,954 entries
+ { id: 20, name: "NCS5500" }, // NCS 5500 โ 3,843 entries
+ { id: 121, name: "NCS540" }, // NCS 540 โ 2,684 entries
+ { id: 141, name: "NCS560" }, // NCS 560 โ 229 entries
+ { id: 17, name: "NCS1000" }, // NCS 1000 (optical) โ 325 entries
];
-async function searchTmg(familyFilter: { id: number; name: string }): Promise
{
- const body = {
+function buildTmgBody(
+ familyFilter?: { id: number; name: string },
+ deviceIdFilter?: { id: number; name: string }
+): object {
+ return {
cableType: [],
dataRate: [],
formFactor: [],
@@ -73,14 +84,16 @@ async function searchTmg(familyFilter: { id: number; name: string }): Promise {
const res = await fetch(TMG_API, {
method: "POST",
headers: {
@@ -88,7 +101,28 @@ async function searchTmg(familyFilter: { id: number; name: string }): Promise;
+}
+
+/** Search for a specific switch by its TMG Product ID to get full compat data */
+async function searchTmgByDeviceId(deviceId: { id: number; name: string }): Promise {
+ const res = await fetch(TMG_API, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(buildTmgBody(undefined, deviceId)),
+ signal: AbortSignal.timeout(30000),
});
if (!res.ok) {
@@ -149,51 +183,66 @@ export async function scrapeCiscoTmg(): Promise {
let totalCompat = 0;
let totalTransceivers = 0;
+ /** Process one networkDevice compat response โ writes switches+compat to DB */
+ async function processDevices(
+ devices: TmgDevice[],
+ familyName: string
+ ): Promise<{ switches: number; transceivers: number; compat: number }> {
+ let sw = 0; let tx = 0; let cp = 0;
+ for (const device of devices) {
+ for (const compat of device.networkAndTransceiverCompatibility) {
+ if (!compat.productId) continue;
+ const switchId = await upsertCiscoSwitch(ciscoVendorId, compat.productId, familyName);
+ sw++;
+ for (const t of compat.transceivers) {
+ if (!t.productId) continue;
+ tx++;
+ const txResult = await pool.query(
+ `SELECT id FROM transceivers WHERE part_number = $1 OR part_number = $2 LIMIT 1`,
+ [t.productId, t.productId.replace(/-S$/, "")]
+ );
+ if (txResult.rows.length > 0) {
+ await upsertCompatibility(switchId, txResult.rows[0].id, t.softReleaseMinVer, t.formFactor, t.reach, t.cableType, t.media, t.dataRate);
+ cp++;
+ }
+ }
+ }
+ }
+ return { switches: sw, transceivers: tx, compat: cp };
+ }
+
for (const family of PLATFORM_FAMILIES) {
console.log(`\nFetching ${family.name}...`);
try {
- const data = await searchTmg(family);
- console.log(` ${family.name}: ${data.totalCount} total entries, ${data.networkDevices.length} device groups`);
+ // Step 1: Fetch family-level response to get all device IDs in the filter
+ const familyData = await searchTmg(family);
+ const deviceIdFilter = familyData.filters
+ ?.find((f) => f.name === "Network Device Product ID")
+ ?.values ?? [];
- for (const device of data.networkDevices) {
- for (const compat of device.networkAndTransceiverCompatibility) {
- if (!compat.productId) continue;
+ console.log(` ${family.name}: ${deviceIdFilter.length} switch models`);
- const switchId = await upsertCiscoSwitch(
- ciscoVendorId,
- compat.productId,
- device.productFamily
- );
- totalSwitches++;
+ if (deviceIdFilter.length === 0) {
+ // Fallback: process whatever family-level search returned
+ const r = await processDevices(familyData.networkDevices, family.name);
+ totalSwitches += r.switches; totalTransceivers += r.transceivers; totalCompat += r.compat;
+ continue;
+ }
- for (const tx of compat.transceivers) {
- if (!tx.productId) continue;
- totalTransceivers++;
-
- // Try to match transceiver in our DB by Cisco PID
- const txResult = await pool.query(
- `SELECT id FROM transceivers
- WHERE part_number = $1
- OR part_number = $2
- LIMIT 1`,
- [tx.productId, tx.productId.replace(/-S$/, "")]
- );
-
- if (txResult.rows.length > 0) {
- await upsertCompatibility(
- switchId,
- txResult.rows[0].id,
- tx.softReleaseMinVer,
- tx.formFactor,
- tx.reach,
- tx.cableType,
- tx.media,
- tx.dataRate
- );
- totalCompat++;
- }
+ // Step 2: Iterate every switch model by its specific TMG Product ID
+ // (family search only returns 1 switch; per-device search returns full compat list)
+ for (const dev of deviceIdFilter) {
+ try {
+ await new Promise((r) => setTimeout(r, 1000)); // 1s between requests
+ const devData = await searchTmgByDeviceId({ id: dev.id, name: dev.name });
+ const r = await processDevices(devData.networkDevices, family.name);
+ totalSwitches += r.switches; totalTransceivers += r.transceivers; totalCompat += r.compat;
+ if (totalSwitches % 20 === 0) {
+ console.log(` ... ${totalSwitches} switches processed, ${totalCompat} compat matches`);
}
+ } catch (devErr) {
+ console.warn(` Skip ${dev.name}: ${(devErr as Error).message.slice(0, 60)}`);
}
}