mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-12 18:16:20 +02:00
* fix(seeds): extend TTL on stale data instead of crashing on fetch errors Seed scripts crashed with process.exit(1) when upstream APIs returned errors (e.g., Wingbits 401), causing Redis keys to expire and panels to lose data. Now all seeds gracefully extend TTL on existing keys and exit 0, keeping stale data alive until the API recovers. - Add shared extendExistingTtl() helper to _seed-utils.mjs - Update runSeed() catch block (fixes 24 scripts using it) - Fix fetch-gpsjam.mjs, seed-airport-delays.mjs, seed-military-flights.mjs, seed-service-statuses.mjs * fix(seeds): preserve per-key TTLs when extending stale military data THEATER_POSTURE_BACKUP_KEY has a 7-day TTL (604800s) but was being extended with STALE_TTL (86400s), shortening it from 7 days to 1 day during upstream outages. Now each key group gets its original TTL.
301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
import { cellToLatLng } from 'h3-js';
|
|
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import { extendExistingTtl } from './_seed-utils.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const DATA_DIR = path.resolve(__dirname, 'data');
|
|
|
|
const REDIS_KEY_V2 = 'intelligence:gpsjam:v2';
|
|
const REDIS_KEY_V1 = 'intelligence:gpsjam:v1';
|
|
const REDIS_TTL = 172800; // 48h
|
|
|
|
const args = process.argv.slice(2);
|
|
function getArg(name, fallback) {
|
|
const idx = args.indexOf(`--${name}`);
|
|
return idx >= 0 && args[idx + 1] ? args[idx + 1] : fallback;
|
|
}
|
|
|
|
const outputPath = getArg('output', null);
|
|
|
|
function classifyRegion(lat, lon) {
|
|
if (lat >= 29 && lat <= 42 && lon >= 43 && lon <= 63) return 'iran-iraq';
|
|
if (lat >= 31 && lat <= 37 && lon >= 35 && lon <= 43) return 'levant';
|
|
if (lat >= 28 && lat <= 34 && lon >= 29 && lon <= 36) return 'israel-sinai';
|
|
if (lat >= 44 && lat <= 53 && lon >= 22 && lon <= 41) return 'ukraine-russia';
|
|
if (lat >= 54 && lat <= 70 && lon >= 27 && lon <= 60) return 'russia-north';
|
|
if (lat >= 36 && lat <= 42 && lon >= 26 && lon <= 45) return 'turkey-caucasus';
|
|
if (lat >= 32 && lat <= 38 && lon >= 63 && lon <= 75) return 'afghanistan-pakistan';
|
|
if (lat >= 10 && lat <= 20 && lon >= 42 && lon <= 55) return 'yemen-horn';
|
|
if (lat >= 0 && lat <= 12 && lon >= 32 && lon <= 48) return 'east-africa';
|
|
if (lat >= 15 && lat <= 24 && lon >= 25 && lon <= 40) return 'sudan-sahel';
|
|
if (lat >= 50 && lat <= 72 && lon >= -10 && lon <= 25) return 'northern-europe';
|
|
if (lat >= 35 && lat <= 50 && lon >= -10 && lon <= 25) return 'western-europe';
|
|
if (lat >= 1 && lat <= 8 && lon >= 95 && lon <= 108) return 'southeast-asia';
|
|
if (lat >= 20 && lat <= 45 && lon >= 100 && lon <= 145) return 'east-asia';
|
|
if (lat >= 25 && lat <= 50 && lon >= -125 && lon <= -65) return 'north-america';
|
|
return 'other';
|
|
}
|
|
|
|
function loadEnvFile() {
|
|
const envPath = path.join(__dirname, '..', '.env.local');
|
|
if (!existsSync(envPath)) return;
|
|
const lines = readFileSync(envPath, 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx === -1) continue;
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
let val = trimmed.slice(eqIdx + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
if (!process.env[key]) process.env[key] = val;
|
|
}
|
|
}
|
|
|
|
function maskToken(token) {
|
|
if (!token || token.length < 8) return '***';
|
|
return token.slice(0, 4) + '***' + token.slice(-4);
|
|
}
|
|
|
|
async function fetchWingbits(apiKey) {
|
|
const url = 'https://customer-api.wingbits.com/v1/gps/jam';
|
|
console.error(`[gpsjam] Fetching ${url}`);
|
|
|
|
const resp = await fetch(url, {
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
'User-Agent': 'WorldMonitor/1.0',
|
|
},
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`HTTP ${resp.status} from Wingbits API`);
|
|
}
|
|
|
|
const body = await resp.json();
|
|
|
|
if (!Array.isArray(body.hexes)) {
|
|
throw new Error(`Invalid response: body.hexes is not an array`);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
function processHexes(rawHexes) {
|
|
const results = [];
|
|
let skipped = 0;
|
|
let h3Failures = 0;
|
|
|
|
for (const hex of rawHexes) {
|
|
if (typeof hex.h3Index !== 'string') {
|
|
console.error(`[gpsjam] WARN: skipping hex with non-string h3Index: ${JSON.stringify(hex).slice(0, 100)}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (!Number.isFinite(hex.npAvg)) {
|
|
console.error(`[gpsjam] WARN: skipping hex ${hex.h3Index} — npAvg not finite: ${hex.npAvg}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (!Number.isInteger(hex.sampleCount) || hex.sampleCount < 0) {
|
|
console.error(`[gpsjam] WARN: skipping hex ${hex.h3Index} — invalid sampleCount: ${hex.sampleCount}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (!Number.isInteger(hex.aircraftCount) || hex.aircraftCount < 0) {
|
|
console.error(`[gpsjam] WARN: skipping hex ${hex.h3Index} — invalid aircraftCount: ${hex.aircraftCount}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
let level;
|
|
if (hex.npAvg <= 0.5) level = 'high';
|
|
else if (hex.npAvg <= 1.0) level = 'medium';
|
|
else continue; // skip low interference
|
|
|
|
let lat, lon;
|
|
try {
|
|
const [lt, ln] = cellToLatLng(hex.h3Index);
|
|
lat = Math.round(lt * 1e5) / 1e5;
|
|
lon = Math.round(ln * 1e5) / 1e5;
|
|
} catch {
|
|
console.error(`[gpsjam] WARN: h3 conversion failed for ${hex.h3Index}`);
|
|
h3Failures++;
|
|
continue;
|
|
}
|
|
|
|
results.push({
|
|
h3: hex.h3Index,
|
|
lat,
|
|
lon,
|
|
level,
|
|
npAvg: hex.npAvg,
|
|
sampleCount: hex.sampleCount,
|
|
aircraftCount: hex.aircraftCount,
|
|
region: classifyRegion(lat, lon),
|
|
});
|
|
}
|
|
|
|
if (h3Failures > rawHexes.length * 0.5) {
|
|
throw new Error(`>50% of hexes failed h3 conversion (${h3Failures}/${rawHexes.length}) — aborting seed`);
|
|
}
|
|
|
|
// Sort: high first, then by npAvg ascending (lower = worse)
|
|
results.sort((a, b) => {
|
|
if (a.level !== b.level) return a.level === 'high' ? -1 : 1;
|
|
return a.npAvg - b.npAvg;
|
|
});
|
|
|
|
console.error(`[gpsjam] Processed ${rawHexes.length} hexes → ${results.length} kept, ${skipped} invalid, ${h3Failures} h3 failures`);
|
|
|
|
return results;
|
|
}
|
|
|
|
async function seedRedis(output) {
|
|
const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
|
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
|
|
if (!redisUrl || !redisToken) {
|
|
console.error('[gpsjam] No UPSTASH_REDIS_REST_URL/TOKEN — skipping Redis seed');
|
|
return;
|
|
}
|
|
|
|
console.error(`[gpsjam] Seeding Redis keys "${REDIS_KEY_V2}" and "${REDIS_KEY_V1}"...`);
|
|
console.error(`[gpsjam] URL: ${redisUrl}`);
|
|
console.error(`[gpsjam] Token: ${maskToken(redisToken)}`);
|
|
|
|
const payload = JSON.stringify(output);
|
|
|
|
// Write v2
|
|
const v2Body = JSON.stringify(['SET', REDIS_KEY_V2, payload, 'EX', REDIS_TTL]);
|
|
const v2Resp = await fetch(redisUrl, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${redisToken}`, 'Content-Type': 'application/json' },
|
|
body: v2Body,
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
|
|
if (!v2Resp.ok) {
|
|
const text = await v2Resp.text().catch(() => '');
|
|
console.error(`[gpsjam] Redis SET v2 failed: HTTP ${v2Resp.status} — ${text.slice(0, 200)}`);
|
|
return;
|
|
}
|
|
console.error(`[gpsjam] Redis SET v2 result:`, await v2Resp.json());
|
|
|
|
// Dual-write v1 in old schema shape so pre-deploy code can parse it
|
|
const v1Output = {
|
|
...output,
|
|
source: output.source || 'wingbits',
|
|
hexes: output.hexes.map(hex => ({
|
|
h3: hex.h3,
|
|
lat: hex.lat,
|
|
lon: hex.lon,
|
|
level: hex.level,
|
|
region: hex.region,
|
|
pct: hex.npAvg <= 0.5 ? 15 : hex.npAvg <= 1.0 ? 5 : 0,
|
|
good: Math.max(0, hex.aircraftCount - hex.sampleCount),
|
|
bad: hex.sampleCount,
|
|
total: hex.aircraftCount,
|
|
})),
|
|
};
|
|
const v1Body = JSON.stringify(['SET', REDIS_KEY_V1, JSON.stringify(v1Output), 'EX', REDIS_TTL]);
|
|
const v1Resp = await fetch(redisUrl, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${redisToken}`, 'Content-Type': 'application/json' },
|
|
body: v1Body,
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
|
|
if (!v1Resp.ok) {
|
|
const text = await v1Resp.text().catch(() => '');
|
|
console.error(`[gpsjam] Redis SET v1 failed: HTTP ${v1Resp.status} — ${text.slice(0, 200)}`);
|
|
} else {
|
|
console.error(`[gpsjam] Redis SET v1 result:`, await v1Resp.json());
|
|
}
|
|
|
|
// Verify v2
|
|
const getResp = await fetch(`${redisUrl}/get/${encodeURIComponent(REDIS_KEY_V2)}`, {
|
|
headers: { Authorization: `Bearer ${redisToken}` },
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (getResp.ok) {
|
|
const getData = await getResp.json();
|
|
if (getData.result) {
|
|
const parsed = JSON.parse(getData.result);
|
|
console.error(`[gpsjam] Verified: ${parsed.hexes?.length} hexes in Redis (source: ${parsed.source})`);
|
|
}
|
|
}
|
|
|
|
// Write seed-meta
|
|
const metaKey = 'seed-meta:intelligence:gpsjam';
|
|
const meta = { fetchedAt: Date.now(), recordCount: output.hexes?.length || 0 };
|
|
const metaBody = JSON.stringify(['SET', metaKey, JSON.stringify(meta), 'EX', 604800]);
|
|
await fetch(redisUrl, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${redisToken}`, 'Content-Type': 'application/json' },
|
|
body: metaBody,
|
|
signal: AbortSignal.timeout(5_000),
|
|
}).catch(() => console.error('[gpsjam] seed-meta write failed'));
|
|
console.error(`[gpsjam] Wrote seed-meta: ${metaKey}`);
|
|
}
|
|
|
|
async function main() {
|
|
loadEnvFile();
|
|
const apiKey = process.env.WINGBITS_API_KEY;
|
|
if (!apiKey) {
|
|
console.error('[gpsjam] WINGBITS_API_KEY not set — cannot fetch data');
|
|
process.exit(1);
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await fetchWingbits(apiKey);
|
|
} catch (err) {
|
|
console.error(`[gpsjam] Fetch failed: ${err.message} — extending TTL on stale data`);
|
|
await extendExistingTtl([REDIS_KEY_V2, REDIS_KEY_V1], REDIS_TTL);
|
|
process.exit(0);
|
|
}
|
|
|
|
const hexes = processHexes(body.hexes);
|
|
|
|
const highCount = hexes.filter(r => r.level === 'high').length;
|
|
const mediumCount = hexes.filter(r => r.level === 'medium').length;
|
|
|
|
const output = {
|
|
fetchedAt: new Date().toISOString(),
|
|
source: 'wingbits',
|
|
stats: {
|
|
totalHexes: body.hexes.length,
|
|
highCount,
|
|
mediumCount,
|
|
},
|
|
hexes,
|
|
};
|
|
|
|
console.error(`[gpsjam] ${body.hexes.length} total hexes → ${highCount} high, ${mediumCount} medium`);
|
|
|
|
if (outputPath) {
|
|
mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
writeFileSync(path.resolve(outputPath), JSON.stringify(output, null, 2));
|
|
console.error(`[gpsjam] Written to ${outputPath}`);
|
|
} else {
|
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
const defaultPath = path.join(DATA_DIR, 'gpsjam-latest.json');
|
|
writeFileSync(defaultPath, JSON.stringify(output, null, 2));
|
|
console.error(`[gpsjam] Written to ${defaultPath}`);
|
|
process.stdout.write(JSON.stringify(output));
|
|
}
|
|
|
|
await seedRedis(output);
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(`[gpsjam] Fatal: ${err.message}`);
|
|
process.exit(1);
|
|
});
|