diff --git a/api/AGENTS.md b/api/AGENTS.md index 632265a1..b638ba86 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -14,9 +14,9 @@ services, service-status poller), parts of which the frontend also imports Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` / `ipapi-com` / `ipapi-is` / `ip2location-io` / `ip-sb` / `ipcheck-ing` / `maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` / -`cf-radar` / `asn-history` / `asn-connectivity` / `ooni-blocking` / -`globalping-probes` / `service-status` / `google-map` / `github-stars` / -`invisibility-test` / `dns-leak-test`), user +`cf-radar` / `net-outages` / `asn-history` / `asn-connectivity` / +`ooni-blocking` / `globalping-probes` / `service-status` / `google-map` / +`github-stars` / `invisibility-test` / `dns-leak-test`), user proxies (`get-user-info` / `update-user-achievement`), platform (`configs` / `sentry-tunnel` / `share-report`). Each file's header comment states its route and purpose — read those for specifics. diff --git a/api/net-outages.js b/api/net-outages.js new file mode 100644 index 00000000..c4395241 --- /dev/null +++ b/api/net-outages.js @@ -0,0 +1,119 @@ +// Handler for /api/outages — global internet outage feed for the Pulse panel. +// Merges two Cloudflare Radar sources into one recent-events list: +// - /radar/annotations/outages (manually verified outages, with a cause) +// - /radar/traffic_anomalies (auto-detected drops, all statuses) +// A verified anomaly is often promoted to an outage entry for the same event, +// so anomalies that match an outage's subject and start time are dropped. +import { fetchUpstream } from '../common/fetch-with-timeout.js'; +import logger from '../common/logger.js'; + +const DATE_RANGE = '30d'; +const MAX_EVENTS = 30; +// An anomaly counts as a duplicate of an outage when they share a subject +// (location or ASN) and start within this window of each other. +const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000; + +const fetchFromRadar = async (endpoint) => { + const url = `https://api.cloudflare.com/client/v4${endpoint}`; + const response = await fetchUpstream(url, { + headers: { + 'Authorization': `Bearer ${process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + throw new Error(`Cloudflare Radar responded ${response.status}`); + } + return response.json(); +}; + +// Both kinds normalize to one flat shape; anomaly-only fields stay null on +// outages and vice versa. `endDate: null` means the event is still ongoing. +export const normalizeOutages = (annotations) => annotations.map((entry) => ({ + kind: 'outage', + id: `o-${entry.id}`, + startDate: entry.startDate, + endDate: entry.endDate || null, + locations: (entry.locationsDetails || []).map((loc) => loc.code), + asns: (entry.asnsDetails || []).map((detail) => ({ asn: Number(detail.asn), name: detail.name })), + cause: entry.outage?.outageCause || 'UNKNOWN', + level: entry.outage?.outageType || null, + description: entry.description || null, + scope: entry.scope || null, + linkedUrl: entry.linkedUrl || null, +})); + +// Anomaly statuses hidden from the feed. Live data carries values beyond the +// documented VERIFIED/UNVERIFIED pair (e.g. TP = third-party confirmed), so +// this is a blocklist, not an allowlist. Empty today — every status ships; +// add 'UNVERIFIED' here to restrict the feed to confirmed events only. +const EXCLUDED_ANOMALY_STATUSES = new Set([]); + +export const normalizeAnomalies = (anomalies) => anomalies + .filter((entry) => !EXCLUDED_ANOMALY_STATUSES.has(entry.status)) + .map((entry) => ({ + kind: 'anomaly', + id: `a-${entry.uuid}`, + startDate: entry.startDate, + endDate: entry.endDate || null, + locations: entry.locationDetails?.code ? [entry.locationDetails.code] : [], + asns: entry.asnDetails?.asn + ? [{ asn: Number(entry.asnDetails.asn), name: entry.asnDetails.name }] + : [], + cause: null, + level: null, + description: null, + scope: null, + linkedUrl: null, +})); + +export const mergeEvents = (outages, anomalies) => { + const isDuplicate = (anomaly) => outages.some((outage) => { + const sharesSubject = anomaly.locations.some((code) => outage.locations.includes(code)) + || anomaly.asns.some((a) => outage.asns.some((o) => o.asn === a.asn)); + if (!sharesSubject) return false; + return Math.abs(Date.parse(anomaly.startDate) - Date.parse(outage.startDate)) <= DEDUPE_WINDOW_MS; + }); + // Two-level order, consumed by the frontend as-is: ongoing events first + // (endDate null), ended ones after, newest-first inside each group. The + // cap then favors ongoing events by construction. + return [...outages, ...anomalies.filter((anomaly) => !isDuplicate(anomaly))] + .sort((a, b) => { + const aOngoing = a.endDate === null; + const bOngoing = b.endDate === null; + if (aOngoing !== bOngoing) return aOngoing ? -1 : 1; + return Date.parse(b.startDate) - Date.parse(a.startDate); + }) + .slice(0, MAX_EVENTS); +}; + +export default async (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + if (!process.env.CLOUDFLARE_API_KEY && !process.env.CLOUDFLARE_API) { + return res.status(500).json({ error: 'API key is missing' }); + } + + // One failed source degrades to a partial feed; both failing is an error. + const [outagesResult, anomaliesResult] = await Promise.allSettled([ + fetchFromRadar(`/radar/annotations/outages?dateRange=${DATE_RANGE}&limit=50`), + fetchFromRadar(`/radar/traffic_anomalies?dateRange=${DATE_RANGE}&limit=100`), + ]); + + if (outagesResult.status === 'rejected' && anomaliesResult.status === 'rejected') { + logger.error({ err: outagesResult.reason }, 'net-outages: both Radar sources failed'); + return res.status(500).json({ error: 'Internal server error' }); + } + if (outagesResult.status === 'rejected' || anomaliesResult.status === 'rejected') { + const failed = outagesResult.status === 'rejected' ? outagesResult : anomaliesResult; + logger.warn({ err: failed.reason }, 'net-outages: one Radar source failed'); + } + + const outages = normalizeOutages( + outagesResult.status === 'fulfilled' ? outagesResult.value?.result?.annotations || [] : []); + const anomalies = normalizeAnomalies( + anomaliesResult.status === 'fulfilled' ? anomaliesResult.value?.result?.trafficAnomalies || [] : []); + + res.json({ events: mergeEvents(outages, anomalies) }); +}; diff --git a/backend-server.js b/backend-server.js index 34389462..68c44e78 100644 --- a/backend-server.js +++ b/backend-server.js @@ -21,6 +21,7 @@ import ipsbHandler from './api/ip-sb.js'; import maxmindHandler from './api/maxmind.js'; // Others import cfHander from './api/cf-radar.js'; +import netOutagesHandler from './api/net-outages.js'; import asnHistoryHandler from './api/asn-history.js'; import asnConnectivityHandler from './api/asn-connectivity.js'; import ooniBlockingHandler from './api/ooni-blocking.js'; @@ -251,6 +252,8 @@ app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler); // Feature flags derived from env vars — they only change on a redeploy, so // an hour of caching is safe. app.get('/api/configs', cacheable(ONE_HOUR_CACHE), validateConfigs); +// Radar's outage feed moves on an hourly-ish cadence +app.get('/api/outages', cacheable(ONE_HOUR_CACHE), netOutagesHandler); // OONI aggregates cover a 30-day window aligned to UTC days — the payload // only drifts as new measurements land, so 1 day of edge cache keeps us polite // to OONI's free API without the view going meaningfully stale. diff --git a/frontend/components/widgets/Pulse.vue b/frontend/components/widgets/Pulse.vue index ebde594a..c10a458d 100644 --- a/frontend/components/widgets/Pulse.vue +++ b/frontend/components/widgets/Pulse.vue @@ -107,6 +107,10 @@ + + + @@ -202,6 +206,7 @@ import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { Separator } from '@/components/ui/separator'; import { JnTooltip } from '@/components/ui/tooltip'; +import PulseOutages from '@/components/widgets/PulseOutages.vue'; import { Icon } from '@iconify/vue'; import { Check, Orbit, ClockFading } from '@lucide/vue'; diff --git a/frontend/components/widgets/PulseOutages.vue b/frontend/components/widgets/PulseOutages.vue new file mode 100644 index 00000000..dd1f75a9 --- /dev/null +++ b/frontend/components/widgets/PulseOutages.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 907801f7..c9430a28 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -202,6 +202,35 @@ "alreadySent": "This status has already been sent", "rateLimited": "Only one status can be shared in a short period", "limitedTitle": "Limited-time status", + "outages": { + "title": "Global Outage Events", + "outage": "Internet Outage", + "anomaly": "Traffic Anomaly", + "ongoing": "Ongoing", + "ended": "Ended", + "started": "Started {time}", + "lasted": "Lasted {duration}", + "quiet": "All quiet — no outage events in the past 30 days", + "loadMore": "Load more", + "cause": { + "GOVERNMENT_DIRECTED": "Government Directed", + "POWER_OUTAGE": "Power Outage", + "CABLE_CUT": "Cable Cut", + "TECHNICAL_PROBLEM": "Technical Problem", + "UNKNOWN": "Unknown Cause", + "WEATHER": "Severe Weather", + "FIRE": "Fire", + "CYBERATTACK": "Cyberattack", + "NATURAL_DISASTER": "Natural Disaster", + "MAINTENANCE": "Maintenance", + "MILITARY_ACTION": "Military Action" + }, + "level": { + "NATIONWIDE": "Nationwide", + "REGIONAL": "Regional", + "NETWORK": "Network" + } + }, "statuses": { "fast": "Blazing fast network", "ok": "All good here", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 67727e71..fd832c0f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -202,6 +202,35 @@ "alreadySent": "Ce statut a déjà été envoyé", "rateLimited": "Un seul statut possible sur une courte période", "limitedTitle": "Statut à durée limitée", + "outages": { + "title": "Pannes d'Internet dans le monde", + "outage": "Panne d'Internet", + "anomaly": "Anomalie de trafic", + "ongoing": "En cours", + "ended": "Terminé", + "started": "Commencé {time}", + "lasted": "A duré {duration}", + "quiet": "Tout est calme — aucune panne au cours des 30 derniers jours", + "loadMore": "Afficher plus", + "cause": { + "GOVERNMENT_DIRECTED": "Décision gouvernementale", + "POWER_OUTAGE": "Panne d'électricité", + "CABLE_CUT": "Câble sectionné", + "TECHNICAL_PROBLEM": "Problème technique", + "UNKNOWN": "Cause inconnue", + "WEATHER": "Intempéries", + "FIRE": "Incendie", + "CYBERATTACK": "Cyberattaque", + "NATURAL_DISASTER": "Catastrophe naturelle", + "MAINTENANCE": "Maintenance", + "MILITARY_ACTION": "Action militaire" + }, + "level": { + "NATIONWIDE": "National", + "REGIONAL": "Régional", + "NETWORK": "Réseau" + } + }, "statuses": { "fast": "Réseau ultra rapide", "ok": "Tout va bien ici", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 9c420cf3..e7890ae9 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -202,6 +202,35 @@ "alreadySent": "Этот статус уже отправлен", "rateLimited": "За короткое время можно отправить только один статус", "limitedTitle": "Статус на ограниченное время", + "outages": { + "title": "Глобальные сбои интернета", + "outage": "Сбой интернета", + "anomaly": "Аномалия трафика", + "ongoing": "Продолжается", + "ended": "Завершено", + "started": "Началось {time}", + "lasted": "Длилось {duration}", + "quiet": "Всё спокойно — за последние 30 дней сбоев не было", + "loadMore": "Показать больше", + "cause": { + "GOVERNMENT_DIRECTED": "По решению властей", + "POWER_OUTAGE": "Отключение электроэнергии", + "CABLE_CUT": "Обрыв кабеля", + "TECHNICAL_PROBLEM": "Техническая неисправность", + "UNKNOWN": "Причина неизвестна", + "WEATHER": "Непогода", + "FIRE": "Пожар", + "CYBERATTACK": "Кибератака", + "NATURAL_DISASTER": "Стихийное бедствие", + "MAINTENANCE": "Плановые работы", + "MILITARY_ACTION": "Военные действия" + }, + "level": { + "NATIONWIDE": "По всей стране", + "REGIONAL": "Региональный", + "NETWORK": "Сеть" + } + }, "statuses": { "fast": "Сеть летает", "ok": "Всё в порядке", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index d0d47b79..166af753 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -202,6 +202,35 @@ "alreadySent": "状态已经发送过了", "rateLimited": "短期内只能发一次状态", "limitedTitle": "限时状态", + "outages": { + "title": "全球断网事件", + "outage": "互联网中断", + "anomaly": "流量异常", + "ongoing": "进行中", + "ended": "已结束", + "started": "{time}开始", + "lasted": "持续 {duration}", + "quiet": "一切平静——过去 30 天没有断网事件", + "loadMore": "加载更多", + "cause": { + "GOVERNMENT_DIRECTED": "政府主导断网", + "POWER_OUTAGE": "电力中断", + "CABLE_CUT": "光缆中断", + "TECHNICAL_PROBLEM": "技术故障", + "UNKNOWN": "原因未知", + "WEATHER": "恶劣天气", + "FIRE": "火灾", + "CYBERATTACK": "网络攻击", + "NATURAL_DISASTER": "自然灾害", + "MAINTENANCE": "计划维护", + "MILITARY_ACTION": "军事行动" + }, + "level": { + "NATIONWIDE": "全国范围", + "REGIONAL": "区域范围", + "NETWORK": "单一网络" + } + }, "statuses": { "fast": "网速快得飞起", "ok": "一切正常", diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index 7bd3a82e..13772bb8 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -18,6 +18,7 @@ import dnsResolverHandler from '../api/dns-resolver.js'; import getUserInfoHandler from '../api/get-user-info.js'; import getWhoisHandler from '../api/get-whois.js'; import cfRadarHandler from '../api/cf-radar.js'; +import netOutagesHandler from '../api/net-outages.js'; import invisibilityHandler from '../api/invisibility-test.js'; import macCheckerHandler from '../api/mac-checker.js'; import githubStarsHandler from '../api/github-stars.js'; @@ -293,6 +294,25 @@ describe('cf-radar handler', () => { }); }); +// -- net-outages handler ---------------------------------------------------- + +describe('net-outages handler', () => { + it('rejects non-GET methods', async () => { + const res = createResponse(); + await netOutagesHandler(createRequest({ method: 'POST' }), res); + assert.equal(res.statusCode, 405); + }); + + it('returns 500 when no Cloudflare API key is configured', async () => { + delete process.env.CLOUDFLARE_API_KEY; + delete process.env.CLOUDFLARE_API; + const res = createResponse(); + await netOutagesHandler(createRequest(), res); + assert.equal(res.statusCode, 500); + assert.deepEqual(res.body, { error: 'API key is missing' }); + }); +}); + // -- invisibility-test handler -------------------------------------------- describe('invisibility-test handler', () => { diff --git a/tests/net-outages.test.js b/tests/net-outages.test.js new file mode 100644 index 00000000..74698f10 --- /dev/null +++ b/tests/net-outages.test.js @@ -0,0 +1,170 @@ +// Unit tests for the pure normalize/merge pipeline in api/net-outages.js: +// Radar payload → flat event shape, anomaly-vs-outage dedupe, sort and cap. +// Fixtures mirror real /radar/annotations/outages and /radar/traffic_anomalies +// responses (see the field names — they are the upstream contract). +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { normalizeOutages, normalizeAnomalies, mergeEvents } from '../api/net-outages.js'; + +const outageFixture = { + id: '1645', + dataSource: 'ALL', + description: 'A 7.4-magnitude earthquake in western Colombia', + scope: null, + startDate: '2026-08-10T12:30:00Z', + endDate: null, + locations: ['CO'], + asns: [], + eventType: 'OUTAGE', + linkedUrl: null, + asnsDetails: [], + locationsDetails: [{ name: 'Colombia', code: 'CO' }], + outage: { outageCause: 'NATURAL_DISASTER', outageType: 'NATIONWIDE' }, +}; + +const anomalyFixture = { + uuid: '7dbd7c69-6fe3-4090-ab64-de4194c283b7', + type: 'LOCATION', + status: 'VERIFIED', + startDate: '2026-08-08T17:00:00Z', + endDate: '2026-08-08T20:15:00Z', + locationDetails: { code: 'GA', name: 'Gabon' }, + asnDetails: null, + originDetails: null, +}; + +describe('normalizeOutages', () => { + it('maps a Radar outage annotation to the flat event shape', () => { + const [event] = normalizeOutages([outageFixture]); + assert.deepEqual(event, { + kind: 'outage', + id: 'o-1645', + startDate: '2026-08-10T12:30:00Z', + endDate: null, + locations: ['CO'], + asns: [], + cause: 'NATURAL_DISASTER', + level: 'NATIONWIDE', + description: 'A 7.4-magnitude earthquake in western Colombia', + scope: null, + linkedUrl: null, + }); + }); + + it('numbers stringly-typed ASNs and survives a missing outage object', () => { + const [event] = normalizeOutages([{ + ...outageFixture, + outage: undefined, + asnsDetails: [{ asn: '27725', name: 'ETECSA' }], + }]); + assert.deepEqual(event.asns, [{ asn: 27725, name: 'ETECSA' }]); + assert.equal(event.cause, 'UNKNOWN'); + assert.equal(event.level, null); + }); +}); + +describe('normalizeAnomalies', () => { + it('maps a location anomaly, leaving outage-only fields null', () => { + const [event] = normalizeAnomalies([anomalyFixture]); + assert.equal(event.kind, 'anomaly'); + assert.equal(event.id, 'a-7dbd7c69-6fe3-4090-ab64-de4194c283b7'); + assert.deepEqual(event.locations, ['GA']); + assert.deepEqual(event.asns, []); + assert.equal(event.cause, null); + assert.equal(event.level, null); + }); + + it('keeps every anomaly status (the exclusion blocklist ships empty)', () => { + const events = normalizeAnomalies([ + { ...anomalyFixture, uuid: 'u-1', status: 'UNVERIFIED' }, + { ...anomalyFixture, uuid: 'u-2', status: 'TP' }, + { ...anomalyFixture, uuid: 'u-3', status: 'VERIFIED' }, + ]); + assert.deepEqual(events.map((e) => e.id), ['a-u-1', 'a-u-2', 'a-u-3']); + }); + + it('maps an AS anomaly to an asns entry with no locations', () => { + const [event] = normalizeAnomalies([{ + ...anomalyFixture, + type: 'AS', + locationDetails: null, + asnDetails: { asn: 27653, name: 'Comteco' }, + }]); + assert.deepEqual(event.locations, []); + assert.deepEqual(event.asns, [{ asn: 27653, name: 'Comteco' }]); + }); +}); + +describe('mergeEvents', () => { + const outages = normalizeOutages([outageFixture]); + + it('drops an anomaly that shares a location and start window with an outage', () => { + const anomalies = normalizeAnomalies([{ + ...anomalyFixture, + locationDetails: { code: 'CO', name: 'Colombia' }, + startDate: '2026-08-10T12:30:00Z', + }]); + const merged = mergeEvents(outages, anomalies); + assert.equal(merged.length, 1); + assert.equal(merged[0].kind, 'outage'); + }); + + it('drops an anomaly that shares an ASN with an outage', () => { + const cubaOutage = normalizeOutages([{ + ...outageFixture, + id: '1611', + locationsDetails: [{ name: 'Cuba', code: 'CU' }], + asnsDetails: [{ asn: '27725', name: 'ETECSA' }], + startDate: '2026-08-03T02:45:00Z', + }]); + const anomalies = normalizeAnomalies([{ + ...anomalyFixture, + type: 'AS', + locationDetails: null, + asnDetails: { asn: 27725, name: 'ETECSA' }, + startDate: '2026-08-03T02:45:00Z', + }]); + const merged = mergeEvents(cubaOutage, anomalies); + assert.equal(merged.length, 1); + assert.equal(merged[0].kind, 'outage'); + }); + + it('keeps an anomaly whose subject matches but starts outside the window', () => { + const anomalies = normalizeAnomalies([{ + ...anomalyFixture, + locationDetails: { code: 'CO', name: 'Colombia' }, + startDate: '2026-08-01T00:00:00Z', + }]); + assert.equal(mergeEvents(outages, anomalies).length, 2); + }); + + it('orders ongoing before ended, newest-first inside each group', () => { + // Ended CO outage is newer than everything; the older ongoing events + // must still lead, themselves in reverse start order. + const endedOutage = normalizeOutages([{ + ...outageFixture, id: '1', endDate: '2026-08-10T18:00:00Z', + }]); + const ongoingOld = normalizeAnomalies([{ + ...anomalyFixture, uuid: 'u-old', startDate: '2026-08-01T00:00:00Z', endDate: null, + }]); + const ongoingNew = normalizeAnomalies([{ + ...anomalyFixture, + uuid: 'u-new', + locationDetails: { code: 'GN', name: 'Guinea' }, + startDate: '2026-08-05T00:00:00Z', + endDate: null, + }]); + const merged = mergeEvents(endedOutage, [...ongoingOld, ...ongoingNew]); + assert.deepEqual(merged.map((e) => e.id), ['a-u-new', 'a-u-old', 'o-1']); + }); + + it('caps the merged feed at 30 events', () => { + const many = normalizeOutages(Array.from({ length: 40 }, (_, i) => ({ + ...outageFixture, + id: String(i), + startDate: `2026-07-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`, + }))); + assert.equal(mergeEvents(many, []).length, 30); + }); +});