diff --git a/README.md b/README.md index 3739f19..89b1477 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,8 @@ This is normal — the first sweep takes 30–60 seconds to query all 27 sources Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`. +OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Crucix does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep. + ### Telegram bot not responding to commands Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot/getMe`. diff --git a/apis/sources/opensky.mjs b/apis/sources/opensky.mjs index 70d595b..655a513 100644 --- a/apis/sources/opensky.mjs +++ b/apis/sources/opensky.mjs @@ -69,6 +69,7 @@ export async function briefing() { const results = await Promise.all( hotspotEntries.map(async ([key, box]) => { const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax); + const error = data?.error || null; const states = data?.states || []; return { region: box.label, @@ -83,14 +84,25 @@ export async function briefing() { // Flag potentially interesting (military often have no callsign or specific patterns) noCallsign: states.filter(s => !s[1]?.trim()).length, highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude + ...(error ? { error } : {}), }; }) ); + const hotspotErrors = results + .filter(r => r.error) + .map(r => ({ region: r.region, error: r.error })); + return { source: 'OpenSky', timestamp: new Date().toISOString(), hotspots: results, + ...(hotspotErrors.length ? { + error: hotspotErrors.length === results.length + ? `OpenSky unavailable across all hotspots: ${hotspotErrors[0].error}` + : `OpenSky unavailable for ${hotspotErrors.length}/${results.length} hotspots`, + hotspotErrors, + } : {}), }; } diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index 3f294ac..bb6b32f 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -5,7 +5,7 @@ // // Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs -import { readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { exec } from 'child_process'; @@ -102,6 +102,49 @@ function sanitizeExternalUrl(raw) { } } +function sumAirHotspots(hotspots = []) { + return hotspots.reduce((sum, hotspot) => sum + (hotspot.totalAircraft || 0), 0); +} + +function summarizeAirHotspots(hotspots = []) { + return hotspots.map(h => ({ + region: h.region, + total: h.totalAircraft || 0, + noCallsign: h.noCallsign || 0, + highAlt: h.highAltitude || 0, + top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5), + })); +} + +function loadOpenSkyFallback(currentTimestamp) { + const runsDir = join(ROOT, 'runs'); + if (!existsSync(runsDir)) return null; + + const currentMs = currentTimestamp ? new Date(currentTimestamp).getTime() : NaN; + const files = readdirSync(runsDir) + .filter(name => /^briefing_.*\.json$/.test(name)) + .sort() + .reverse(); + + for (const file of files) { + const filePath = join(runsDir, file); + try { + const prior = JSON.parse(readFileSync(filePath, 'utf8')); + const priorTimestamp = prior.sources?.OpenSky?.timestamp || prior.crucix?.timestamp || null; + if (priorTimestamp && Number.isFinite(currentMs) && new Date(priorTimestamp).getTime() >= currentMs) continue; + + const hotspots = prior.sources?.OpenSky?.hotspots || []; + if (sumAirHotspots(hotspots) > 0) { + return { file, timestamp: priorTimestamp, hotspots }; + } + } catch { + // Ignore unreadable historical runs and continue searching backward. + } + } + + return null; +} + // === RSS Fetching === async function fetchRSS(url, source) { try { @@ -326,11 +369,12 @@ export function generateIdeas(V2) { // === Synthesize raw sweep data into dashboard format === export async function synthesize(data) { - const air = (data.sources.OpenSky?.hotspots || []).map(h => ({ - region: h.region, total: h.totalAircraft || 0, noCallsign: h.noCallsign || 0, - highAlt: h.highAltitude || 0, - top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5) - })); + const liveAirHotspots = data.sources.OpenSky?.hotspots || []; + const airFallback = sumAirHotspots(liveAirHotspots) > 0 + ? null + : loadOpenSkyFallback(data.sources.OpenSky?.timestamp || data.crucix?.timestamp); + const effectiveAirHotspots = airFallback?.hotspots || liveAirHotspots; + const air = summarizeAirHotspots(effectiveAirHotspots); const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({ region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0, hc: h.highConfidence || 0, @@ -511,6 +555,14 @@ export async function synthesize(data) { const V2 = { meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals, + airMeta: { + fallback: Boolean(airFallback), + liveTotal: sumAirHotspots(liveAirHotspots), + timestamp: airFallback?.timestamp || data.sources.OpenSky?.timestamp || data.crucix?.timestamp || null, + source: airFallback ? 'OpenSky fallback' : 'OpenSky', + ...(airFallback ? { fallbackFile: airFallback.file } : {}), + ...(data.sources.OpenSky?.error ? { error: data.sources.OpenSky.error } : {}), + }, sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 9ca9c06..46a52b0 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -97,6 +97,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s .econ-row .eval{font-family:var(--mono);font-weight:600} /* CENTER: MAP */ +.map-region-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--border);background:var(--panel);backdrop-filter:blur(20px)} .map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden} #globeViz{width:100%;height:100%;cursor:grab} #globeViz:active{cursor:grabbing} @@ -272,6 +273,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .topbar{padding:10px 12px} .top-left,.top-center,.top-right{width:100%} .top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} + .map-region-bar{display:none} .top-right{gap:6px} .region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px} .grid{display:flex;flex-direction:column} @@ -331,6 +333,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
+
@@ -389,6 +392,7 @@ let globeInitialized = false; let flightsVisible = true; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; let isFlat = shouldStartFlat(); +let currentRegion = 'world'; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; const signalGuideItems = [ { @@ -554,7 +558,26 @@ function togglePerfMode(){ } // === TOPBAR === +function getRegionControlsMarkup(){ + return ['world','americas','europe','middleEast','asiaPacific','africa'].map(r=> + `` + ).join(''); +} + +function renderRegionControls(){ + const mapRegionBar = document.getElementById('mapRegionBar'); + if(!mapRegionBar) return; + if(isMobileLayout()){ + mapRegionBar.innerHTML = ''; + mapRegionBar.style.display = 'none'; + return; + } + mapRegionBar.innerHTML = getRegionControlsMarkup(); + mapRegionBar.style.display = 'flex'; +} + function renderTopbar(){ + const mobile = isMobileLayout(); const ts = new Date(D.meta.timestamp); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); @@ -563,11 +586,7 @@ function renderTopbar(){ CRUCIX MONITOR WARTIME STAGFLATION RISK
-
- ${['world','americas','europe','middleEast','asiaPacific','africa'].map(r=> - `` - ).join('')} -
+ ${mobile ? `
${getRegionControlsMarkup()}
` : ''}
${t('dashboard.sweep','SWEEP')} ${(D.meta.totalDurationMs/1000).toFixed(1)}s @@ -577,6 +596,7 @@ function renderTopbar(){ ${t('dashboard.highAlert','HIGH ALERT')}
`; + renderRegionControls(); } // === LEFT RAIL === @@ -1077,6 +1097,13 @@ function toggleFlights() { flightsVisible = !flightsVisible; const btn = document.getElementById('flightToggle'); btn.classList.toggle('off', !flightsVisible); + if(isFlat){ + if(flatG){ + flatG.selectAll('*').remove(); + drawFlatMap(); + } + return; + } if(!globe){ return; } @@ -1149,13 +1176,6 @@ function initFlatMap(){ flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px') .style('display',k>=2.5?'block':'none'); - // Priority-based visibility: hide low-priority markers at low zoom - flatG.selectAll('[data-priority]').style('display',function(){ - const p=+this.dataset.priority; - if(p<=1) return 'block'; - if(p<=2) return k>=2?'block':'none'; - return k>=3.5?'block':'none'; - }); }); flatSvg.call(flatZoom); drawFlatMap(); @@ -1184,12 +1204,14 @@ function plotFlatMarkers(){ }; // Air const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; - D.air.forEach((a,i)=>{ - const c=airCoords[i];if(!c)return; - const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', - ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
High alt: ${a.highAlt}`,'Air Activity'),1); - if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); - }); + if(flightsVisible){ + D.air.forEach((a,i)=>{ + const c=airCoords[i];if(!c)return; + const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', + ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
High alt: ${a.highAlt}`,'Air Activity'),1); + if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); + }); + } // Thermal D.thermal.forEach(t=>t.fires.forEach(f=>{ addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', @@ -1237,25 +1259,27 @@ function plotFlatMarkers(){ g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); }); // Flight corridors - const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; - const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; - const cG=flatG.append('g').attr('class','corridors-layer'); - for(let i=0;i0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)'; - const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]); - const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); - const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}}; - cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80))); - }} - D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{ - if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return; - const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]); - const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); - cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6); - })}); + if(flightsVisible){ + const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; + const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; + const cG=flatG.append('g').attr('class','corridors-layer'); + for(let i=0;i0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)'; + const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]); + const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); + const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}}; + cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80))); + }} + D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{ + if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return; + const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]); + const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); + cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6); + })}); + } } // Update setRegion for flat mode @@ -1265,6 +1289,7 @@ const _origSetRegion = setRegion; const _origMapZoom = mapZoom; function setRegion(r){ + currentRegion = r; document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r)); closePopup(); if(isFlat && flatSvg && flatZoom){ @@ -1553,7 +1578,7 @@ function runBoot(){ document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})}); document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})}); },1000); - },4.0); + },[],4.0); } function isMobileLayout(){ return window.innerWidth <= 1100; } @@ -1644,6 +1669,7 @@ function syncResponsiveLayout(force=false){ const mobileNow = isMobileLayout(); if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){ lastResponsiveMobile = mobileNow; + renderTopbar(); renderLeftRail(); renderLower(); renderRight();