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:
parent
eeff755cc2
commit
46681ad6f2
103
server.js
103
server.js
@ -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 });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user