mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
e56eb3e130
commit
ae1ca8a3de
11 files changed
+673
-3
No files matched your search
+3
-3
@@ -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.
|
||||
|
||||
@@ -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) });
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Всё в порядке",
|
||||
|
||||
@@ -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": "一切正常",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user