diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 0c07a43..054794b 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -21,3 +21,5 @@
{"d":"2026-04-09","t":"FIX","m":"lookup: remove WithRetry on Prefixes+Neighbours (was 8s+8s=16s, now 8s max), add 9s timedFetch hard cap per source"}
{"d":"2026-04-09","t":"FIX","m":"validate Phase1: reduce timeout 8s→5s; Phase2 per-check cap 10s→5s; rdns sample 20→3; total cold ≤10s vs 16s before"}
{"d":"2026-04-09","t":"FIX","m":"doLookup: add 15s AbortController on initial fetch — skeleton no longer spins indefinitely on slow/failed lookups"}
+{"d":"2026-04-09","t":"FIX","m":"aspath/rpki-history/looking-glass/communities: fetchJSONWithRetry with 15-20s timeouts replaced by fetchJSON 5-6s — was causing 40-72s hangs"}
+{"d":"2026-04-09","t":"FIX","m":"loadCommunities/loadIrrAudit/loadRpkiHistory/loadAspath/loadHijackMonitor: add AbortController 8-10s — cards no longer spin forever"}
diff --git a/public/index.html b/public/index.html
index e3ebb66..df88332 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3674,8 +3674,9 @@ async function loadCommunities(asn) {
const content = document.getElementById('commContent');
card.classList.remove('hidden');
content.innerHTML = 'Decoding communities…';
+ const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
try {
- const r = await fetch('/api/communities?asn=' + asn);
+ const r = await fetch('/api/communities?asn=' + asn, { signal: ctrl.signal });
const d = await r.json();
if (!d.communities || !d.communities.length) {
content.innerHTML = 'No communities found for this ASN.';
@@ -3712,8 +3713,9 @@ async function loadIrrAudit(asn) {
const content = document.getElementById('irrContent');
card.classList.remove('hidden');
content.innerHTML = 'Checking IRR registration via NLNOG IRR Explorer…';
+ const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
try {
- const r = await fetch('/api/irr-audit?asn=' + asn);
+ const r = await fetch('/api/irr-audit?asn=' + asn, { signal: ctrl.signal });
const d = await r.json();
const pct = d.score || 0;
const color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--orange)' : 'var(--red)';
@@ -3761,8 +3763,9 @@ async function loadRpkiHistory(asn) {
const content = document.getElementById('rpkiHistContent');
card.classList.remove('hidden');
content.innerHTML = 'Loading routing history…';
+ const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
try {
- const r = await fetch('/api/rpki-history?asn=' + asn);
+ const r = await fetch('/api/rpki-history?asn=' + asn, { signal: ctrl.signal });
const d = await r.json();
if (!d.prefixes || !d.prefixes.length) {
content.innerHTML = 'No routing history data available for this ASN.';
@@ -3789,8 +3792,9 @@ async function loadAspath(asn) {
const content = document.getElementById('aspathContent');
card.classList.remove('hidden');
content.innerHTML = 'Loading AS-PATH data…';
+ const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 10000);
try {
- const r = await fetch('/api/aspath?asn=' + asn);
+ const r = await fetch('/api/aspath?asn=' + asn, { signal: ctrl.signal });
const d = await r.json();
const paths = d && d.paths || [];
if (!paths.length) { content.innerHTML = 'No AS-PATH data available for this ASN.'; return; }
@@ -3911,8 +3915,9 @@ async function loadHijackMonitor(asn) {
const content = document.getElementById('hijackContent');
card.classList.remove('hidden');
content.innerHTML = 'Checking hijack status…';
+ const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
try {
- const r = await fetch('/api/hijack-alerts?asn=' + asn);
+ const r = await fetch('/api/hijack-alerts?asn=' + asn, { signal: ctrl.signal });
const d = await r.json();
let html = '';
if (!d.monitoring) {
diff --git a/server.js b/server.js
index ffeb5f8..70aba62 100644
--- a/server.js
+++ b/server.js
@@ -369,7 +369,7 @@ function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALE
async function checkHijacksForAsn(asn) {
try {
const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}&${UA}`;
- const data = await fetchJSONWithRetry(url, { timeout: 15000 });
+ const data = await fetchJSON(url, { timeout: 6000 });
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
return prefixes;
} catch (_) { return []; }
@@ -4734,7 +4734,7 @@ ${html}
res.setHeader('Cache-Control', 'public, max-age=3600');
try {
const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`;
- const data = await fetchJSONWithRetry(url, { timeout: 12000 });
+ const data = await fetchJSON(url, { timeout: 6000 });
const rawComms = [];
if (data && data.data && data.data.bgp_state) {
for (const entry of data.data.bgp_state.slice(0, 50)) {
@@ -4862,7 +4862,7 @@ ${html}
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100';
- const data = await fetchJSONWithRetry(url, { timeout: 20000 });
+ const data = await fetchJSON(url, { timeout: 6000 });
const byOrigin = data && data.data && data.data.by_origin || [];
// Flatten: each origin entry has prefixes[]
var prefixes = [];
@@ -4892,7 +4892,7 @@ ${html}
try {
// Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths
var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn;
- var annData = await fetchJSONWithRetry(annUrl, { timeout: 15000 });
+ var annData = await fetchJSON(annUrl, { timeout: 5000 });
var announced = annData && annData.data && annData.data.prefixes || [];
var prefix = announced.length > 0 ? announced[0].prefix : null;
if (!prefix) {
@@ -4901,7 +4901,7 @@ ${html}
}
// Get looking-glass data for the first announced prefix
var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix);
- var lgData = await fetchJSONWithRetry(lgUrl, { timeout: 20000 });
+ var lgData = await fetchJSON(lgUrl, { timeout: 6000 });
var rrcs = lgData && lgData.data && lgData.data.rrcs || [];
var paths = [];
var seen = new Set();
@@ -4939,7 +4939,7 @@ ${html}
if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); }
try {
const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`;
- const data = await fetchJSONWithRetry(url, { timeout: 20000 });
+ const data = await fetchJSON(url, { timeout: 6000 });
const rrcs = data && data.data && data.data.rrcs || [];
const results = rrcs.slice(0, 15).map(rrc => ({
rrc: rrc.rrc,
@@ -5164,7 +5164,7 @@ ${html}
}
try {
const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`;
- const raw = await fetchJSON(updUrl, { timeout: 25000 });
+ const raw = await fetchJSON(updUrl, { timeout: 8000 });
const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || [];
const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = [];