/** * Unified infrastructure service module -- replaces two legacy services: * - src/services/outages.ts (Cloudflare Radar internet outages) * - ServiceStatusPanel's direct /api/service-status fetch * * All data now flows through the InfrastructureServiceClient RPC. */ import { getRpcBaseUrl } from '@/services/rpc-client'; import type { ListInternetDdosAttacksResponse, ListInternetOutagesResponse, ListInternetTrafficAnomaliesResponse, ListServiceStatusesResponse, InternetOutage as ProtoOutage, ServiceStatus as ProtoServiceStatus } from '@/generated/client/worldmonitor/infrastructure/v1/service_client'; import type { InternetOutage } from '@/types'; import { createCircuitBreaker } from '@/utils'; import { isFeatureAvailable } from '../runtime-config'; import { getHydratedData } from '@/services/bootstrap'; import { InfrastructureServiceClient } from '@/services/generated-rpc-clients'; // ---- Client + Circuit Breakers ---- const client = new InfrastructureServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); const outageBreaker = createCircuitBreaker({ name: 'Internet Outages', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); const statusBreaker = createCircuitBreaker({ name: 'Service Statuses', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); const ddosBreaker = createCircuitBreaker({ name: 'DDoS Attacks', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); const trafficAnomaliesBreaker = createCircuitBreaker({ name: 'Traffic Anomalies', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); const emptyOutageFallback: ListInternetOutagesResponse = { outages: [], pagination: undefined }; const emptyStatusFallback: ListServiceStatusesResponse = { statuses: [] }; const emptyDdosFallback: ListInternetDdosAttacksResponse = { protocol: [], vector: [], dateRangeStart: '', dateRangeEnd: '', topTargetLocations: [] }; const emptyAnomaliesFallback: ListInternetTrafficAnomaliesResponse = { anomalies: [], totalCount: 0 }; // ---- Proto enum -> legacy string adapters ---- const SEVERITY_REVERSE: Record = { OUTAGE_SEVERITY_PARTIAL: 'partial', OUTAGE_SEVERITY_MAJOR: 'major', OUTAGE_SEVERITY_TOTAL: 'total', }; const STATUS_REVERSE: Record = { SERVICE_OPERATIONAL_STATUS_OPERATIONAL: 'operational', SERVICE_OPERATIONAL_STATUS_DEGRADED: 'degraded', SERVICE_OPERATIONAL_STATUS_PARTIAL_OUTAGE: 'degraded', SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE: 'outage', SERVICE_OPERATIONAL_STATUS_MAINTENANCE: 'degraded', SERVICE_OPERATIONAL_STATUS_UNSPECIFIED: 'unknown', }; // ---- Adapter: proto InternetOutage -> legacy InternetOutage ---- function toOutage(proto: ProtoOutage): InternetOutage { return { id: proto.id, title: proto.title, link: proto.link, description: proto.description, pubDate: proto.detectedAt ? new Date(proto.detectedAt) : new Date(), country: proto.country, region: proto.region || undefined, lat: proto.location?.latitude ?? 0, lon: proto.location?.longitude ?? 0, severity: SEVERITY_REVERSE[proto.severity] || 'partial', categories: proto.categories, cause: proto.cause || undefined, outageType: proto.outageType || undefined, endDate: proto.endedAt ? new Date(proto.endedAt) : undefined, }; } // ======================================================================== // Internet Outages -- replaces src/services/outages.ts // ======================================================================== let outagesConfigured: boolean | null = null; export function isOutagesConfigured(): boolean | null { return outagesConfigured; } export async function fetchInternetOutages(): Promise { if (!isFeatureAvailable('internetOutages')) { outagesConfigured = false; return []; } const hydrated = getHydratedData('outages') as ListInternetOutagesResponse | undefined; const resp = (hydrated?.outages?.length ? hydrated : null) ?? await outageBreaker.execute(async () => { return client.listInternetOutages({ country: '', start: 0, end: 0, pageSize: 0, cursor: '', }); }, emptyOutageFallback, { shouldCache: (r) => r.outages.length > 0 }); if (resp.outages.length === 0) { if (outagesConfigured === null) outagesConfigured = false; return []; } outagesConfigured = true; return resp.outages.map(toOutage); } export function getOutagesStatus(): string { return outageBreaker.getStatus(); } // ======================================================================== // DDoS Attacks -- L3/L4 attack summaries from Cloudflare Radar // ======================================================================== export async function fetchDdosAttacks(): Promise { const hydrated = getHydratedData('ddosAttacks') as ListInternetDdosAttacksResponse | undefined; if (hydrated?.protocol?.length || hydrated?.vector?.length) return hydrated; return ddosBreaker.execute(async () => { return client.listInternetDdosAttacks({}); }, emptyDdosFallback, { shouldCache: (r) => r.protocol.length > 0 || r.vector.length > 0 }); } // ======================================================================== // Traffic Anomalies -- anomalous traffic patterns from Cloudflare Radar // ======================================================================== export async function fetchTrafficAnomalies(country?: string): Promise { const hydrated = getHydratedData('trafficAnomalies') as ListInternetTrafficAnomaliesResponse | undefined; if (hydrated?.anomalies !== undefined && !country) return hydrated; return trafficAnomaliesBreaker.execute(async () => { return client.listInternetTrafficAnomalies({ country: country || '' }); }, emptyAnomaliesFallback, { shouldCache: (r) => r.anomalies.length > 0 }); } // ======================================================================== // Service Statuses -- replaces direct /api/service-status fetch // ======================================================================== export interface ServiceStatusResult { id: string; name: string; category: string; status: 'operational' | 'degraded' | 'outage' | 'unknown'; description: string; } export interface ServiceStatusSummary { operational: number; degraded: number; outage: number; unknown: number; } export interface ServiceStatusResponse { success: boolean; timestamp: string; summary: ServiceStatusSummary; services: ServiceStatusResult[]; } // Category map for the service IDs (matches the handler's SERVICES list) const CATEGORY_MAP: Record = { aws: 'cloud', azure: 'cloud', gcp: 'cloud', cloudflare: 'cloud', vercel: 'cloud', netlify: 'cloud', digitalocean: 'cloud', render: 'cloud', railway: 'cloud', github: 'dev', gitlab: 'dev', npm: 'dev', docker: 'dev', bitbucket: 'dev', circleci: 'dev', jira: 'dev', confluence: 'dev', linear: 'dev', slack: 'comm', discord: 'comm', zoom: 'comm', notion: 'comm', openai: 'ai', anthropic: 'ai', replicate: 'ai', stripe: 'saas', twilio: 'saas', datadog: 'saas', sentry: 'saas', supabase: 'saas', }; function toServiceResult(proto: ProtoServiceStatus): ServiceStatusResult { return { id: proto.id, name: proto.name, category: CATEGORY_MAP[proto.id] || 'saas', status: STATUS_REVERSE[proto.status] || 'unknown', description: proto.description, }; } function computeSummary(services: ServiceStatusResult[]): ServiceStatusSummary { return { operational: services.filter((s) => s.status === 'operational').length, degraded: services.filter((s) => s.status === 'degraded').length, outage: services.filter((s) => s.status === 'outage').length, unknown: services.filter((s) => s.status === 'unknown').length, }; } export async function fetchServiceStatuses(): Promise { const hydrated = getHydratedData('serviceStatuses') as { statuses?: ProtoServiceStatus[] } | undefined; if (hydrated?.statuses?.length) { const services = hydrated.statuses.map(toServiceResult); return { success: true, timestamp: new Date().toISOString(), summary: computeSummary(services), services }; } const resp = await statusBreaker.execute(async () => { return client.listServiceStatuses({ status: 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED', }); }, emptyStatusFallback, { shouldCache: (r) => r.statuses.length > 0 }); const services = resp.statuses.map(toServiceResult); return { success: true, timestamp: new Date().toISOString(), summary: computeSummary(services), services, }; }