refactor: extract ipv6-adoption.js and pdf-export/{templates,renderer}.js
templates.js and renderer.js content extracted via direct line-slicing (not retyped) given their size (~660-line HTML/CSS template functions) -- avoids transcription risk on template-literal-heavy code. Verified via smoke-test harness (28/28 match). server.js: 4492 -> 3784 lines (6838 originally -- 45% reduction so far).
This commit is contained in:
parent
a5912a3d0c
commit
7701edd657
821
server.js
821
server.js
@ -7,50 +7,6 @@ const crypto = require("crypto");
|
|||||||
const localDb = require('./local-db-client');
|
const localDb = require('./local-db-client');
|
||||||
console.log('[PeerCortex] Local DB client initialized');
|
console.log('[PeerCortex] Local DB client initialized');
|
||||||
|
|
||||||
// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
|
|
||||||
let _puppeteerBrowser = null;
|
|
||||||
let _puppeteerLoading = false;
|
|
||||||
async function getPuppeteerBrowser() {
|
|
||||||
if (_puppeteerBrowser) return _puppeteerBrowser;
|
|
||||||
if (_puppeteerLoading) {
|
|
||||||
// Wait for in-flight launch
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
return _puppeteerBrowser;
|
|
||||||
}
|
|
||||||
_puppeteerLoading = true;
|
|
||||||
try {
|
|
||||||
const puppeteer = require("puppeteer");
|
|
||||||
_puppeteerBrowser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
|
||||||
});
|
|
||||||
console.log("[PDF] Puppeteer browser launched");
|
|
||||||
_puppeteerBrowser.on("disconnected", () => {
|
|
||||||
console.log("[PDF] Browser disconnected — will relaunch on next request");
|
|
||||||
_puppeteerBrowser = null;
|
|
||||||
_puppeteerLoading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[PDF] Puppeteer unavailable:", e.message);
|
|
||||||
_puppeteerBrowser = null;
|
|
||||||
}
|
|
||||||
_puppeteerLoading = false;
|
|
||||||
return _puppeteerBrowser;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
|
|
||||||
const pdfCache = new Map();
|
|
||||||
const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
function pdfCacheGet(key) {
|
|
||||||
const e = pdfCache.get(key);
|
|
||||||
if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function pdfCacheSet(key, buf) {
|
|
||||||
if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
|
|
||||||
pdfCache.set(key, { buf, ts: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
require('./src/backend/config');
|
require('./src/backend/config');
|
||||||
@ -116,773 +72,26 @@ const {
|
|||||||
buildAspaAdoptionDashboard,
|
buildAspaAdoptionDashboard,
|
||||||
} = require("./server/aspa-adoption/tracker");
|
} = require("./server/aspa-adoption/tracker");
|
||||||
|
|
||||||
// ============================================================
|
const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption");
|
||||||
// FEATURE 4: IPv6 Adoption per RIR
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
// In-memory cache: { data, ts }
|
|
||||||
let ipv6StatsCache = null;
|
|
||||||
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
|
||||||
|
|
||||||
const { RIR_DELEGATION_URLS } = require("./server/data/rir-delegation-urls");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and parse one RIR delegation file.
|
|
||||||
* Returns { ipv4, ipv6, asns } count objects.
|
|
||||||
*/
|
|
||||||
function fetchRirDelegation(rir, url) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
|
|
||||||
const mod = url.startsWith('https') ? require('https') : require('http');
|
|
||||||
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
|
|
||||||
if (res.statusCode !== 200) { resolve(result); return; }
|
|
||||||
let buf = '';
|
|
||||||
res.on('data', d => { buf += d; });
|
|
||||||
res.on('end', () => {
|
|
||||||
for (const line of buf.split('\n')) {
|
|
||||||
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
|
|
||||||
const parts = line.split('|');
|
|
||||||
if (parts.length < 7) continue;
|
|
||||||
const type = parts[2];
|
|
||||||
const status = parts[6].trim();
|
|
||||||
const cc = parts[1];
|
|
||||||
if (status !== 'allocated' && status !== 'assigned') continue;
|
|
||||||
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
|
|
||||||
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
|
|
||||||
else if (type === 'asn') { result.asns++; }
|
|
||||||
}
|
|
||||||
resolve(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', () => resolve(result));
|
|
||||||
req.on('timeout', () => { req.destroy(); resolve(result); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all 5 RIR delegation files and aggregate IPv6 stats.
|
|
||||||
* Cached for 24 hours.
|
|
||||||
*/
|
|
||||||
async function fetchIpv6AdoptionStats(force) {
|
|
||||||
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
|
|
||||||
return ipv6StatsCache.data;
|
|
||||||
}
|
|
||||||
console.log('[IPv6] Fetching RIR delegation stats...');
|
|
||||||
const results = await Promise.all(
|
|
||||||
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
|
|
||||||
);
|
|
||||||
const rirs = results.map(r => ({
|
|
||||||
rir: r.rir,
|
|
||||||
ipv4_records: r.ipv4,
|
|
||||||
ipv6_records: r.ipv6,
|
|
||||||
asn_records: r.asns,
|
|
||||||
ipv6_pct: (r.ipv4 + r.ipv6) > 0
|
|
||||||
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
|
|
||||||
: 0,
|
|
||||||
countries_with_ipv6: r.ipv6_countries.size,
|
|
||||||
countries_with_ipv4: r.ipv4_countries.size,
|
|
||||||
}));
|
|
||||||
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
|
|
||||||
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
|
|
||||||
const data = {
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
|
|
||||||
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
|
|
||||||
: 0,
|
|
||||||
total_ipv4_records: total_ipv4,
|
|
||||||
total_ipv6_records: total_ipv6,
|
|
||||||
by_rir: rirs,
|
|
||||||
};
|
|
||||||
ipv6StatsCache = { data, ts: Date.now() };
|
|
||||||
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fetch on startup (non-blocking)
|
|
||||||
fetchIpv6AdoptionStats().catch(() => {});
|
|
||||||
|
|
||||||
const { UA } = require("./server/data/constants");
|
const { UA } = require("./server/data/constants");
|
||||||
const { CITY_COORDS } = require("./server/data/city-coords");
|
const { CITY_COORDS } = require("./server/data/city-coords");
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
const {
|
||||||
// ============================================================
|
scoreRingSvg,
|
||||||
|
pdfBaseCSS,
|
||||||
/**
|
buildPdfHtml,
|
||||||
* Generate a health score ring SVG (inline, no external deps)
|
buildComparisonPdfHtml,
|
||||||
* @param {number} score 0–100
|
formatLabel,
|
||||||
* @param {string} color CSS color
|
escHtml,
|
||||||
*/
|
} = require("./server/pdf-export/templates");
|
||||||
function scoreRingSvg(score, color) {
|
const {
|
||||||
const r = 54, cx = 64, cy = 64;
|
getPuppeteerBrowser,
|
||||||
const circ = 2 * Math.PI * r;
|
pdfCacheGet,
|
||||||
const fill = circ * (1 - score / 100);
|
pdfCacheSet,
|
||||||
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
|
renderHtmlToPdf,
|
||||||
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
} = require("./server/pdf-export/renderer");
|
||||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
|
|
||||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
|
|
||||||
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
|
|
||||||
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
|
|
||||||
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
|
|
||||||
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
|
|
||||||
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
|
|
||||||
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shared CSS for all PDF templates */
|
|
||||||
function pdfBaseCSS() {
|
|
||||||
return `
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
|
|
||||||
@page { size: A4; margin: 18mm 16mm; }
|
|
||||||
.page { page-break-after: always; min-height: 247mm; padding: 0; }
|
|
||||||
.page:last-child { page-break-after: avoid; }
|
|
||||||
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
|
|
||||||
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
|
|
||||||
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
|
|
||||||
p { margin-bottom: 0.5em; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
|
|
||||||
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
|
|
||||||
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
|
|
||||||
tr:nth-child(even) td { background: #f5f3ff; }
|
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
|
|
||||||
.badge-green { background: #dcfce7; color: #166534; }
|
|
||||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
|
||||||
.badge-yellow{ background: #fef9c3; color: #854d0e; }
|
|
||||||
.badge-gray { background: #f3f4f6; color: #374151; }
|
|
||||||
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
|
|
||||||
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
|
|
||||||
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
|
|
||||||
.cover-logo span { color: #1e1b4b; }
|
|
||||||
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
|
|
||||||
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
|
|
||||||
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
|
|
||||||
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
|
|
||||||
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
|
|
||||||
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
|
|
||||||
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
|
|
||||||
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
|
||||||
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
|
|
||||||
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
|
|
||||||
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
|
|
||||||
.check-list { list-style: none; padding: 0; }
|
|
||||||
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
|
|
||||||
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
|
|
||||||
.check-pass { color: #16a34a; }
|
|
||||||
.check-fail { color: #dc2626; }
|
|
||||||
.check-warn { color: #d97706; }
|
|
||||||
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
|
|
||||||
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the PDF HTML for a single ASN.
|
|
||||||
* @param {object} data Full /api/validate response
|
|
||||||
* @param {object} aspa /api/aspa response (may be null)
|
|
||||||
* @param {string} format 'report' | 'executive' | 'technical'
|
|
||||||
*/
|
|
||||||
function buildPdfHtml(data, aspa, format) {
|
|
||||||
const asn = data.asn || '?';
|
|
||||||
const name = data.name || 'Unknown Network';
|
|
||||||
const score = Math.round(data.health_score || 0);
|
|
||||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
const checks = (data.score_breakdown || []);
|
|
||||||
const rel = data.relationships || {};
|
|
||||||
const counts = rel.counts || {};
|
|
||||||
// validations is an object keyed by check name; rpki details live in rpki_completeness
|
|
||||||
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
|
|
||||||
const rpkiDetails = (validations.rpki_completeness?.details || []);
|
|
||||||
const totalPfx = data.meta?.total_prefixes || 0;
|
|
||||||
|
|
||||||
// Derived stats — use rpki_completeness for RPKI metrics
|
|
||||||
const rpki = validations.rpki_completeness || {};
|
|
||||||
const rpkiCoverage = rpki.coverage_pct ?? 0;
|
|
||||||
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
|
|
||||||
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
|
|
||||||
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
|
|
||||||
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
|
|
||||||
|
|
||||||
const passedChecks = checks.filter(c => c.status === 'pass').length;
|
|
||||||
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
|
||||||
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
|
||||||
const aspaDirect = aspa?.direct?.has_aspa ?? null;
|
|
||||||
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
|
|
||||||
|
|
||||||
// Check list HTML
|
|
||||||
function checkIcon(passed) {
|
|
||||||
if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
|
||||||
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const checksHtml = checks.map(c => {
|
|
||||||
const isPassed = c.status === 'pass';
|
|
||||||
const isInfo = c.status === 'info';
|
|
||||||
const icon = checkIcon(isPassed || isInfo);
|
|
||||||
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
|
|
||||||
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
|
|
||||||
return `
|
|
||||||
<li>
|
|
||||||
${icon}
|
|
||||||
<div>
|
|
||||||
<span style="font-weight:600">${escHtml(c.check || '')}</span>
|
|
||||||
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
|
|
||||||
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</li>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Upstream providers table
|
|
||||||
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
|
|
||||||
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Prefix validations table (sample from rpki_completeness.details)
|
|
||||||
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
|
|
||||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
|
||||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
|
||||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
|
||||||
<td>${v.validating_roas ?? '-'}</td>
|
|
||||||
<td>—</td></tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Recommendations
|
|
||||||
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
|
|
||||||
const recHtml = failedItems.length === 0
|
|
||||||
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
|
|
||||||
: failedItems.map((c, i) => `
|
|
||||||
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
|
|
||||||
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
|
|
||||||
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
|
|
||||||
</div>`).join('');
|
|
||||||
|
|
||||||
// ── Executive pages (always present) ───────────────────────────────────────
|
|
||||||
const coverPage = `
|
|
||||||
<div class="page cover">
|
|
||||||
<div class="cover-stripe"></div>
|
|
||||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
|
||||||
<div class="cover-asn">AS${asn}</div>
|
|
||||||
<div class="cover-name">${escHtml(name)}</div>
|
|
||||||
<div class="cover-sub">${formatLabel(format)} Report</div>
|
|
||||||
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
|
|
||||||
${scoreRingSvg(score)}
|
|
||||||
<div>
|
|
||||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
|
|
||||||
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
|
|
||||||
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cover-meta">
|
|
||||||
Generated by PeerCortex · ${ts} · peercortex.org
|
|
||||||
| ${totalPfx} prefixes analysed
|
|
||||||
| RPKI coverage ${rpkiCoverage}%
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const summaryPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>Executive Summary</h2>
|
|
||||||
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
|
|
||||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
|
|
||||||
<div class="metric-card"><div class="label">ASPA Status</div>
|
|
||||||
<div class="value" style="font-size:12pt;margin-top:4px">
|
|
||||||
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
|
|
||||||
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="unit">${aspaProviders} providers</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3>Status Overview</h3>
|
|
||||||
<ul class="check-list">${checksHtml}</ul>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// ── Recommendations page ────────────────────────────────────────────────────
|
|
||||||
const recsPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>Recommendations</h2>
|
|
||||||
<p class="section-intro">Action items to improve network health and security posture.</p>
|
|
||||||
${recHtml}
|
|
||||||
${failedItems.length > 0 ? `
|
|
||||||
<h3 style="margin-top:1.5em">Remediation Priority</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
|
|
||||||
${failedItems.map((c, i) => `
|
|
||||||
<tr>
|
|
||||||
<td>${i+1}</td>
|
|
||||||
<td>${escHtml(c.check || '')}</td>
|
|
||||||
<td><span class="badge badge-red">High</span></td>
|
|
||||||
<td><span class="badge badge-yellow">Medium</span></td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</table>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// ── Detail pages (included for 'report' and 'technical') ───────────────────
|
|
||||||
const rpkiPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>RPKI Compliance</h2>
|
|
||||||
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
|
|
||||||
</div>
|
|
||||||
<h3>ASPA (Autonomous System Provider Authorization)</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Attribute</th><th>Value</th></tr>
|
|
||||||
<tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
|
||||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
|
||||||
<tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
|
||||||
</table>
|
|
||||||
${pfxRows ? `
|
|
||||||
<h3>Prefix Validation Sample (first 20)</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
|
|
||||||
${pfxRows}
|
|
||||||
</table>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const bgpPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>BGP Topology</h2>
|
|
||||||
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
|
|
||||||
</div>
|
|
||||||
${upstreamRows ? `
|
|
||||||
<h3>Top Upstream Providers</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
|
||||||
${upstreamRows}
|
|
||||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const footer = `
|
|
||||||
<footer>
|
|
||||||
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
|
|
||||||
<span>Generated ${ts} · peercortex.org</span>
|
|
||||||
</footer>`;
|
|
||||||
|
|
||||||
// ── Extra pages for technical / report formats ─────────────────────────────
|
|
||||||
|
|
||||||
// Technical: full prefix table (no row cap) + all upstream rows + check scores
|
|
||||||
const allPfxRows = rpkiDetails.map(v => {
|
|
||||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
|
||||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
|
||||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
|
||||||
<td>${v.validating_roas ?? '-'}</td>
|
|
||||||
<td>${v.max_length ?? '-'}</td></tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const allUpstreamRows = (rel.upstreams || []).map(u =>
|
|
||||||
`<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const checkScoreRows = checks.map(c =>
|
|
||||||
`<tr>
|
|
||||||
<td>${escHtml(c.check || '')}</td>
|
|
||||||
<td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td>
|
|
||||||
<td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td>
|
|
||||||
<td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td>
|
|
||||||
</tr>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const technicalHeaderPage = `
|
|
||||||
<div class="page" style="padding-top:2cm">
|
|
||||||
<div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px">
|
|
||||||
<div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div>
|
|
||||||
<div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} — ${escHtml(name)}</div>
|
|
||||||
<div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div>
|
|
||||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
|
||||||
<div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div>
|
|
||||||
<div class="metric-card"><div class="label">ASPA</div>
|
|
||||||
<div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div>
|
|
||||||
<div class="unit">${aspaProviders} providers registered</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const rpkiFullPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>RPKI Compliance — Full Prefix Table</h2>
|
|
||||||
<p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p>
|
|
||||||
<h3>ASPA Object</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Attribute</th><th>Value</th></tr>
|
|
||||||
<tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
|
||||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
|
||||||
<tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
|
||||||
</table>
|
|
||||||
${allPfxRows ? `
|
|
||||||
<h3 style="margin-top:1.5em">All Prefixes</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr>
|
|
||||||
${allPfxRows}
|
|
||||||
</table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const bgpFullPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>BGP Topology — Full Relationship Table</h2>
|
|
||||||
<p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p>
|
|
||||||
${allUpstreamRows ? `
|
|
||||||
<h3>All Upstream Providers</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
|
||||||
${allUpstreamRows}
|
|
||||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
|
||||||
${(rel.peers || []).length > 0 ? `
|
|
||||||
<h3 style="margin-top:1.5em">Peer Sample (top 20)</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>ASN</th><th>Name</th></tr>
|
|
||||||
${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')}
|
|
||||||
</table>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const checkDetailPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>Check Score Detail</h2>
|
|
||||||
<p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p>
|
|
||||||
<table>
|
|
||||||
<tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr>
|
|
||||||
${checkScoreRows}
|
|
||||||
</table>
|
|
||||||
<div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280">
|
|
||||||
<strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
|
|
||||||
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Report: add data provenance and ASPA deep-dive
|
|
||||||
const aspaPage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>ASPA — Provider Authorization Analysis</h2>
|
|
||||||
<p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<div class="metric-card"><div class="label">ASPA Deployed</div>
|
|
||||||
<div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div>
|
|
||||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div>
|
|
||||||
</div>
|
|
||||||
<h3>What ASPA Protects Against</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr>
|
|
||||||
<tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr>
|
|
||||||
<tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr>
|
|
||||||
<tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr>
|
|
||||||
<tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr>
|
|
||||||
</table>
|
|
||||||
${aspaDirect === false ? `
|
|
||||||
<div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
|
|
||||||
<div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div>
|
|
||||||
<div style="font-size:9pt;color:#6b7280">
|
|
||||||
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br>
|
|
||||||
2. Create an ASPA object in your RIR portal listing their ASNs<br>
|
|
||||||
3. Sign with your RPKI key — takes effect within hours<br>
|
|
||||||
4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator
|
|
||||||
</div>
|
|
||||||
</div>` : `
|
|
||||||
<div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px">
|
|
||||||
<div style="font-weight:600;color:#15803d">ASPA is deployed — listed providers:</div>
|
|
||||||
<div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div>
|
|
||||||
</div>`}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const provenancePage = `
|
|
||||||
<div class="page">
|
|
||||||
<h2>Data Provenance</h2>
|
|
||||||
<p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p>
|
|
||||||
<table>
|
|
||||||
<tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr>
|
|
||||||
<tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr>
|
|
||||||
<tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr>
|
|
||||||
<tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr>
|
|
||||||
<tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr>
|
|
||||||
<tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr>
|
|
||||||
<tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr>
|
|
||||||
</table>
|
|
||||||
<h3 style="margin-top:1.5em">Report Metadata</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Value</th></tr>
|
|
||||||
<tr><td>ASN</td><td>AS${asn}</td></tr>
|
|
||||||
<tr><td>Network Name</td><td>${escHtml(name)}</td></tr>
|
|
||||||
<tr><td>Report Format</td><td>${formatLabel(format)}</td></tr>
|
|
||||||
<tr><td>Generated</td><td>${ts}</td></tr>
|
|
||||||
<tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr>
|
|
||||||
<tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr>
|
|
||||||
<tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr>
|
|
||||||
<tr><td>Health Checks Run</td><td>${checks.length}</td></tr>
|
|
||||||
</table>
|
|
||||||
<div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px">
|
|
||||||
This report is generated automatically based on publicly available BGP and RPKI data.
|
|
||||||
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
|
|
||||||
MIT License · github.com/renefichtmueller/PeerCortex
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// ── Compose pages by format ────────────────────────────────────────────────
|
|
||||||
let bodyContent;
|
|
||||||
if (format === 'executive') {
|
|
||||||
// 3 pages: clean stakeholder summary — no deep technical data
|
|
||||||
bodyContent = coverPage + summaryPage + recsPage;
|
|
||||||
} else if (format === 'technical') {
|
|
||||||
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
|
|
||||||
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
|
|
||||||
} else {
|
|
||||||
// 'report' — full stakeholder + technical, ~10 pages
|
|
||||||
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<title>PeerCortex — AS${asn} ${formatLabel(format)}</title>
|
|
||||||
<style>${pdfBaseCSS()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${bodyContent}
|
|
||||||
${footer}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build comparison PDF HTML for two ASNs.
|
|
||||||
*/
|
|
||||||
function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
|
|
||||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
|
|
||||||
function col(d, aspa) {
|
|
||||||
const asn = d.asn || '?';
|
|
||||||
const name = d.name || 'Unknown';
|
|
||||||
const score = Math.round(d.health_score || 0);
|
|
||||||
const rel = d.relationships || {};
|
|
||||||
const counts= rel.counts || {};
|
|
||||||
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
|
|
||||||
const rpki = vals.rpki_completeness || {};
|
|
||||||
const rpkiV = rpki.with_roa || 0;
|
|
||||||
const rpkiT = rpki.total_checked || 0;
|
|
||||||
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
|
|
||||||
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
|
||||||
const aspaDep = aspa?.direct?.has_aspa ?? null;
|
|
||||||
const checks= (d.score_breakdown || []);
|
|
||||||
const passC = checks.filter(c => c.status === 'pass').length;
|
|
||||||
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
|
|
||||||
<div style="text-align:center;padding:16px 0 12px;">
|
|
||||||
${scoreRingSvg(score, sc)}
|
|
||||||
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
|
|
||||||
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
|
|
||||||
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
|
|
||||||
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
|
|
||||||
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
|
|
||||||
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
|
|
||||||
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
|
|
||||||
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
|
|
||||||
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
|
|
||||||
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
|
|
||||||
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
|
|
||||||
</table>
|
|
||||||
</td>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
const allChecks = [...new Set([
|
|
||||||
...(d1.score_breakdown || []).map(c => c.check),
|
|
||||||
...(d2.score_breakdown || []).map(c => c.check),
|
|
||||||
])];
|
|
||||||
const checkRows = allChecks.map(checkName => {
|
|
||||||
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
|
|
||||||
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
|
|
||||||
const isP1 = c1?.status === 'pass';
|
|
||||||
const isP2 = c2?.status === 'pass';
|
|
||||||
const icon = p => p ? '✓' : '✗';
|
|
||||||
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
|
|
||||||
return `<tr>
|
|
||||||
<td>${escHtml(checkName)}</td>
|
|
||||||
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
|
|
||||||
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<title>PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison</title>
|
|
||||||
<style>${pdfBaseCSS()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page cover">
|
|
||||||
<div class="cover-stripe"></div>
|
|
||||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
|
||||||
<div style="margin-top:40px">
|
|
||||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
|
|
||||||
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
|
|
||||||
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}</div>
|
|
||||||
</div>
|
|
||||||
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
|
|
||||||
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
|
|
||||||
</div>
|
|
||||||
<div class="page">
|
|
||||||
<h2>Head-to-Head Health Checks</h2>
|
|
||||||
<table>
|
|
||||||
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
|
|
||||||
${checkRows}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
${peeringPage}
|
|
||||||
<footer>
|
|
||||||
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
|
|
||||||
<span>Generated ${ts} · peercortex.org</span>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLabel(fmt) {
|
|
||||||
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render HTML to PDF buffer using puppeteer.
|
|
||||||
* Returns Buffer or throws.
|
|
||||||
*/
|
|
||||||
async function renderHtmlToPdf(html) {
|
|
||||||
const browser = await getPuppeteerBrowser();
|
|
||||||
if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
|
|
||||||
const page = await browser.newPage();
|
|
||||||
try {
|
|
||||||
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
||||||
const buf = await page.pdf({
|
|
||||||
format: 'A4',
|
|
||||||
printBackground: true,
|
|
||||||
margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
|
|
||||||
});
|
|
||||||
return buf;
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
responseCache,
|
responseCache,
|
||||||
|
|||||||
80
server/ipv6-adoption.js
Normal file
80
server/ipv6-adoption.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls");
|
||||||
|
|
||||||
|
// FEATURE 4: IPv6 Adoption per RIR
|
||||||
|
|
||||||
|
// In-memory cache: { data, ts }
|
||||||
|
let ipv6StatsCache = null;
|
||||||
|
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
||||||
|
// Fetch and parse one RIR delegation file.
|
||||||
|
// Returns { ipv4, ipv6, asns } count objects.
|
||||||
|
function fetchRirDelegation(rir, url) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
|
||||||
|
const mod = url.startsWith('https') ? require('https') : require('http');
|
||||||
|
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
|
||||||
|
if (res.statusCode !== 200) { resolve(result); return; }
|
||||||
|
let buf = '';
|
||||||
|
res.on('data', d => { buf += d; });
|
||||||
|
res.on('end', () => {
|
||||||
|
for (const line of buf.split('\n')) {
|
||||||
|
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
|
||||||
|
const parts = line.split('|');
|
||||||
|
if (parts.length < 7) continue;
|
||||||
|
const type = parts[2];
|
||||||
|
const status = parts[6].trim();
|
||||||
|
const cc = parts[1];
|
||||||
|
if (status !== 'allocated' && status !== 'assigned') continue;
|
||||||
|
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
|
||||||
|
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
|
||||||
|
else if (type === 'asn') { result.asns++; }
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve(result));
|
||||||
|
req.on('timeout', () => { req.destroy(); resolve(result); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all 5 RIR delegation files and aggregate IPv6 stats.
|
||||||
|
// Cached for 24 hours.
|
||||||
|
async function fetchIpv6AdoptionStats(force) {
|
||||||
|
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
|
||||||
|
return ipv6StatsCache.data;
|
||||||
|
}
|
||||||
|
console.log('[IPv6] Fetching RIR delegation stats...');
|
||||||
|
const results = await Promise.all(
|
||||||
|
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
|
||||||
|
);
|
||||||
|
const rirs = results.map(r => ({
|
||||||
|
rir: r.rir,
|
||||||
|
ipv4_records: r.ipv4,
|
||||||
|
ipv6_records: r.ipv6,
|
||||||
|
asn_records: r.asns,
|
||||||
|
ipv6_pct: (r.ipv4 + r.ipv6) > 0
|
||||||
|
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
|
||||||
|
: 0,
|
||||||
|
countries_with_ipv6: r.ipv6_countries.size,
|
||||||
|
countries_with_ipv4: r.ipv4_countries.size,
|
||||||
|
}));
|
||||||
|
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
|
||||||
|
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
|
||||||
|
const data = {
|
||||||
|
fetched_at: new Date().toISOString(),
|
||||||
|
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
|
||||||
|
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
|
||||||
|
: 0,
|
||||||
|
total_ipv4_records: total_ipv4,
|
||||||
|
total_ipv6_records: total_ipv6,
|
||||||
|
by_rir: rirs,
|
||||||
|
};
|
||||||
|
ipv6StatsCache = { data, ts: Date.now() };
|
||||||
|
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fetch on startup (non-blocking)
|
||||||
|
fetchIpv6AdoptionStats().catch(() => {});
|
||||||
|
|
||||||
|
module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats };
|
||||||
65
server/pdf-export/renderer.js
Normal file
65
server/pdf-export/renderer.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// PDF Export: lazy puppeteer loader + response cache + renderer
|
||||||
|
|
||||||
|
// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
|
||||||
|
let _puppeteerBrowser = null;
|
||||||
|
let _puppeteerLoading = false;
|
||||||
|
async function getPuppeteerBrowser() {
|
||||||
|
if (_puppeteerBrowser) return _puppeteerBrowser;
|
||||||
|
if (_puppeteerLoading) {
|
||||||
|
// Wait for in-flight launch
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
return _puppeteerBrowser;
|
||||||
|
}
|
||||||
|
_puppeteerLoading = true;
|
||||||
|
try {
|
||||||
|
const puppeteer = require("puppeteer");
|
||||||
|
_puppeteerBrowser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
||||||
|
});
|
||||||
|
console.log("[PDF] Puppeteer browser launched");
|
||||||
|
_puppeteerBrowser.on("disconnected", () => {
|
||||||
|
console.log("[PDF] Browser disconnected — will relaunch on next request");
|
||||||
|
_puppeteerBrowser = null;
|
||||||
|
_puppeteerLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[PDF] Puppeteer unavailable:", e.message);
|
||||||
|
_puppeteerBrowser = null;
|
||||||
|
}
|
||||||
|
_puppeteerLoading = false;
|
||||||
|
return _puppeteerBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
|
||||||
|
const pdfCache = new Map();
|
||||||
|
const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
function pdfCacheGet(key) {
|
||||||
|
const e = pdfCache.get(key);
|
||||||
|
if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function pdfCacheSet(key, buf) {
|
||||||
|
if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
|
||||||
|
pdfCache.set(key, { buf, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHtmlToPdf(html) {
|
||||||
|
const browser = await getPuppeteerBrowser();
|
||||||
|
if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||||
|
const buf = await page.pdf({
|
||||||
|
format: 'A4',
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
|
||||||
|
});
|
||||||
|
return buf;
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf };
|
||||||
661
server/pdf-export/templates.js
Normal file
661
server/pdf-export/templates.js
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
|
||||||
|
|
||||||
|
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a health score ring SVG (inline, no external deps)
|
||||||
|
* @param {number} score 0–100
|
||||||
|
* @param {string} color CSS color
|
||||||
|
*/
|
||||||
|
function scoreRingSvg(score, color) {
|
||||||
|
const r = 54, cx = 64, cy = 64;
|
||||||
|
const circ = 2 * Math.PI * r;
|
||||||
|
const fill = circ * (1 - score / 100);
|
||||||
|
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
|
||||||
|
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
|
||||||
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
|
||||||
|
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
|
||||||
|
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
|
||||||
|
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
|
||||||
|
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
|
||||||
|
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared CSS for all PDF templates */
|
||||||
|
function pdfBaseCSS() {
|
||||||
|
return `
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
|
||||||
|
@page { size: A4; margin: 18mm 16mm; }
|
||||||
|
.page { page-break-after: always; min-height: 247mm; padding: 0; }
|
||||||
|
.page:last-child { page-break-after: avoid; }
|
||||||
|
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
|
||||||
|
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
|
||||||
|
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
|
||||||
|
p { margin-bottom: 0.5em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
|
||||||
|
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
|
||||||
|
tr:nth-child(even) td { background: #f5f3ff; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
|
||||||
|
.badge-green { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-yellow{ background: #fef9c3; color: #854d0e; }
|
||||||
|
.badge-gray { background: #f3f4f6; color: #374151; }
|
||||||
|
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
|
||||||
|
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
|
||||||
|
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.cover-logo span { color: #1e1b4b; }
|
||||||
|
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
|
||||||
|
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
|
||||||
|
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
|
||||||
|
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
|
||||||
|
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
|
||||||
|
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
|
||||||
|
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
|
||||||
|
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||||
|
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
|
||||||
|
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
|
||||||
|
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
|
||||||
|
.check-list { list-style: none; padding: 0; }
|
||||||
|
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
|
||||||
|
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
|
||||||
|
.check-pass { color: #16a34a; }
|
||||||
|
.check-fail { color: #dc2626; }
|
||||||
|
.check-warn { color: #d97706; }
|
||||||
|
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
|
||||||
|
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the PDF HTML for a single ASN.
|
||||||
|
* @param {object} data Full /api/validate response
|
||||||
|
* @param {object} aspa /api/aspa response (may be null)
|
||||||
|
* @param {string} format 'report' | 'executive' | 'technical'
|
||||||
|
*/
|
||||||
|
function buildPdfHtml(data, aspa, format) {
|
||||||
|
const asn = data.asn || '?';
|
||||||
|
const name = data.name || 'Unknown Network';
|
||||||
|
const score = Math.round(data.health_score || 0);
|
||||||
|
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
const checks = (data.score_breakdown || []);
|
||||||
|
const rel = data.relationships || {};
|
||||||
|
const counts = rel.counts || {};
|
||||||
|
// validations is an object keyed by check name; rpki details live in rpki_completeness
|
||||||
|
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
|
||||||
|
const rpkiDetails = (validations.rpki_completeness?.details || []);
|
||||||
|
const totalPfx = data.meta?.total_prefixes || 0;
|
||||||
|
|
||||||
|
// Derived stats — use rpki_completeness for RPKI metrics
|
||||||
|
const rpki = validations.rpki_completeness || {};
|
||||||
|
const rpkiCoverage = rpki.coverage_pct ?? 0;
|
||||||
|
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
|
||||||
|
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
|
||||||
|
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
|
||||||
|
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
|
||||||
|
|
||||||
|
const passedChecks = checks.filter(c => c.status === 'pass').length;
|
||||||
|
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||||
|
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||||
|
const aspaDirect = aspa?.direct?.has_aspa ?? null;
|
||||||
|
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
|
||||||
|
|
||||||
|
// Check list HTML
|
||||||
|
function checkIcon(passed) {
|
||||||
|
if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksHtml = checks.map(c => {
|
||||||
|
const isPassed = c.status === 'pass';
|
||||||
|
const isInfo = c.status === 'info';
|
||||||
|
const icon = checkIcon(isPassed || isInfo);
|
||||||
|
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
|
||||||
|
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
${icon}
|
||||||
|
<div>
|
||||||
|
<span style="font-weight:600">${escHtml(c.check || '')}</span>
|
||||||
|
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
|
||||||
|
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Upstream providers table
|
||||||
|
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
|
||||||
|
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Prefix validations table (sample from rpki_completeness.details)
|
||||||
|
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
|
||||||
|
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||||
|
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||||
|
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||||
|
<td>${v.validating_roas ?? '-'}</td>
|
||||||
|
<td>—</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Recommendations
|
||||||
|
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
|
||||||
|
const recHtml = failedItems.length === 0
|
||||||
|
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
|
||||||
|
: failedItems.map((c, i) => `
|
||||||
|
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
|
||||||
|
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
|
||||||
|
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// ── Executive pages (always present) ───────────────────────────────────────
|
||||||
|
const coverPage = `
|
||||||
|
<div class="page cover">
|
||||||
|
<div class="cover-stripe"></div>
|
||||||
|
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||||
|
<div class="cover-asn">AS${asn}</div>
|
||||||
|
<div class="cover-name">${escHtml(name)}</div>
|
||||||
|
<div class="cover-sub">${formatLabel(format)} Report</div>
|
||||||
|
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
|
||||||
|
${scoreRingSvg(score)}
|
||||||
|
<div>
|
||||||
|
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
|
||||||
|
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
|
||||||
|
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-meta">
|
||||||
|
Generated by PeerCortex · ${ts} · peercortex.org
|
||||||
|
| ${totalPfx} prefixes analysed
|
||||||
|
| RPKI coverage ${rpkiCoverage}%
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const summaryPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>Executive Summary</h2>
|
||||||
|
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
|
||||||
|
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
|
||||||
|
<div class="metric-card"><div class="label">ASPA Status</div>
|
||||||
|
<div class="value" style="font-size:12pt;margin-top:4px">
|
||||||
|
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
|
||||||
|
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="unit">${aspaProviders} providers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3>Status Overview</h3>
|
||||||
|
<ul class="check-list">${checksHtml}</ul>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Recommendations page ────────────────────────────────────────────────────
|
||||||
|
const recsPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>Recommendations</h2>
|
||||||
|
<p class="section-intro">Action items to improve network health and security posture.</p>
|
||||||
|
${recHtml}
|
||||||
|
${failedItems.length > 0 ? `
|
||||||
|
<h3 style="margin-top:1.5em">Remediation Priority</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
|
||||||
|
${failedItems.map((c, i) => `
|
||||||
|
<tr>
|
||||||
|
<td>${i+1}</td>
|
||||||
|
<td>${escHtml(c.check || '')}</td>
|
||||||
|
<td><span class="badge badge-red">High</span></td>
|
||||||
|
<td><span class="badge badge-yellow">Medium</span></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</table>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Detail pages (included for 'report' and 'technical') ───────────────────
|
||||||
|
const rpkiPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>RPKI Compliance</h2>
|
||||||
|
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
|
||||||
|
</div>
|
||||||
|
<h3>ASPA (Autonomous System Provider Authorization)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Attribute</th><th>Value</th></tr>
|
||||||
|
<tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||||
|
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||||
|
<tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||||
|
</table>
|
||||||
|
${pfxRows ? `
|
||||||
|
<h3>Prefix Validation Sample (first 20)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
|
||||||
|
${pfxRows}
|
||||||
|
</table>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const bgpPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>BGP Topology</h2>
|
||||||
|
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
|
||||||
|
</div>
|
||||||
|
${upstreamRows ? `
|
||||||
|
<h3>Top Upstream Providers</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||||
|
${upstreamRows}
|
||||||
|
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const footer = `
|
||||||
|
<footer>
|
||||||
|
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
|
||||||
|
<span>Generated ${ts} · peercortex.org</span>
|
||||||
|
</footer>`;
|
||||||
|
|
||||||
|
// ── Extra pages for technical / report formats ─────────────────────────────
|
||||||
|
|
||||||
|
// Technical: full prefix table (no row cap) + all upstream rows + check scores
|
||||||
|
const allPfxRows = rpkiDetails.map(v => {
|
||||||
|
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||||
|
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||||
|
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||||
|
<td>${v.validating_roas ?? '-'}</td>
|
||||||
|
<td>${v.max_length ?? '-'}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const allUpstreamRows = (rel.upstreams || []).map(u =>
|
||||||
|
`<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const checkScoreRows = checks.map(c =>
|
||||||
|
`<tr>
|
||||||
|
<td>${escHtml(c.check || '')}</td>
|
||||||
|
<td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td>
|
||||||
|
<td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td>
|
||||||
|
<td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td>
|
||||||
|
</tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const technicalHeaderPage = `
|
||||||
|
<div class="page" style="padding-top:2cm">
|
||||||
|
<div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px">
|
||||||
|
<div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div>
|
||||||
|
<div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} — ${escHtml(name)}</div>
|
||||||
|
<div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div>
|
||||||
|
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||||
|
<div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div>
|
||||||
|
<div class="metric-card"><div class="label">ASPA</div>
|
||||||
|
<div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div>
|
||||||
|
<div class="unit">${aspaProviders} providers registered</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const rpkiFullPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>RPKI Compliance — Full Prefix Table</h2>
|
||||||
|
<p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p>
|
||||||
|
<h3>ASPA Object</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Attribute</th><th>Value</th></tr>
|
||||||
|
<tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||||
|
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||||
|
<tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||||
|
</table>
|
||||||
|
${allPfxRows ? `
|
||||||
|
<h3 style="margin-top:1.5em">All Prefixes</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr>
|
||||||
|
${allPfxRows}
|
||||||
|
</table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const bgpFullPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>BGP Topology — Full Relationship Table</h2>
|
||||||
|
<p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p>
|
||||||
|
${allUpstreamRows ? `
|
||||||
|
<h3>All Upstream Providers</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||||
|
${allUpstreamRows}
|
||||||
|
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||||
|
${(rel.peers || []).length > 0 ? `
|
||||||
|
<h3 style="margin-top:1.5em">Peer Sample (top 20)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>ASN</th><th>Name</th></tr>
|
||||||
|
${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')}
|
||||||
|
</table>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const checkDetailPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>Check Score Detail</h2>
|
||||||
|
<p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr>
|
||||||
|
${checkScoreRows}
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280">
|
||||||
|
<strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
|
||||||
|
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Report: add data provenance and ASPA deep-dive
|
||||||
|
const aspaPage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>ASPA — Provider Authorization Analysis</h2>
|
||||||
|
<p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric-card"><div class="label">ASPA Deployed</div>
|
||||||
|
<div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div>
|
||||||
|
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div>
|
||||||
|
</div>
|
||||||
|
<h3>What ASPA Protects Against</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr>
|
||||||
|
<tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr>
|
||||||
|
<tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr>
|
||||||
|
<tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr>
|
||||||
|
<tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr>
|
||||||
|
</table>
|
||||||
|
${aspaDirect === false ? `
|
||||||
|
<div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
|
||||||
|
<div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div>
|
||||||
|
<div style="font-size:9pt;color:#6b7280">
|
||||||
|
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br>
|
||||||
|
2. Create an ASPA object in your RIR portal listing their ASNs<br>
|
||||||
|
3. Sign with your RPKI key — takes effect within hours<br>
|
||||||
|
4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator
|
||||||
|
</div>
|
||||||
|
</div>` : `
|
||||||
|
<div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px">
|
||||||
|
<div style="font-weight:600;color:#15803d">ASPA is deployed — listed providers:</div>
|
||||||
|
<div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div>
|
||||||
|
</div>`}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const provenancePage = `
|
||||||
|
<div class="page">
|
||||||
|
<h2>Data Provenance</h2>
|
||||||
|
<p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr>
|
||||||
|
<tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr>
|
||||||
|
<tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr>
|
||||||
|
<tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr>
|
||||||
|
<tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr>
|
||||||
|
<tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr>
|
||||||
|
<tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3 style="margin-top:1.5em">Report Metadata</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Field</th><th>Value</th></tr>
|
||||||
|
<tr><td>ASN</td><td>AS${asn}</td></tr>
|
||||||
|
<tr><td>Network Name</td><td>${escHtml(name)}</td></tr>
|
||||||
|
<tr><td>Report Format</td><td>${formatLabel(format)}</td></tr>
|
||||||
|
<tr><td>Generated</td><td>${ts}</td></tr>
|
||||||
|
<tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr>
|
||||||
|
<tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr>
|
||||||
|
<tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr>
|
||||||
|
<tr><td>Health Checks Run</td><td>${checks.length}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px">
|
||||||
|
This report is generated automatically based on publicly available BGP and RPKI data.
|
||||||
|
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
|
||||||
|
MIT License · github.com/renefichtmueller/PeerCortex
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Compose pages by format ────────────────────────────────────────────────
|
||||||
|
let bodyContent;
|
||||||
|
if (format === 'executive') {
|
||||||
|
// 3 pages: clean stakeholder summary — no deep technical data
|
||||||
|
bodyContent = coverPage + summaryPage + recsPage;
|
||||||
|
} else if (format === 'technical') {
|
||||||
|
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
|
||||||
|
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
|
||||||
|
} else {
|
||||||
|
// 'report' — full stakeholder + technical, ~10 pages
|
||||||
|
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<title>PeerCortex — AS${asn} ${formatLabel(format)}</title>
|
||||||
|
<style>${pdfBaseCSS()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${bodyContent}
|
||||||
|
${footer}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build comparison PDF HTML for two ASNs.
|
||||||
|
*/
|
||||||
|
function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
|
||||||
|
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
function col(d, aspa) {
|
||||||
|
const asn = d.asn || '?';
|
||||||
|
const name = d.name || 'Unknown';
|
||||||
|
const score = Math.round(d.health_score || 0);
|
||||||
|
const rel = d.relationships || {};
|
||||||
|
const counts= rel.counts || {};
|
||||||
|
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
|
||||||
|
const rpki = vals.rpki_completeness || {};
|
||||||
|
const rpkiV = rpki.with_roa || 0;
|
||||||
|
const rpkiT = rpki.total_checked || 0;
|
||||||
|
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
|
||||||
|
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||||
|
const aspaDep = aspa?.direct?.has_aspa ?? null;
|
||||||
|
const checks= (d.score_breakdown || []);
|
||||||
|
const passC = checks.filter(c => c.status === 'pass').length;
|
||||||
|
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
|
||||||
|
<div style="text-align:center;padding:16px 0 12px;">
|
||||||
|
${scoreRingSvg(score, sc)}
|
||||||
|
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
|
||||||
|
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
|
||||||
|
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
|
||||||
|
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
|
||||||
|
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
|
||||||
|
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
|
||||||
|
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
|
||||||
|
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
|
||||||
|
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
|
||||||
|
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
|
||||||
|
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
|
||||||
|
</table>
|
||||||
|
</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const allChecks = [...new Set([
|
||||||
|
...(d1.score_breakdown || []).map(c => c.check),
|
||||||
|
...(d2.score_breakdown || []).map(c => c.check),
|
||||||
|
])];
|
||||||
|
const checkRows = allChecks.map(checkName => {
|
||||||
|
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
|
||||||
|
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
|
||||||
|
const isP1 = c1?.status === 'pass';
|
||||||
|
const isP2 = c2?.status === 'pass';
|
||||||
|
const icon = p => p ? '✓' : '✗';
|
||||||
|
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
|
||||||
|
return `<tr>
|
||||||
|
<td>${escHtml(checkName)}</td>
|
||||||
|
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
|
||||||
|
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<title>PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison</title>
|
||||||
|
<style>${pdfBaseCSS()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page cover">
|
||||||
|
<div class="cover-stripe"></div>
|
||||||
|
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||||
|
<div style="margin-top:40px">
|
||||||
|
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
|
||||||
|
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
|
||||||
|
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}</div>
|
||||||
|
</div>
|
||||||
|
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
|
||||||
|
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<h2>Head-to-Head Health Checks</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
|
||||||
|
${checkRows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
${peeringPage}
|
||||||
|
<footer>
|
||||||
|
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
|
||||||
|
<span>Generated ${ts} · peercortex.org</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(fmt) {
|
||||||
|
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };
|
||||||
Loading…
x
Reference in New Issue
Block a user