Feat(pulse): global internet outage broadcast

New /api/outages merges Cloudflare Radar verified outages and traffic
anomalies (30d window, anomaly-vs-outage dedupe, ongoing-first ordering,
1h edge cache); the Pulse Sheet renders them as a bulletin card list
with cause/level translated in all four locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-08-11 10:38:31 +08:00
1 parent e56eb3e130
commit ae1ca8a3de
11 files changed
+673 -3

No files matched your search

+3 -3
View File
@@ -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.
+119
View File
@@ -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) });
};
+3
View File
@@ -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.
+5
View File
@@ -107,6 +107,10 @@
</ul>
</section>
<!-- Global outage broadcast; renders its
own leading separator, collapses to nothing when empty. -->
<PulseOutages />
<Separator class="mt-7 mb-2" />
<!-- Per-country share + status mix; quiet placeholder when
empty or the fetch failed. -->
@@ -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';
@@ -0,0 +1,237 @@
<template>
<!-- Global internet outage broadcast via /api/outages,
rendered between the Pulse latest-events feed and the live map.
A successful fetch with zero events shows the quiet line ("no news is good news");
a FAILED fetch hides the section entirely. -->
<template v-if="loaded">
<Separator class="mt-7 mb-2" />
<section>
<h3 class="mb-3 flex items-center gap-1.5 text-sm font-semibold">
<RadioTower class="size-4 text-muted-foreground" />
{{ t('nav.pulse.outages.title') }}
</h3>
<p v-if="events.length === 0" class="py-4 text-center text-sm text-muted-foreground">
{{ t('nav.pulse.outages.quiet') }}
</p>
<ul v-else class="m-0 list-none divide-y rounded-lg border bg-card p-0">
<li v-for="event in visibleEvents" :key="event.id" class="px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<Badge variant="outline" class="px-1.5 py-0 text-[11px] font-semibold uppercase tracking-wide"
:class="event.kind === 'outage'
? 'border-destructive/60 text-destructive'
: 'border-primary/60 text-primary'">
{{ t('nav.pulse.outages.' + (event.kind === 'outage' ? 'outage' : 'anomaly')) }}
</Badge>
<span v-if="!event.endDate"
class="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-warning">
<span class="relative flex size-1.5">
<span
class="absolute inline-flex size-full animate-ping rounded-full bg-warning opacity-60"></span>
<span class="relative inline-flex size-1.5 rounded-full bg-warning"></span>
</span>
{{ t('nav.pulse.outages.ongoing') }}
</span>
<span v-else class="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-success">
<CircleCheck class="size-3.5" />
{{ t('nav.pulse.outages.ended') }}
</span>
</div>
<!-- Subject: who is affected. -->
<div class="mt-1.5 flex items-center gap-1.5 text-sm font-medium">
<Icon v-if="event.locations.length > 0" :icon="flagIcon(event.locations[0])"
class="size-4 shrink-0 rounded-full" />
<Network v-else-if="event.asns.length > 0"
class="size-4 shrink-0 text-muted-foreground" />
<Icon v-else icon="circle-flags:earth" class="size-4 shrink-0 rounded-full" />
<span class="truncate">{{ subjectName(event) }}</span>
</div>
<!-- Radar's editorial context, original (English) text. -->
<p v-if="event.description"
class="m-0 mt-1 wrap-break-word text-xs leading-relaxed text-muted-foreground">
{{ event.description }}
</p>
<!-- Cause / scope; hidden when the event carries neither. -->
<div v-if="metaLine(event)" class="mt-1 text-xs text-muted-foreground">
{{ metaLine(event) }}
</div>
<!-- Closing line: everything time-related, same slot for
both kinds — start age, plus duration once ended. -->
<div class="mt-0.5 text-xs tabular-nums text-muted-foreground/70">
{{ timeLine(event) }}
</div>
</li>
</ul>
<!-- Burst escape hatch: the collapsed view holds LIST_TOP entries
(ongoing events always included); one click reveals the full
backend feed (capped at 30 upstream). -->
<Button v-if="!expanded && events.length > visibleEvents.length" variant="ghost" size="sm"
class="mt-1 w-full cursor-pointer text-xs text-muted-foreground" @click="expanded = true">
{{ t('nav.pulse.outages.loadMore') }}
</Button>
</section>
</template>
</template>
<script>
// Module-level cache shared across mounts: the Sheet unmounts its content on
// close, so a plain onMounted fetch would refire on every open. The edge
// cache makes that cheap, but skipping the refetch entirely is nicer.
// `loaded` records that some fetch succeeded — an empty-but-loaded feed shows
// the quiet line, while a never-loaded one keeps the section hidden.
const cachedEvents = { value: [], at: 0, loaded: false };
</script>
<script setup>
// Outage broadcast section for the Pulse Sheet. Data comes from our backend
// (/api/outages, edge-cached 1h), which merges Cloudflare Radar's verified
// outages and traffic anomalies. Cause/level vocabularies are translated for
// the values Radar is known to emit; anything new falls back to a Title Case
// rendering of the raw token (the fields carry no enum contract upstream).
// Radar's free-text description stays in its original English by design.
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import getCountryName from '@/data/country-name.js';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Icon } from '@iconify/vue';
import { CircleCheck, Network, RadioTower } from '@lucide/vue';
const { t, locale } = useI18n();
// Radar cause/level values with a translation; see locales `nav.pulse.outages`.
const KNOWN_CAUSES = new Set([
'GOVERNMENT_DIRECTED', 'POWER_OUTAGE', 'CABLE_CUT', 'TECHNICAL_PROBLEM',
'UNKNOWN', 'WEATHER', 'FIRE', 'CYBERATTACK', 'NATURAL_DISASTER',
'MAINTENANCE', 'MILITARY_ACTION',
]);
const KNOWN_LEVELS = new Set(['NATIONWIDE', 'REGIONAL', 'NETWORK']);
const LIST_TOP = 8;
const CACHE_TTL_MS = 10 * 60 * 1000;
const events = ref(cachedEvents.value);
const loaded = ref(cachedEvents.loaded);
const expanded = ref(false);
// The backend delivers the display order: ongoing events first, ended ones
// after, newest-first inside each group. The collapsed view is therefore a
// plain prefix slice — widened past LIST_TOP only when the ongoing block
// alone exceeds it (every ongoing event is guaranteed a slot; a multi-day
// nationwide outage must not be pushed out by a burst of newer anomalies).
// "Load more" reveals the whole feed.
const visibleEvents = computed(() => {
if (expanded.value) return events.value;
const ongoingCount = events.value.filter((e) => !e.endDate).length;
return events.value.slice(0, Math.max(LIST_TOP, ongoingCount));
});
const loadEvents = async () => {
if (cachedEvents.loaded && Date.now() - cachedEvents.at < CACHE_TTL_MS) {
events.value = cachedEvents.value;
loaded.value = true;
return;
}
try {
const res = await fetchWithTimeout('/api/outages', { timeoutMs: 10000 });
if (!res.ok) return;
const data = await res.json();
cachedEvents.value = Array.isArray(data.events) ? data.events : [];
cachedEvents.at = Date.now();
cachedEvents.loaded = true;
events.value = cachedEvents.value;
loaded.value = true;
} catch {
/* silent — the section simply doesn't render */
}
};
onMounted(loadEvents);
// SNAKE_CASE token → Title Case phrase ("GOVERNMENT_DIRECTED" →
// "Government Directed") — the open-vocabulary fallback for values that
// don't have a translation yet.
const titleCase = (token) => (token || '')
.toLowerCase()
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const causeLabel = (cause) =>
(KNOWN_CAUSES.has(cause) ? t(`nav.pulse.outages.cause.${cause}`) : titleCase(cause));
const levelLabel = (level) =>
(KNOWN_LEVELS.has(level) ? t(`nav.pulse.outages.level.${level}`) : titleCase(level));
const flagIcon = (code) =>
`circle-flags:${/^[A-Za-z]{2}$/.test(code) ? code.toLowerCase() : 'xx'}`;
// Subject line: localized country names first, ASN entities second
// ("AS136442: OCEANWAVE-AS-AP"), and the scope text as a last resort (Radar
// occasionally files an event with no locations or ASNs at all — e.g.
// regional earthquake annotations; those still carry their story in the
// description paragraph).
const subjectName = (event) => {
if (event.locations.length > 0) {
return event.locations
.map((code) => getCountryName(code, locale.value) || code.toUpperCase())
.join(', ');
}
if (event.asns.length > 0) {
return event.asns
.map((a) => (a.name ? `AS${a.asn}: ${a.name}` : `AS${a.asn}`))
.join(', ');
}
return event.scope || '—';
};
// "Power Outage · Nationwide" — the what; times live in timeLine below.
const metaLine = (event) => [
event.cause ? causeLabel(event.cause) : null,
event.level ? levelLabel(event.level) : null,
].filter(Boolean).join(' · ');
// "Started 3h ago · Lasted 5h" — every time-related fact in one closing line.
const timeLine = (event) => [
t('nav.pulse.outages.started', { time: relTime(event.startDate) }),
event.endDate ? t('nav.pulse.outages.lasted', { duration: durationLabel(event) }) : null,
].filter(Boolean).join(' · ');
const relTime = (startDate) => {
const minutesAgo = Math.max(0, Math.round((Date.now() - Date.parse(startDate)) / 60000));
try {
const rtf = new Intl.RelativeTimeFormat(locale.value, { style: 'narrow' });
if (minutesAgo >= 48 * 60) return rtf.format(-Math.round(minutesAgo / (24 * 60)), 'day');
if (minutesAgo >= 60) return rtf.format(-Math.floor(minutesAgo / 60), 'hour');
return rtf.format(-minutesAgo, 'minute');
} catch {
return `-${minutesAgo}m`;
}
};
// Compact localized duration ("2d 3h" / "2 天 3 小时") via Intl.DurationFormat,
// falling back to plain d/h/m units where the API is unavailable.
const durationLabel = (event) => {
const ms = Date.parse(event.endDate) - Date.parse(event.startDate);
if (!Number.isFinite(ms) || ms <= 0) return '';
const totalMinutes = Math.round(ms / 60000);
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
// Two largest units are enough at bulletin granularity.
const parts = days > 0
? { days, hours }
: (hours > 0 ? { hours, minutes } : { minutes });
try {
return new Intl.DurationFormat(locale.value, { style: 'narrow' }).format(parts);
} catch {
return [
parts.days ? `${parts.days}d` : null,
parts.hours ? `${parts.hours}h` : null,
parts.minutes ? `${parts.minutes}m` : null,
].filter(Boolean).join(' ') || '0m';
}
};
</script>
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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": "Всё в порядке",
+29
View File
@@ -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": "一切正常",
+20
View File
@@ -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', () => {
+170
View File
@@ -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);
});
});