feat: add peering intelligence to comparison PDF

- Common IXPs table (both ASNs present — direct peering possible)
  - Shows IXP name, city, port speeds for each ASN
- Shared colocation facilities table (datacenter co-presence)
- Expansion opportunities: IXPs where one ASN is present but the other isn't
- Lookup data fetched in parallel with validate/aspa for zero extra latency
- Comparison PDF now 5 pages including Peering Intelligence page
This commit is contained in:
Rene Fichtmueller 2026-04-29 23:12:04 +02:00
parent eeff755cc2
commit 46681ad6f2

103
server.js
View File

@ -1160,7 +1160,7 @@ function buildPdfHtml(data, aspa, format) {
/** /**
* Build comparison PDF HTML for two ASNs. * Build comparison PDF HTML for two ASNs.
*/ */
function buildComparisonPdfHtml(d1, aspa1, d2, aspa2) { function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
function col(d, aspa) { function col(d, aspa) {
@ -1204,6 +1204,98 @@ function buildComparisonPdfHtml(d1, aspa1, d2, aspa2) {
const colHtml = col(d1, aspa1) + col(d2, aspa2); const colHtml = col(d1, aspa1) + col(d2, aspa2);
// ── Peering Intelligence: common IXPs + common facilities ─────────────────
const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
const ixIds1 = new Set(ixList1.map(x => x.id));
const ixIds2 = new Set(ixList2.map(x => x.id));
const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
const commonIxps = commonIxIds.slice(0, 20).map(id => {
const x1 = ixList1.find(x => x.id === id);
const x2 = ixList2.find(x => x.id === id);
return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
});
const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
const facIds1 = new Set(facList1.map(f => f.id));
const facIds2 = new Set(facList2.map(f => f.id));
const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
// IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
const commonIxHtml = commonIxps.length > 0
? `<table>
<tr><th>IXP Name</th><th>City</th><th>AS${d1.asn} Speed</th><th>AS${d2.asn} Speed</th></tr>
${commonIxps.map(x => `
<tr>
<td style="font-weight:500">${escHtml(x.name)}</td>
<td>${escHtml(x.city)}</td>
<td>${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}</td>
<td>${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No common IXPs found — these networks do not share any Internet Exchange Points.</p>';
const commonFacHtml = commonFacs.length > 0
? `<table>
<tr><th>Datacenter / Colocation Facility</th><th>City</th><th>Country</th></tr>
${commonFacs.map(f => `
<tr>
<td style="font-weight:500">${escHtml(f?.name||'')}</td>
<td>${escHtml(f?.city||'')}</td>
<td>${escHtml(f?.country||'')}</td>
</tr>`).join('')}
</table>`
: '<p style="color:#6b7280;font-style:italic">No shared colocation facilities found.</p>';
const oppsHtml = (asns, opps) => opps.length === 0
? `<p style="color:#16a34a;font-size:9pt">Already maximally peered on these exchanges — or no data available.</p>`
: opps.map(x => `<div style="padding:4px 0;border-bottom:1px solid #f3f4f6;font-size:9pt">
<span style="font-weight:600">${escHtml(x.name)}</span>
${x.city ? `<span style="color:#6b7280"> · ${escHtml(x.city)}</span>` : ''}
${x.speed ? `<span class="badge badge-indigo" style="margin-left:6px">${(x.speed/1000).toLocaleString()}G</span>` : ''}
</div>`).join('');
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
<div class="page">
<h2>Peering Intelligence</h2>
<p class="section-intro">Common Internet Exchange Points and colocation facilities potential peering locations between AS${d1.asn} and AS${d2.asn}.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonIxps.length}</div>
<div style="font-size:9pt;color:#6b7280">Common IXPs</div>
</div>
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonFacs.length}</div>
<div style="font-size:9pt;color:#6b7280">Shared Facilities</div>
</div>
</div>
<h3>Common Internet Exchange Points (direct peering possible)</h3>
${commonIxHtml}
<h3 style="margin-top:1.2em">Shared Colocation Facilities</h3>
${commonFacHtml}
${oppsFor1.length + oppsFor2.length > 0 ? `
<h3 style="margin-top:1.2em">Expansion Opportunities</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px">
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d2.asn} could join (AS${d1.asn} is there)</div>
${oppsHtml(d2.asn, oppsFor2)}
</div>
<div>
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d1.asn} could join (AS${d2.asn} is there)</div>
${oppsHtml(d1.asn, oppsFor1)}
</div>
</div>` : ''}
</div>` : '';
// Side-by-side check comparison // Side-by-side check comparison
const allChecks = [...new Set([ const allChecks = [...new Set([
...(d1.score_breakdown || []).map(c => c.check), ...(d1.score_breakdown || []).map(c => c.check),
@ -1249,8 +1341,9 @@ function buildComparisonPdfHtml(d1, aspa1, d2, aspa2) {
${checkRows} ${checkRows}
</table> </table>
</div> </div>
${peeringPage}
<footer> <footer>
<span>PeerCortex · Comparison Report</span> <span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
<span>Generated ${ts} · peercortex.org</span> <span>Generated ${ts} · peercortex.org</span>
</footer> </footer>
</body> </body>
@ -6280,17 +6373,19 @@ ${html}
return res.end(cached); return res.end(cached);
} }
try { try {
const [v1, a1, v2, a2] = await Promise.all([ const [v1, a1, l1, v2, a2, l2] = await Promise.all([
fetchInternal(`/api/validate?asn=${asn1}`), fetchInternal(`/api/validate?asn=${asn1}`),
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null), fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
fetchInternal(`/api/validate?asn=${asn2}`), fetchInternal(`/api/validate?asn=${asn2}`),
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null), fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
]); ]);
if (v1.error || v2.error) { if (v1.error || v2.error) {
res.writeHead(400, {'Content-Type':'application/json'}); res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: v1.error || v2.error })); return res.end(JSON.stringify({ error: v1.error || v2.error }));
} }
const html = buildComparisonPdfHtml(v1, a1, v2, a2); const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
const pdfBuf = await renderHtmlToPdf(html); const pdfBuf = await renderHtmlToPdf(html);
pdfCacheSet(cacheKey, pdfBuf); pdfCacheSet(cacheKey, pdfBuf);
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length }); res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });