fix: prevent infinite loading screen by adding sweep timeouts (#32)
fix: prevent infinite loading screen by adding sweep timeouts
This commit is contained in:
@@ -43,13 +43,22 @@ import { briefing as space } from './sources/space.mjs';
|
|||||||
// === Tier 5: Live Market Data ===
|
// === Tier 5: Live Market Data ===
|
||||||
import { briefing as yfinance } from './sources/yfinance.mjs';
|
import { briefing as yfinance } from './sources/yfinance.mjs';
|
||||||
|
|
||||||
|
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
|
||||||
|
|
||||||
export async function runSource(name, fn, ...args) {
|
export async function runSource(name, fn, ...args) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
let timer;
|
||||||
try {
|
try {
|
||||||
const data = await fn(...args);
|
const dataPromise = fn(...args);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
const data = await Promise.race([dataPromise, timeoutPromise]);
|
||||||
return { name, status: 'ok', durationMs: Date.now() - start, data };
|
return { name, status: 'ok', durationMs: Date.now() - start, data };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
|
return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +66,7 @@ export async function fullBriefing() {
|
|||||||
console.error('[Crucix] Starting intelligence sweep — 27 sources...');
|
console.error('[Crucix] Starting intelligence sweep — 27 sources...');
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
const results = await Promise.allSettled([
|
const allPromises = [
|
||||||
// Tier 1: Core OSINT & Geopolitical
|
// Tier 1: Core OSINT & Geopolitical
|
||||||
runSource('GDELT', gdelt),
|
runSource('GDELT', gdelt),
|
||||||
runSource('OpenSky', opensky),
|
runSource('OpenSky', opensky),
|
||||||
@@ -94,7 +103,11 @@ export async function fullBriefing() {
|
|||||||
|
|
||||||
// Tier 5: Live Market Data
|
// Tier 5: Live Market Data
|
||||||
runSource('YFinance', yfinance),
|
runSource('YFinance', yfinance),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
// Each runSource has its own 30s timeout, so allSettled will resolve
|
||||||
|
// within ~30s even if APIs hang. Global timeout is a safety net.
|
||||||
|
const results = await Promise.allSettled(allPromises);
|
||||||
|
|
||||||
const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message });
|
const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message });
|
||||||
const totalMs = Date.now() - start;
|
const totalMs = Date.now() - start;
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) {
|
|||||||
if (apiKey) payload.registrationkey = apiKey;
|
if (apiKey) payload.registrationkey = apiKey;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
const res = await fetch(base, {
|
const res = await fetch(base, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
|
|||||||
@@ -130,18 +130,26 @@ fetch('/api/health')
|
|||||||
.catch(() => startCountdown(0));
|
.catch(() => startCountdown(0));
|
||||||
|
|
||||||
// === SSE — wait for sweep to complete, then redirect ===
|
// === SSE — wait for sweep to complete, then redirect ===
|
||||||
|
let redirected = false;
|
||||||
|
function goToDashboard() {
|
||||||
|
if (redirected) return;
|
||||||
|
redirected = true;
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
barFill.style.transition = 'width 0.4s ease';
|
||||||
|
barFill.style.width = '100%';
|
||||||
|
etaText.textContent = '';
|
||||||
|
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
|
||||||
|
setTimeout(() => location.replace('/'), 800);
|
||||||
|
}
|
||||||
|
|
||||||
const es = new EventSource('/events');
|
const es = new EventSource('/events');
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'update') {
|
if (msg.type === 'update') {
|
||||||
es.close();
|
es.close();
|
||||||
clearInterval(countdownInterval);
|
goToDashboard();
|
||||||
barFill.style.transition = 'width 0.4s ease';
|
|
||||||
barFill.style.width = '100%';
|
|
||||||
etaText.textContent = '';
|
|
||||||
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
|
|
||||||
setTimeout(() => location.replace('/'), 800);
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
@@ -149,6 +157,13 @@ es.onerror = () => {
|
|||||||
es.close();
|
es.close();
|
||||||
setTimeout(() => location.reload(), 3000);
|
setTimeout(() => location.reload(), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// === Fallback polling — in case SSE misses the update ===
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
fetch('/api/data').then(r => {
|
||||||
|
if (r.ok) goToDashboard();
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
19
server.mjs
19
server.mjs
@@ -408,7 +408,7 @@ async function start() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('listening', () => {
|
server.on('listening', async () => {
|
||||||
console.log(`[Crucix] Server running on http://localhost:${port}`);
|
console.log(`[Crucix] Server running on http://localhost:${port}`);
|
||||||
|
|
||||||
// Auto-open browser
|
// Auto-open browser
|
||||||
@@ -420,17 +420,18 @@ async function start() {
|
|||||||
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
|
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to load existing data first for instant display
|
// Try to load existing data first for instant display (await so dashboard shows immediately)
|
||||||
try {
|
try {
|
||||||
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
|
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
|
||||||
synthesize(existing).then(data => {
|
const data = await synthesize(existing);
|
||||||
currentData = data;
|
currentData = data;
|
||||||
console.log('[Crucix] Loaded existing data from runs/latest.json');
|
console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
|
||||||
broadcast({ type: 'update', data: currentData });
|
broadcast({ type: 'update', data: currentData });
|
||||||
}).catch(() => {});
|
} catch {
|
||||||
} catch { /* no existing data */ }
|
console.log('[Crucix] No existing data found — first sweep required');
|
||||||
|
}
|
||||||
|
|
||||||
// Run first sweep
|
// Run first sweep (refreshes data in background)
|
||||||
console.log('[Crucix] Running initial sweep...');
|
console.log('[Crucix] Running initial sweep...');
|
||||||
runSweepCycle().catch(err => {
|
runSweepCycle().catch(err => {
|
||||||
console.error('[Crucix] Initial sweep failed:', err.message || err);
|
console.error('[Crucix] Initial sweep failed:', err.message || err);
|
||||||
|
|||||||
Reference in New Issue
Block a user