The 2026-07-14 backend refactor (PR #1) moved 13 features' route logic out of server.js into src/features/*/routes.ts as Fastify plugins, and left "// Migrated to src/features/X/" comments behind -- but never actually built or started anything that served those plugins. Found a complete, unused Fastify app bootstrap already sitting at src/api/server.ts (added in an earlier commit, 5554c1a, alongside the BGP hijack/PDF-export/ASPA-adoption work) that imports and registers all 16 route modules, including 3 (pdf-export, aspa-adoption, hijack-alerts) with proper Postgres-backed implementations, tests, and PDF templates -- more capable than the puppeteer-based inline version kept from the production snapshot. Also confirmed there is no code anywhere that calls initializeDatabase() or startApiServer(): this Fastify server had never actually been run, not even once. What this commit does: - Fixes 6 real TypeScript compile errors blocking `tsc` from building the affected modules cleanly (all pre-existing, none introduced by this branch): - src/routes/hijack-alerts.ts: route generic was missing `Body` in its type param, so it didn't match the handler's own declared type - src/features/aspa-adoption/scheduler.ts: node-cron v4's ScheduledTask dropped `.destroy()` (relevant now that this branch already bumped node-cron 3->4 for the uuid CVE fix) -- `.stop()` alone is sufficient - src/features/{bgp-communities,rpki-history}/routes.ts: `response.json()` typed as `{}` under this @types/node version, cast to `any` to match this file's existing loose-typing style - src/features/pdf-export/cache-manager.ts: `NodeJS.Timer` -> `NodeJS.Timeout` (what setInterval/clearInterval actually use) - src/features/pdf-export/renderer.ts: this uses Playwright, not Puppeteer -- `timeout` isn't a page.pdf() option in Playwright, moved to page.setDefaultTimeout() before the call - Adds src/api/index.ts as the actual entry point (there wasn't one). Calls initializeDatabase() before dynamically importing ./server.js, because src/routes/hijack-alerts.ts calls getDatabase() at module load time -- a static top-level import of ./server would have crashed on startup before initializeDatabase() ever ran. Verified this boots cleanly and serves real responses (changelog-data, ix-matrix, rib/prefix graceful-503, bgp-communities with a live RIPE Stat call) in local testing. - Wires server.js to reverse-proxy the 11 genuinely-orphaned paths (submarine-cables, global-infra, communities, irr-audit, asset-expand, rpki-history, aspath, looking-glass, ix-matrix, changelog-data, rib/*, prefix-changes) to this new internal server via a plain http.request() proxy -- chosen over exposing a second Cloudflare Tunnel ingress rule so this stays a same-origin, internal-only change with no DNS/tunnel config to touch. Verified the proxy mechanism itself against a live instance of the new server. Deliberately did NOT proxy /api/webhooks, /api/hijacks, or /api/hijack-subscribe -- server.js already serves those paths inline (file-backed, live, working), and src/routes/hijack-alerts.ts registers the same path names against an empty Postgres table. Proxying would have silently shadowed working data with nothing. - Default port 3102, not 3100: checked Erik and found 3100 already bound by an unrelated docker-proxy container. 3102 is free and reads as "the second peercortex port" next to the existing 3101. - Adds a `peercortex-api` PM2 app to ecosystem.config.js on Erik (backed up first via rollback-standard.sh) -- config only, NOT started. Nothing is deployed to Erik as part of this commit: no dist/ upload, no `npm install` for fastify/pg on Erik, no `pm2 start`. server.js and public/index.html on Erik are untouched.
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
import cron from 'node-cron'
|
|
import { ASNSampler } from './sampler'
|
|
import { ASPACollector } from './collector'
|
|
import { AdoptionAggregator } from './aggregator'
|
|
import { AdoptionForecaster } from './forecaster'
|
|
import { ASPADatabaseClient } from './db-client'
|
|
|
|
export class ASPAAdoptionScheduler {
|
|
private task: cron.ScheduledTask | null = null
|
|
private sampler = new ASNSampler()
|
|
private collector = new ASPACollector()
|
|
private aggregator = new AdoptionAggregator()
|
|
private forecaster = new AdoptionForecaster()
|
|
private dbClient = new ASPADatabaseClient()
|
|
private isRunning = false
|
|
|
|
/**
|
|
* Start the daily ASPA adoption sampling job
|
|
* Runs at 2 AM UTC every day (0 2 * * *)
|
|
*/
|
|
start(): void {
|
|
if (this.task) {
|
|
console.log('[ASPA Scheduler] Job already scheduled')
|
|
return
|
|
}
|
|
|
|
this.task = cron.schedule('0 2 * * *', () => {
|
|
if (!this.isRunning) {
|
|
this.runDailyJob().catch((error) => {
|
|
console.error('[ASPA Scheduler] Daily job failed:', error)
|
|
})
|
|
}
|
|
})
|
|
|
|
console.log('[ASPA Scheduler] Scheduled daily job at 2:00 AM UTC')
|
|
}
|
|
|
|
/**
|
|
* Stop the scheduled job
|
|
*/
|
|
stop(): void {
|
|
if (this.task) {
|
|
this.task.stop()
|
|
this.task = null
|
|
console.log('[ASPA Scheduler] Job stopped')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manually trigger the daily job (useful for testing)
|
|
*/
|
|
async runManual(): Promise<void> {
|
|
if (this.isRunning) {
|
|
throw new Error('Job is already running')
|
|
}
|
|
|
|
await this.runDailyJob()
|
|
}
|
|
|
|
private async runDailyJob(): Promise<void> {
|
|
this.isRunning = true
|
|
const startTime = Date.now()
|
|
|
|
try {
|
|
console.log('[ASPA Scheduler] Starting daily adoption snapshot')
|
|
|
|
const sampleDate = new Date()
|
|
sampleDate.setUTCHours(0, 0, 0, 0)
|
|
|
|
// Step 1: Stratified sample of ASNs
|
|
console.log('[ASPA Scheduler] Sampling ASNs...')
|
|
const samplingResult = this.sampler.sample(750, {
|
|
stratifiedByRegion: true,
|
|
minASNsPerRegion: 100,
|
|
})
|
|
console.log(`[ASPA Scheduler] Sampled ${samplingResult.totalSampled} ASNs`)
|
|
|
|
// Step 2: Collect ASPA data for sampled ASNs
|
|
console.log('[ASPA Scheduler] Collecting ASPA data...')
|
|
const snapshots = await this.collector.collect(samplingResult.asns, {
|
|
maxRetries: 3,
|
|
timeoutMs: 5000,
|
|
rateLimit: 100,
|
|
})
|
|
console.log(`[ASPA Scheduler] Collected ASPA data for ${snapshots.length} ASNs`)
|
|
|
|
// Step 3: Aggregate by region and IXP
|
|
console.log('[ASPA Scheduler] Aggregating data by region and IXP...')
|
|
const { overall, regions, ixps } = this.aggregator.aggregate(snapshots, {
|
|
includeRegions: true,
|
|
includeIXPs: true,
|
|
})
|
|
console.log(
|
|
`[ASPA Scheduler] Aggregation complete: ${overall.withASPA}/${overall.total} ASNs with ASPA (${overall.coverage}%)`
|
|
)
|
|
|
|
// Step 4: Fetch historical data and forecast
|
|
console.log('[ASPA Scheduler] Calculating forecast...')
|
|
const history = await this.dbClient.getAdoptionHistory(90)
|
|
const timeSeriesData = history
|
|
.reverse()
|
|
.map((h) => ({
|
|
date: h.sampleDate,
|
|
coverage: h.coveragePercentage,
|
|
}))
|
|
const forecast = this.forecaster.forecast(timeSeriesData, {
|
|
historyDays: 90,
|
|
forecastMonths: 6,
|
|
regressionAlpha: 0.05,
|
|
})
|
|
console.log(`[ASPA Scheduler] Forecast: ${forecast.predictedCoverage6m}% coverage in 6 months (confidence: ${forecast.confidence})`)
|
|
|
|
// Step 5: Store results in database
|
|
console.log('[ASPA Scheduler] Storing results...')
|
|
const topAdopters = snapshots
|
|
.filter((s) => s.hasASPA)
|
|
.sort((a, b) => b.providers.length - a.providers.length)
|
|
.slice(0, 10)
|
|
.map((s) => ({
|
|
asn: s.asn,
|
|
name: `AS${s.asn}`,
|
|
providers: s.providers.length,
|
|
}))
|
|
|
|
const metadata = {
|
|
samplingMethod: 'stratified-regional',
|
|
forecastData: forecast,
|
|
dataCollectionTimeMs: Date.now() - startTime,
|
|
}
|
|
|
|
await this.dbClient.recordAdoptionSnapshot(sampleDate, overall.total, overall.withASPA, regions, topAdopters, metadata)
|
|
await this.dbClient.recordRegionalBreakdown(sampleDate, regions)
|
|
await this.dbClient.recordIXPBreakdown(sampleDate, ixps)
|
|
|
|
console.log(`[ASPA Scheduler] Daily job completed in ${Date.now() - startTime}ms`)
|
|
} catch (error) {
|
|
console.error('[ASPA Scheduler] Job failed:', error instanceof Error ? error.message : String(error))
|
|
throw error
|
|
} finally {
|
|
this.isRunning = false
|
|
}
|
|
}
|
|
|
|
isJobRunning(): boolean {
|
|
return this.isRunning
|
|
}
|
|
|
|
isScheduled(): boolean {
|
|
return this.task !== null
|
|
}
|
|
}
|
|
|
|
export const aspaScheduler = new ASPAAdoptionScheduler()
|