diff --git a/README.md b/README.md index 8212d0c3b..e59483d64 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) An open-source, all-in-one IP toolbox: IP lookup from multiple sources, connectivity tests, WebRTC & DNS-leak detection, speed test, MTR, censorship checks, Whois, and more — self-hostable with one Docker command. diff --git a/README_FR.md b/README_FR.md index 94d750318..71b2125af 100644 --- a/README_FR.md +++ b/README_FR.md @@ -20,8 +20,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Une boîte à outils IP tout-en-un et open source : recherche d'IP depuis plusieurs sources, tests de connectivité, détection WebRTC et de fuites DNS, test de vitesse, MTR, vérification de la censure, Whois et plus encore — auto-hébergeable en une seule commande Docker. diff --git a/README_PT-BR.md b/README_PT-BR.md index c4c47eae0..87452f319 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -20,8 +20,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Uma caixa de ferramentas de IP completa e de código aberto: consulta de IP a partir de múltiplas fontes, testes de conectividade, detecção de WebRTC e de vazamento de DNS, teste de velocidade, MTR, verificação de censura, Whois e muito mais — auto-hospedável com um único comando Docker. diff --git a/README_RU.md b/README_RU.md index 286bcfe0b..d2bc15657 100644 --- a/README_RU.md +++ b/README_RU.md @@ -20,8 +20,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Универсальный набор IP-инструментов с открытым исходным кодом: определение IP по нескольким источникам, проверка доступности сайтов, обнаружение утечек WebRTC и DNS, тест скорости, MTR, проверка цензуры, Whois и многое другое — разворачивается на своём сервере одной командой Docker. diff --git a/README_ZH_TW.md b/README_ZH-TW.md similarity index 97% rename from README_ZH_TW.md rename to README_ZH-TW.md index fe87da928..8de4cfbfb 100644 --- a/README_ZH_TW.md +++ b/README_ZH-TW.md @@ -20,8 +20,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) 一個開源的多合一 IP 工具箱:多來源 IP 查詢、連通性測試、WebRTC 與 DNS 洩漏偵測、網速測試、MTR、審查封鎖檢查、Whois 等——一條 Docker 指令即可自行架設。 diff --git a/README_ZH.md b/README_ZH.md index 5e72966be..66046eb78 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -20,8 +20,9 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) +[![CI](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jason5ng32/MyIP/actions/workflows/ci.yml) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) 一个开源的一站式 IP 工具箱:多来源 IP 查询、连通性测试、WebRTC 与 DNS 泄露检测、网速测试、MTR、封锁检测、Whois 等等 —— 一条 Docker 命令即可自行部署。 diff --git a/api/AGENTS.md b/api/AGENTS.md index b920d96b9..a4d34e105 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -14,13 +14,19 @@ 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` / `net-outages` / `asn-history` / `asn-connectivity` / +`cf-radar` / `asn-history` / `asn-connectivity` / `ooni-blocking` / `globalping-probes` / `service-status` / `google-map` / `github-stars` / `invisibility-test` / `dns-leak-test` / `persona`), 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. +The exception to one-file-per-route is Cloudflare Radar: all Radar data +rides the single `/api/cfradar` route, dispatched by `?view=` over the +`RADAR_VIEWS` registry in `common/cf-radar.js`. Each view declares its +guards, edge-cache TTL, and fetch function there — new Radar data means a +view function plus a registry row, never a new route. + `api/data/` holds contributor-editable static config consumed by handlers — currently `dns-resolvers.js`, the country-annotated resolver list behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). @@ -83,8 +89,10 @@ these checks: are reachable — that is what a DMARC or DKIM lookup needs. - `requireValidPrefix()` — `?prefix=` (CIDR); lets the frontend quantize to the BGP DFZ floor (/24 v4, /48 v6) for maximal CF edge-cache reuse. -- `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric - (`cf-radar` predates it and still validates inline). +- `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric. +- `requireValidCountry()` — `?country=` (alpha-2), uppercases in place for one + canonical edge-cache key. Syntactic only — an unassigned code just yields an + empty upstream series. - `requireValidProviderId()` — whitelists `?id=` against service-status slugs. - `requireValidRecordType()` — whitelists `?type=` against `DNS_RECORD_TYPES` in `common/dns-record-types.js` and uppercases it. That list is the single @@ -132,8 +140,11 @@ Leave the gate in place when a test covers it. ## Edge caching Every `/api/*` response defaults to `Cache-Control: no-store`; slowly-changing -public routes opt in via the `cacheable(maxAgeSeconds)` middleware in -`backend-server.js` — e.g. `app.get('/api/cfradar', cacheable(60 * 60), …)`. +public routes opt in via the `cacheable(maxAge)` middleware in +`backend-server.js` — e.g. `app.get('/api/whois', cacheable(24 * 60 * 60), …)`. +`maxAge` also accepts a `(req) => seconds` resolver for routes whose TTL +depends on the request (`/api/cfradar` reads it from the view registry); a +falsy resolution keeps the no-store default, so unknown views never cache. Write TTLs as multiplied expressions (`24 * 60 * 60`), not raw seconds. The middleware only sets `public, max-age=N` on status < 400, so CF never caches error pages; handlers themselves never touch `Cache-Control`. diff --git a/api/asn-connectivity.js b/api/asn-connectivity.js index c506a499b..d5b71f3ed 100644 --- a/api/asn-connectivity.js +++ b/api/asn-connectivity.js @@ -1,42 +1,63 @@ -// /api/asn-connectivity — BFS over CAIDA AS-Relationships starting at the -// origin AS, halting at Tier 1s or MAX_DEPTH. Returns a graph the frontend +// /api/asn-connectivity — layered BFS over CAIDA AS-Relationships starting at +// the origin AS, halting at Tier 1s or MAX_DEPTH. Returns a graph the frontend // renders dagre-style: origin (left) → intermediates → Tier 1s (right). // Inspired by bgp.tools' /as/#connectivity view. // +// Edges carry a kind: 'transit' (p2c, customer → provider) or 'peering' (p2p +// with a Tier 1). Peering matters because hypergiants buy no transit — their +// backbone connectivity is settlement-free peering with the Tier 1 clique, +// invisible to a p2c-only walk. +// // Data is fully local (common/as-rel-db.js + common/as-org-db.js), so the // whole BFS is synchronous. We only hit RIPEstat for as-overview as a // rare fallback when as2org doesn't have an ASN's org name. import { resolveAsnOrgName } from '../common/ripestat.js'; -import { providersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js'; +import { providersOf, peersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js'; import logger from '../common/logger.js'; // How deep to recurse from the origin. 3 covers regional networks reaching // Tier 1s through 1-2 intermediates; deeper just adds noise at the periphery. const MAX_DEPTH = 3; -// Per-node cap on non-Tier-1 providers to recurse into. Matters mainly for -// hyperscalers (Cloudflare-class has 100+ providers); ranked by customerCount -// as a proxy for "primary transit". +// Per-node cap on non-Tier-1 providers to recurse into, ranked by +// customerCount as a proxy for "primary transit". const MAX_INTERMEDIATE_BRANCH = 3; +// Past either bar, a node's non-Tier-1 "providers" are treated as CAIDA +// misinference and dropped. Peering-only bar: the hypergiant signature is +// clique peering (Google 12, Cloudflare 7). Combined bar: multihomed hosters +// with a real non-T1 upstream sit at ≤6 Tier-1 adjacencies. +const TIER1_PEERING_TRUSTED = 5; +const TIER1_ADJACENCY_TRUSTED = 8; + // Two-tier org name resolver lives in common/ripestat.js. No onError hook // here — connectivity stays silent on as-overview fallback failures (a node // just keeps name=null); asn-history is the one that warns. const resolveOrgName = (asn) => resolveAsnOrgName(asn); -async function buildGraph(origin) { - const nodes = new Map(); - const edgeSet = new Set(); - const orgPromises = new Map(); +// Default adjacency API for buildTopology; tests inject a fixture instead. +const asRelApi = { providersOf, peersOf, isTier1, customerCountOf }; - // If the queried AS is itself a Tier 1, mark it 'origin-tier1' so the - // frontend can render the combined origin+Tier1 styling; the rest of - // the BFS is a no-op (Tier 1s have no providers) and the graph is - // legitimately a single node. - const originType = isTier1(origin) ? 'origin-tier1' : 'origin'; - nodes.set(origin, { asn: origin, type: originType, name: null }); - orgPromises.set(origin, resolveOrgName(origin)); +// Pure synchronous topology walk — no org names, no I/O. Exported for tests. +export const buildTopology = (origin, rel = asRelApi) => { + const nodes = new Map(); + const edges = []; + const edgeKeys = new Set(); + + const addNode = (asn, type) => { + if (!nodes.has(asn)) nodes.set(asn, { asn, type, name: null }); + }; + // First kind wins on a duplicate pair; transit is always added first. + const addEdge = (from, to, kind) => { + const key = `${from}->${to}`; + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ from, to, kind }); + }; + + // A Tier-1 origin's graph is its peering edges into the rest of the clique. + addNode(origin, rel.isTier1(origin) ? 'origin-tier1' : 'origin'); let currentLayer = [origin]; @@ -45,31 +66,33 @@ async function buildGraph(origin) { const nextLayer = []; for (const asn of currentLayer) { - const providers = providersOf(asn); - if (providers.length === 0) continue; + const providers = rel.providersOf(asn); + const tier1Providers = providers.filter(p => rel.isTier1(p)); + const tier1Peers = rel.peersOf(asn).filter(p => rel.isTier1(p)); // Tier 1 hits are terminal — record the edge + node, no recursion. - for (const p of providers) { - if (!isTier1(p)) continue; - edgeSet.add(`${asn}->${p}`); - if (!nodes.has(p)) { - nodes.set(p, { asn: p, type: 'tier1', name: null }); - orgPromises.set(p, resolveOrgName(p)); - } + for (const p of tier1Providers) { + addEdge(asn, p, 'transit'); + addNode(p, 'tier1'); } + for (const p of tier1Peers) { + addEdge(asn, p, 'peering'); + addNode(p, 'tier1'); + } + + // Clique-adjacent node: non-Tier-1 providers are noise. + if (tier1Peers.length >= TIER1_PEERING_TRUSTED + || tier1Providers.length + tier1Peers.length >= TIER1_ADJACENCY_TRUSTED) continue; - // Non-Tier-1 providers: pick top-N by customerCount so when we - // truncate, the displayed ones are the more meaningful transits. const intermediates = providers - .filter(p => !isTier1(p)) - .sort((a, b) => customerCountOf(b) - customerCountOf(a)) + .filter(p => !rel.isTier1(p)) + .sort((a, b) => rel.customerCountOf(b) - rel.customerCountOf(a)) .slice(0, MAX_INTERMEDIATE_BRANCH); for (const p of intermediates) { - edgeSet.add(`${asn}->${p}`); + addEdge(asn, p, 'transit'); if (!nodes.has(p)) { - nodes.set(p, { asn: p, type: 'intermediate', name: null }); - orgPromises.set(p, resolveOrgName(p)); + addNode(p, 'intermediate'); nextLayer.push(p); } } @@ -78,24 +101,23 @@ async function buildGraph(origin) { currentLayer = nextLayer; } - // Await all org lookups. They've been running in the background since - // each node was discovered, so most are already settled. - for (const [asn, promise] of orgPromises) { + return pruneLeafIntermediates([...nodes.values()], edges); +}; + +async function buildGraph(origin) { + const { nodes, edges } = buildTopology(origin); + + // Org lookups run concurrently; a failed one just keeps name=null. + await Promise.all(nodes.map(async (node) => { try { - const name = await promise; - const node = nodes.get(asn); - if (node && name) node.name = name; + const name = await resolveOrgName(node.asn); + if (name) node.name = name; } catch { // node keeps name=null } - } + })); - const allNodes = [...nodes.values()]; - const allEdges = [...edgeSet].map(s => { - const [from, to] = s.split('->').map(Number); - return { from, to }; - }); - return pruneLeafIntermediates(allNodes, allEdges); + return { nodes, edges }; } // Iteratively drop intermediate nodes with no outgoing edge — visual diff --git a/api/cf-radar.js b/api/cf-radar.js index 8d82cf433..bcb9eae68 100644 --- a/api/cf-radar.js +++ b/api/cf-radar.js @@ -1,144 +1,43 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; +// /api/cfradar — single dispatch route for all Cloudflare Radar data. +// `?view=` selects an entry in RADAR_VIEWS (common/cf-radar.js): the view's +// guards validate and normalize its params, its fetch builds the payload, +// and the route's cache middleware in backend-server.js reads the view's +// per-view TTL. Guards run before the API-key check so param errors stay +// 400s on keyless deployments (and tests never reach an upstream call). + +import { RADAR_VIEWS, hasRadarApiKey } from '../common/cf-radar.js'; import logger from '../common/logger.js'; -// Common fetch request function. -// CLOUDFLARE_API is the pre-rename spelling — keep reading it so existing -// deployments don't lose the key on upgrade. -async function fetchFromCloudflare(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', - }, - }); - // Outage pages come back as HTML — fail on status instead of JSON.parse. - if (!response.ok) { - throw new Error(`Cloudflare Radar responded ${response.status}`); - } - return response.json(); -} - -// The five Radar segments backing one /api/cfradar response, keyed by the -// field name cleanUpResponseData expects. -const SEGMENTS = { - asnInfo: (asn) => `/radar/entities/asns/${asn}`, - ipVersion: (asn) => `/radar/http/summary/ip_version?asn=${asn}&dateRange=7d`, - httpProtocol: (asn) => `/radar/http/summary/http_protocol?asn=${asn}&dateRange=7d`, - deviceType: (asn) => `/radar/http/summary/device_type?asn=${asn}&dateRange=7d`, - botType: (asn) => `/radar/http/summary/bot_class?asn=${asn}&dateRange=7d`, -}; - -// Fetch all segments in parallel. A failed segment is dropped rather than -// failing the whole response — cleanUpResponseData / filterData already -// tolerate sparse data (small ASNs), so partial results degrade to missing -// fields. Logging is the handler's job: it warns on partial failure and -// errors only when every segment failed. -const getAllASNData = async (asn) => { - const names = Object.keys(SEGMENTS); - const settled = await Promise.allSettled(names.map((name) => fetchFromCloudflare(SEGMENTS[name](asn)))); - const data = {}; - const failed = []; - settled.forEach((result, i) => { - if (result.status === 'fulfilled') { - data[names[i]] = result.value; - } else { - failed.push({ name: names[i], reason: result.reason }); - } - }); - return { data, failed }; -}; - -// Validate asn is valid -function isValidASN(asn) { - return /^[0-9]+$/.test(asn); -}; - -// Clean up Cloudflare Radar return data to uniform field names. -// Optional-chaining everywhere because CF Radar returns sparse data for -// small / private / new ASNs (e.g. AS64512 is in the RFC 6996 private range -// and has no info at all; many smaller ASNs have asn info but no traffic -// summaries). Missing fields fall through as undefined and get stripped -// downstream in filterData via the NaN check. -function cleanUpResponseData(data) { - return { - asnName: data.asnInfo?.result?.asn?.name, - asnCountryCode: data.asnInfo?.result?.asn?.country, - asnOrgName: data.asnInfo?.result?.asn?.orgName, - estimatedUsers: data.asnInfo?.result?.asn?.estimatedUsers?.estimatedUsers, - IPv4_Pct: data.ipVersion?.result?.summary_0?.IPv4, - IPv6_Pct: data.ipVersion?.result?.summary_0?.IPv6, - HTTP_Pct: data.httpProtocol?.result?.summary_0?.http, - HTTPS_Pct: data.httpProtocol?.result?.summary_0?.https, - Desktop_Pct: data.deviceType?.result?.summary_0?.desktop, - Mobile_Pct: data.deviceType?.result?.summary_0?.mobile, - Bot_Pct: data.botType?.result?.summary_0?.bot, - Human_Pct: data.botType?.result?.summary_0?.human - }; -} - -// Format output - -function formatData(data) { - const { asnName, asnCountryCode, asnOrgName, estimatedUsers, IPv4_Pct, IPv6_Pct, HTTP_Pct, HTTPS_Pct, Desktop_Pct, Mobile_Pct, Bot_Pct, Human_Pct } = data; - const formattedData = { - asnName, - asnCountryCode, - asnOrgName, - estimatedUsers: parseFloat(estimatedUsers).toLocaleString(), - IPv4_Pct: `${parseFloat(IPv4_Pct).toFixed(2)}%`, - IPv6_Pct: `${parseFloat(IPv6_Pct).toFixed(2)}%`, - HTTP_Pct: `${parseFloat(HTTP_Pct).toFixed(2)}%`, - HTTPS_Pct: `${parseFloat(HTTPS_Pct).toFixed(2)}%`, - Desktop_Pct: `${parseFloat(Desktop_Pct).toFixed(2)}%`, - Mobile_Pct: `${parseFloat(Mobile_Pct).toFixed(2)}%`, - Bot_Pct: `${parseFloat(Bot_Pct).toFixed(2)}%`, - Human_Pct: `${parseFloat(Human_Pct).toFixed(2)}%` - }; +// Run a view's guard middlewares outside an Express chain. Each guard either +// calls next() or writes its own 4xx response — so "didn't pass" means the +// response has already been sent and the dispatcher must stop. +const runGuards = (guards, req, res) => guards.every((guard) => { + let passed = false; + guard(req, res, () => { passed = true; }); + return passed; +}); - return formattedData; - -} - -// Filter out non-existent fields -function filterData(data) { - for (const key in data) { - if (data[key] === 'NaN' || data[key] === 'NaN%') { - delete data[key]; - } - } - return data; -} - -// Export function export default async (req, res) => { - const asn = req.query.asn; - if (!asn) { - return res.status(400).json({ error: 'No ASN provided' }); + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method Not Allowed' }); + } + const viewName = req.query.view; + if (!viewName) { + return res.status(400).json({ error: 'No view provided' }); } - if (!isValidASN(asn)) { - return res.status(400).json({ error: 'Invalid ASN' }); + const view = RADAR_VIEWS[viewName]; + if (!view) { + return res.status(400).json({ error: 'Invalid view' }); + } + if (!runGuards(view.guards, req, res)) return; + if (!hasRadarApiKey()) { + return res.status(500).json({ error: 'API key is missing' }); } try { - const { data, failed } = await getAllASNData(asn); - - // One log line per request: a full wipe-out is a real failure - if (failed.length === Object.keys(SEGMENTS).length) { - logger.error({ err: failed[0].reason, asn }, 'cf-radar: all Radar segments failed'); - return res.status(500).json({ error: 'Internal server error' }); - } - if (failed.length > 0) { - logger.warn({ err: failed[0].reason, asn, segments: failed.map((f) => f.name) }, 'cf-radar: partial Radar segment failure'); - } - - const cleanedResponse = cleanUpResponseData(data); - const finalResponse = formatData(cleanedResponse); - filterData(finalResponse); - - res.json(finalResponse); + res.json(await view.fetch(req.query)); } catch (error) { - logger.error({ err: error, asn }, 'cf-radar handler failed'); + logger.error({ err: error, view: viewName }, 'cf-radar view failed'); res.status(500).json({ error: 'Internal server error' }); } -} \ No newline at end of file +}; diff --git a/api/net-outages.js b/api/net-outages.js deleted file mode 100644 index c4395241f..000000000 --- a/api/net-outages.js +++ /dev/null @@ -1,119 +0,0 @@ -// 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 af67af8db..0be912834 100644 --- a/backend-server.js +++ b/backend-server.js @@ -22,8 +22,8 @@ import ip2locationHandler from './api/ip2location-io.js'; 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 cfRadarHandler from './api/cf-radar.js'; +import { RADAR_VIEWS } from './common/cf-radar.js'; import asnHistoryHandler from './api/asn-history.js'; import asnConnectivityHandler from './api/asn-connectivity.js'; import ooniBlockingHandler from './api/ooni-blocking.js'; @@ -217,15 +217,21 @@ app.use('/api', (req, res, next) => { // attached on 2xx — CF must not cache 4xx/5xx error pages. The intended cache // value is also stashed on res.locals.cacheControl so binary-stream handlers // (which bypass res.json) can apply it themselves on their own 2xx path. -const cacheable = (maxAgeSeconds) => (req, res, next) => { - res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`; - const originalJson = res.json.bind(res); - res.json = function (body) { - if (res.statusCode < 400) { - res.setHeader('Cache-Control', res.locals.cacheControl); - } - return originalJson(body); - }; +// `maxAge` is a number of seconds, or a `(req) => seconds` resolver for +// routes whose TTL depends on the request (the /api/cfradar view registry); +// a falsy resolution keeps the /api-wide no-store default. +const cacheable = (maxAge) => (req, res, next) => { + const maxAgeSeconds = typeof maxAge === 'function' ? maxAge(req) : maxAge; + if (maxAgeSeconds) { + res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`; + const originalJson = res.json.bind(res); + res.json = function (body) { + if (res.statusCode < 400) { + res.setHeader('Cache-Control', res.locals.cacheControl); + } + return originalJson(body); + }; + } next(); }; @@ -256,8 +262,6 @@ 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. @@ -265,10 +269,13 @@ app.get('/api/ooni-blocking', requireValidDomain(), cacheable(ONE_DAY_CACHE), oo // Which countries have online Globalping probes — coverage changes slowly, // and the pickers fail open anyway, so a week of edge cache is fine. app.get('/api/globalping-probes', cacheable(SEVEN_DAYS_CACHE), globalpingProbesHandler); +// All Cloudflare Radar data rides one route; `?view=` picks the dataset and +// the TTL comes from that view's registry entry (common/cf-radar.js) — 30d +// for the slow-moving ASN/traffic profiles, 1h for the outage feed. +app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl), cfRadarHandler); // Cache for 30 days — registry / historical data that changes on a monthly // (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection, // and append-only BGP routing history. -app.get('/api/cfradar', cacheable(THIRTY_DAYS_CACHE), cfHander); app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler); app.get('/api/asn-connectivity', requireValidASN(), cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler); app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); diff --git a/common/as-rel-db.js b/common/as-rel-db.js index 6151a8c78..12af32fed 100644 --- a/common/as-rel-db.js +++ b/common/as-rel-db.js @@ -2,8 +2,8 @@ // by common/caida-updater.js; this module parses and serves. // // CAIDA as-rel2 row format: `|||` -// rel = -1 → p2c (a is provider of b) ← we keep these -// rel = 0 → p2p (peering) ← skipped +// rel = -1 → p2c (a is provider of b) ← providers index +// rel = 0 → p2p (peering) ← peers index // rel = 1 → s2s (sibling) ← skipped // // Source: https://publicdata.caida.org/datasets/as-relationships/serial-2/ @@ -20,6 +20,9 @@ export const AS_REL_FILE = 'as-rel2.txt'; // customer ASN → Set. Hot path of asn-connectivity. const providersIndex = new Map(); +// ASN → Set, symmetric p2p. Feeds asn-connectivity's peering edges. +const peersIndex = new Map(); + // ASN → number of distinct customers it provides transit for. Ranking // signal when an intermediate has more providers than MAX_INTERMEDIATE_BRANCH // can show; roughly correlates with customer-cone size. @@ -50,22 +53,31 @@ function findSnapshot() { return withMtime[0].full; } +function addToIndex(index, key, value) { + let set = index.get(key); + if (!set) { + set = new Set(); + index.set(key, set); + } + set.add(value); +} + function parsePipeText(text) { for (const rawLine of text.split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const parts = line.split('|'); - if (parts.length < 3 || parts[2] !== '-1') continue; - const provider = Number(parts[0]); - const customer = Number(parts[1]); - if (!provider || !customer) continue; - let set = providersIndex.get(customer); - if (!set) { - set = new Set(); - providersIndex.set(customer, set); + if (parts.length < 3) continue; + const a = Number(parts[0]); + const b = Number(parts[1]); + if (!a || !b) continue; + if (parts[2] === '-1') { + addToIndex(providersIndex, b, a); + customerCount.set(a, (customerCount.get(a) || 0) + 1); + } else if (parts[2] === '0') { + addToIndex(peersIndex, a, b); + addToIndex(peersIndex, b, a); } - set.add(provider); - customerCount.set(provider, (customerCount.get(provider) || 0) + 1); } } @@ -82,6 +94,7 @@ function loadDatabase() { if (!filePath) { logger.warn({ dir: AS_REL_DB_DIR }, '⚠️ CAIDA as-rel snapshot not found; asn-connectivity will return empty graphs until the updater downloads one'); providersIndex.clear(); + peersIndex.clear(); customerCount.clear(); tier1Set.clear(); loadedFrom = null; @@ -91,11 +104,12 @@ function loadDatabase() { try { const text = fs.readFileSync(filePath, 'utf8'); providersIndex.clear(); + peersIndex.clear(); customerCount.clear(); parsePipeText(text); rebuildTier1Set(); loadedFrom = path.basename(filePath); - logger.info(`📦 CAIDA as-rel loaded (${loadedFrom}) — ${providersIndex.size} customers, ${customerCount.size} providers, ${tier1Set.size} Tier 1s in ${Date.now() - start}ms`); + logger.info(`📦 CAIDA as-rel loaded (${loadedFrom}) — ${providersIndex.size} customers, ${customerCount.size} providers, ${peersIndex.size} peering ASes, ${tier1Set.size} Tier 1s in ${Date.now() - start}ms`); } catch (error) { logger.warn({ err: error, path: filePath }, '⚠️ Failed to parse CAIDA as-rel snapshot'); } @@ -115,6 +129,12 @@ export function providersOf(asn) { return set ? [...set] : []; } +/** Settlement-free peers of an ASN. Empty array when none observed. */ +export function peersOf(asn) { + const set = peersIndex.get(Number(asn)); + return set ? [...set] : []; +} + /** How many distinct ASes this one provides transit for. 0 when never a provider. */ export function customerCountOf(asn) { return customerCount.get(Number(asn)) || 0; diff --git a/common/cf-radar.js b/common/cf-radar.js new file mode 100644 index 000000000..65960e40d --- /dev/null +++ b/common/cf-radar.js @@ -0,0 +1,359 @@ +// Cloudflare Radar data layer behind the single /api/cfradar route. Each +// RADAR_VIEWS entry is one `?view=` value: the guard middlewares that +// validate/normalize its query params (reused from common/guards.js), the +// edge-cache TTL the route middleware in backend-server.js applies, and the +// fetch function that produces the response payload. api/cf-radar.js +// dispatches over this registry — adding Radar data means one view function +// plus one registry row here, never a new route. + +import { fetchUpstream } from './fetch-with-timeout.js'; +import { requireValidASN, requireValidCountry } from './guards.js'; +import { providersOf, peersOf, customerCountOf } from './as-rel-db.js'; +import logger from './logger.js'; + +// -- shared Radar client ---------------------------------------------------- + +// CLOUDFLARE_API is the pre-rename spelling — keep reading it so existing +// deployments don't lose the key on upgrade. +export const hasRadarApiKey = () => + Boolean(process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API); + +export async function fetchFromCloudflare(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', + }, + }); + // Outage pages come back as HTML — fail on status instead of JSON.parse. + if (!response.ok) { + throw new Error(`Cloudflare Radar responded ${response.status}`); + } + return response.json(); +} + +// -- view: asn — per-ASN entity info + 7d HTTP traffic profile -------------- + +// The five Radar segments backing one response, keyed by the field name +// cleanUpResponseData expects. +const SEGMENTS = { + asnInfo: (asn) => `/radar/entities/asns/${asn}`, + ipVersion: (asn) => `/radar/http/summary/ip_version?asn=${asn}&dateRange=7d`, + httpProtocol: (asn) => `/radar/http/summary/http_protocol?asn=${asn}&dateRange=7d`, + deviceType: (asn) => `/radar/http/summary/device_type?asn=${asn}&dateRange=7d`, + botType: (asn) => `/radar/http/summary/bot_class?asn=${asn}&dateRange=7d`, + routesStats: (asn) => `/radar/bgp/routes/stats?asn=${asn}`, + rels: (asn) => `/radar/entities/asns/${asn}/rel`, + quality: (asn) => `/radar/quality/speed/summary?asn=${asn}`, +}; + +// Fetch all segments in parallel. A failed segment is dropped rather than +// failing the whole response — cleanUpResponseData / filterData already +// tolerate sparse data (small ASNs), so partial results degrade to missing +// fields. Only a full wipe-out is an error (fetchAsnProfile throws). +const getAllASNData = async (asn) => { + const names = Object.keys(SEGMENTS); + const settled = await Promise.allSettled(names.map((name) => fetchFromCloudflare(SEGMENTS[name](asn)))); + const data = {}; + const failed = []; + settled.forEach((result, i) => { + if (result.status === 'fulfilled') { + data[names[i]] = result.value; + } else { + failed.push({ name: names[i], reason: result.reason }); + } + }); + return { data, failed }; +}; + +// Clean up Cloudflare Radar return data to uniform field names. +// Optional-chaining everywhere because CF Radar returns sparse data for +// small / private / new ASNs (e.g. AS64512 is in the RFC 6996 private range +// and has no info at all; many smaller ASNs have asn info but no traffic +// summaries). Missing fields fall through as undefined and get stripped +// downstream in filterData via the NaN check. +function cleanUpResponseData(data) { + return { + asnName: data.asnInfo?.result?.asn?.name, + asnCountryCode: data.asnInfo?.result?.asn?.country, + asnOrgName: data.asnInfo?.result?.asn?.orgName, + estimatedUsers: data.asnInfo?.result?.asn?.estimatedUsers?.estimatedUsers, + IPv4_Pct: data.ipVersion?.result?.summary_0?.IPv4, + IPv6_Pct: data.ipVersion?.result?.summary_0?.IPv6, + HTTP_Pct: data.httpProtocol?.result?.summary_0?.http, + HTTPS_Pct: data.httpProtocol?.result?.summary_0?.https, + Desktop_Pct: data.deviceType?.result?.summary_0?.desktop, + Mobile_Pct: data.deviceType?.result?.summary_0?.mobile, + Bot_Pct: data.botType?.result?.summary_0?.bot, + Human_Pct: data.botType?.result?.summary_0?.human, + prefixesV4: data.routesStats?.result?.stats?.distinct_prefixes_ipv4, + prefixesV6: data.routesStats?.result?.stats?.distinct_prefixes_ipv6, + speedDownload: data.quality?.result?.summary_0?.bandwidthDownload, + speedUpload: data.quality?.result?.summary_0?.bandwidthUpload, + latency: data.quality?.result?.summary_0?.latencyIdle, + jitter: data.quality?.result?.summary_0?.jitterIdle, + }; +} + +// Distinct relationship partners from Radar /rel rows. A pair listed as both +// transit and peer counts as transit only (same dedupe as asn-connectivity). +export const countAsnRels = (rows, asn) => { + const upstreams = new Set(); + const downstreams = new Set(); + const peers = new Set(); + for (const row of rows) { + if (row.rel === 'provider-customer') { + if (row.asn2 === asn) upstreams.add(row.asn1); + else if (row.asn1 === asn) downstreams.add(row.asn2); + } else if (row.rel === 'peer') { + peers.add(row.asn1 === asn ? row.asn2 : row.asn1); + } + } + for (const p of upstreams) peers.delete(p); + for (const p of downstreams) peers.delete(p); + return { upstreamCount: upstreams.size, downstreamCount: downstreams.size, peerCount: peers.size }; +}; + +// Rel counts prefer Radar's path-observed rows; when the segment failed or +// came back empty, fall back to the local CAIDA snapshot. All-zero counts +// (AS unknown to both) are dropped so the frontend hides the fields. +function resolveRelCounts(rows, asn) { + const counts = Array.isArray(rows) && rows.length > 0 + ? countAsnRels(rows, asn) + : { + upstreamCount: providersOf(asn).length, + downstreamCount: customerCountOf(asn), + peerCount: peersOf(asn).length, + }; + return Object.values(counts).every(v => v === 0) ? {} : counts; +} + +// Format output +function formatData(data) { + const { asnName, asnCountryCode, asnOrgName, estimatedUsers, IPv4_Pct, IPv6_Pct, HTTP_Pct, HTTPS_Pct, Desktop_Pct, Mobile_Pct, Bot_Pct, Human_Pct, prefixesV4, prefixesV6, upstreamCount, downstreamCount, peerCount, speedDownload, speedUpload, latency, jitter } = data; + return { + asnName, + asnCountryCode, + asnOrgName, + estimatedUsers: parseFloat(estimatedUsers).toLocaleString(), + prefixesV4: parseFloat(prefixesV4).toLocaleString(), + prefixesV6: parseFloat(prefixesV6).toLocaleString(), + upstreamCount: parseFloat(upstreamCount).toLocaleString(), + downstreamCount: parseFloat(downstreamCount).toLocaleString(), + peerCount: parseFloat(peerCount).toLocaleString(), + speedDownload: `${parseFloat(speedDownload).toFixed(1)} Mbps`, + speedUpload: `${parseFloat(speedUpload).toFixed(1)} Mbps`, + latency: `${Math.round(parseFloat(latency))} ms`, + jitter: `${parseFloat(jitter).toFixed(1)} ms`, + IPv4_Pct: `${parseFloat(IPv4_Pct).toFixed(2)}%`, + IPv6_Pct: `${parseFloat(IPv6_Pct).toFixed(2)}%`, + HTTP_Pct: `${parseFloat(HTTP_Pct).toFixed(2)}%`, + HTTPS_Pct: `${parseFloat(HTTPS_Pct).toFixed(2)}%`, + Desktop_Pct: `${parseFloat(Desktop_Pct).toFixed(2)}%`, + Mobile_Pct: `${parseFloat(Mobile_Pct).toFixed(2)}%`, + Bot_Pct: `${parseFloat(Bot_Pct).toFixed(2)}%`, + Human_Pct: `${parseFloat(Human_Pct).toFixed(2)}%`, + }; +} + +// Filter out non-existent fields — a missing upstream value formats into a +// string leading with "NaN" whatever unit suffix it carries. +function filterData(data) { + for (const key in data) { + if (String(data[key]).startsWith('NaN')) { + delete data[key]; + } + } + return data; +} + +const fetchAsnProfile = async ({ asn }) => { + const { data, failed } = await getAllASNData(asn); + if (failed.length === Object.keys(SEGMENTS).length) { + throw failed[0].reason instanceof Error + ? failed[0].reason + : new Error('all Radar segments failed'); + } + if (failed.length > 0) { + logger.warn({ err: failed[0].reason, asn, segments: failed.map((f) => f.name) }, 'cf-radar: partial Radar segment failure'); + } + const cleaned = cleanUpResponseData(data); + Object.assign(cleaned, resolveRelCounts(data.rels?.result?.rels, Number(asn))); + return filterData(formatData(cleaned)); +}; + +// -- view: country-traffic — country online-activity heatmap ---------------- + +// Aggregates Radar's hourly HTTP-requests timeseries (requests track people +// being online better than NetFlows bytes) into a Monday-first 7×24 +// weekday/hour matrix. Country-level on purpose: global ASNs have no single +// diurnal rhythm, and ~250 cache keys let the 30-day edge cache absorb +// nearly every request. +// +// 28d × 1h ≈ 4 samples per weekday/hour cell — one week alone would let a +// single holiday skew the heatmap. +const seriesEndpoint = (country, humanOnly) => + `/radar/http/timeseries?location=${country}${humanOnly ? '&botClass=LIKELY_HUMAN' : ''}&dateRange=28d&aggInterval=1h`; + +// Mean of Radar's MIN0_MAX-normalized values per weekday/hour cell, re-scaled +// so the busiest cell is 1. Stays in UTC — the response is edge-cached across +// timezones, so the frontend does the hour rotation. Null when the series +// covers less than a full week of hourly points. Exported for tests. +export const buildTrafficMatrix = (serie) => { + const timestamps = serie?.timestamps; + const values = serie?.values; + if (!Array.isArray(timestamps) || !Array.isArray(values)) return null; + + const sums = Array.from({ length: 7 }, () => new Array(24).fill(0)); + const counts = Array.from({ length: 7 }, () => new Array(24).fill(0)); + let usable = 0; + timestamps.forEach((ts, i) => { + const value = parseFloat(values[i]); + const ms = Date.parse(ts); + if (!Number.isFinite(value) || !Number.isFinite(ms)) return; + const date = new Date(ms); + const day = (date.getUTCDay() + 6) % 7; // getUTCDay is Sunday-first + sums[day][date.getUTCHours()] += value; + counts[day][date.getUTCHours()] += 1; + usable += 1; + }); + if (usable < 7 * 24) return null; + + const means = sums.map((row, d) => row.map((sum, h) => (counts[d][h] ? sum / counts[d][h] : 0))); + const max = Math.max(...means.flat()); + if (max <= 0) return null; + return means.map((row) => row.map((v) => Math.round((v / max) * 1000) / 1000)); +}; + +const fetchCountryTraffic = async (query) => { + // Strict '1' keeps the cache key space to two variants per country. + const humanOnly = query.human === '1'; + const json = await fetchFromCloudflare(seriesEndpoint(query.country, humanOnly)); + // null (no usable series) is a valid, cacheable answer. + return { trafficMatrix: buildTrafficMatrix(json?.result?.serie_0) }; +}; + +// -- view: outages — global internet outage feed for the Pulse panel ------- + +// Merges two 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. + +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; + +// 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. +// Exported for tests. +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); +}; + +const fetchOutages = async () => { + // One failed source degrades to a partial feed; both failing is an error. + const [outagesResult, anomaliesResult] = await Promise.allSettled([ + fetchFromCloudflare(`/radar/annotations/outages?dateRange=${DATE_RANGE}&limit=50`), + fetchFromCloudflare(`/radar/traffic_anomalies?dateRange=${DATE_RANGE}&limit=100`), + ]); + + if (outagesResult.status === 'rejected' && anomaliesResult.status === 'rejected') { + throw outagesResult.reason instanceof Error + ? outagesResult.reason + : new Error('both Radar sources failed'); + } + if (outagesResult.status === 'rejected' || anomaliesResult.status === 'rejected') { + const failed = outagesResult.status === 'rejected' ? outagesResult : anomaliesResult; + logger.warn({ err: failed.reason }, 'cf-radar: one outage source failed'); + } + + const outages = normalizeOutages( + outagesResult.status === 'fulfilled' ? outagesResult.value?.result?.annotations || [] : []); + const anomalies = normalizeAnomalies( + anomaliesResult.status === 'fulfilled' ? anomaliesResult.value?.result?.trafficAnomalies || [] : []); + + return { events: mergeEvents(outages, anomalies) }; +}; + +// -- view registry ---------------------------------------------------------- + +// guards: run by the dispatcher before fetch — each 400s invalid input and +// normalizes params in place, so cache keys stay canonical. +// ttl: edge-cache seconds, read by the /api/cfradar route middleware. +// fetch: async (req.query) => payload; throws on upstream failure. +export const RADAR_VIEWS = { + 'asn': { + guards: [requireValidASN()], + ttl: 30 * 24 * 60 * 60, + fetch: fetchAsnProfile, + }, + 'country-traffic': { + guards: [requireValidCountry()], + ttl: 30 * 24 * 60 * 60, + fetch: fetchCountryTraffic, + }, + 'outages': { + guards: [], + ttl: 60 * 60, + fetch: fetchOutages, + }, +}; diff --git a/common/guards.js b/common/guards.js index 70f3a6738..40876a04a 100644 --- a/common/guards.js +++ b/common/guards.js @@ -75,8 +75,8 @@ export const requireValidPrefix = (paramName = 'prefix') => (req, res, next) => }; // Reject requests without a valid ASN (numeric, with optional 'AS' prefix). -// Used by /api/asn-connectivity; other ASN-taking handlers (cf-radar) still -// validate inline for historical reasons. +// Used by /api/asn-connectivity and, per-view, by the /api/cfradar +// dispatcher (see RADAR_VIEWS in common/cf-radar.js). export const requireValidASN = (paramName = 'asn') => (req, res, next) => { const raw = req.query[paramName]; if (!raw) { @@ -90,6 +90,21 @@ export const requireValidASN = (paramName = 'asn') => (req, res, next) => { next(); }; +// Reject requests without a two-letter country code; uppercases in place so +// the edge cache sees one canonical key. Syntactic only — an unassigned code +// just yields an empty upstream series. +export const requireValidCountry = (paramName = 'country') => (req, res, next) => { + const raw = req.query[paramName]; + if (!raw) { + return res.status(400).json({ error: 'No country provided' }); + } + if (!/^[A-Za-z]{2}$/.test(raw)) { + return res.status(400).json({ error: 'Invalid country' }); + } + req.query[paramName] = String(raw).toUpperCase(); + next(); +}; + // Reject requests whose report id doesn't look like one we issued: 16 random // bytes as base64url, always exactly 22 chars. Reads the ROUTE param (not the // query) — the share-report read endpoint is /api/report/:id. diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 4b3f47951..a51a62384 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -6,12 +6,38 @@ class="m-0 flex min-w-0 flex-1 items-center gap-2 text-xl md:text-3xl font-semibold tracking-tight leading-tight"> 🚦 {{ t('connectivity.Title') }} - - - + +
+ + + + + + + + {{ listName(list) }} + + + + + {{ t('connectivity.lists.Manage') }} + + + + + + +

{{ t('connectivity.Note') }}

@@ -35,12 +61,15 @@ ? - -
+ + @@ -48,9 +77,12 @@ class="size-6 shrink-0 rounded-lg inline-flex items-center justify-center text-xs font-semibold text-muted-foreground border"> {{ (test.name || '?').charAt(0).toUpperCase() }} - {{ test.name }} -
- + {{ test.name }} + +
@@ -62,10 +94,19 @@ {{ test.status }} - + +
-
- - + + @@ -120,16 +161,19 @@ import { trackEvent } from '@/utils/analytics'; import { emitAppEvent, waitForAppEvent } from '@/utils/app-events'; import { useAppCommand } from '@/composables/use-app-command.js'; import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js'; -import { TILE_PREVIEW, faviconPath } from '@/data/connectivity-import-lists.js'; -import { removeTarget } from '@/utils/connectivity-import.js'; +import { TILE_PREVIEW, faviconPath, MINE_LIST_ID } from '@/data/connectivity-import-lists.js'; +import { siteUrlOf, removeMember } from '@/utils/connectivity-lists.js'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import ConnectivityAddDialog from '@/components/widgets/ConnectivityAddDialog.vue'; import InfoBanner from '@/components/widgets/InfoBanner.vue'; import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { - Play, Frown, Meh, Plus, RotateCw, Smile, X, + Check, ChevronDown, Play, Frown, Meh, Plus, RotateCw, Settings2, Smile, X, } from '@lucide/vue'; const { t } = useI18n(); @@ -155,14 +199,46 @@ const totalRounds = computed(() => 1 + maxCounts.value); const allRoundsDone = ref(false); const alertFired = ref(false); -// One reactive test entry per stored target (the connectivityTargets -// preference holds the whole set, defaults included). `roundResults` -// records per-round { tone, time } for the latency bars; icons come from -// the committed favicon set, hand-added targets fall back to letter tiles. +// ── Lists ────────────────────────────────────────────────────────────────── +// One list renders (and tests) at a time. +const lists = computed(() => userPreferences.value.connectivityLists?.lists || []); + +// Open on the preferred default list (in-page switching is per-visit +// only); a stale preference falls back to Mine. +const initialListId = () => { + const preferred = userPreferences.value.connectivityDefaultListId; + return lists.value.some((l) => l.id === preferred) ? preferred : MINE_LIST_ID; +}; +const activeListId = ref(initialListId()); +const activeList = computed(() => lists.value.find((l) => l.id === activeListId.value)); + +// Mine's stored name is null — its display name is localized. +const listName = (list) => (list.id === MINE_LIST_ID ? t('connectivity.lists.Mine') : list.name); + +const switchList = (id) => { + if (id === activeListId.value) return; + activeListId.value = id; + trackEvent('Section', 'SwitchConnectivityList', 'Connectivity'); +}; + +// A deleted active list falls back to Mine instead of an empty grid. +watch(lists, (all) => { + if (!all.some((l) => l.id === activeListId.value)) activeListId.value = MINE_LIST_ID; +}); + +const titleOpensSite = computed(() => userPreferences.value.connectivityCardTitleOpensSite); +// Card title link target — null (render a plain div) when the preference is +// off or the member's site URL can't be resolved. +const cardHref = (test) => (titleOpensSite.value ? siteUrlOf(test) : null); + +// ── Test entries ─────────────────────────────────────────────────────────── +// One reactive entry per member of the active list. `roundResults` records +// per-round { tone, time } for the latency bars. const targetEntry = (target) => reactive({ id: target.id, name: target.name, url: target.url, + siteUrl: target.siteUrl || null, favicon: target.favicon || null, status: t('connectivity.StatusWait'), time: 0, @@ -171,26 +247,33 @@ const targetEntry = (target) => reactive({ }); const connectivityTests = reactive([]); -// Reconcile by id (not wipe-and-refill) so existing cards don't flash back -// to "Awaiting Test" each time the user adds or removes another one. +// Rebuild vs reconcile: a list switch replaces the whole grid (the same +// member id can exist in two lists and must not carry results across); a +// same-list change reconciles in place so existing cards don't flash back +// to "Awaiting Test". watch( - () => userPreferences.value.connectivityTargets, - (newTargets) => { - const targets = newTargets || []; - const targetIds = new Set(targets.map((t) => t.id)); + () => ({ listId: activeListId.value, members: activeList.value?.members || [] }), + ({ listId, members }, prev) => { + if (!prev || prev.listId !== listId) { + connectivityTests.splice(0, connectivityTests.length, ...members.map(targetEntry)); + // A newly shown list runs a full pass right away, toast suppressed. + if (prev && isStarted.value) handelCheckStart('refresh'); + return; + } - // Drop targets no longer in storage. + // Drop members no longer stored. + const memberIds = new Set(members.map((m) => m.id)); for (let i = connectivityTests.length - 1; i >= 0; i--) { - if (!targetIds.has(connectivityTests[i].id)) { + if (!memberIds.has(connectivityTests[i].id)) { connectivityTests.splice(i, 1); } } // Push only newcomers. const existingIds = new Set(connectivityTests.map((entry) => entry.id)); - for (const target of targets) { - if (existingIds.has(target.id)) continue; - const entry = targetEntry(target); + for (const member of members) { + if (existingIds.has(member.id)) continue; + const entry = targetEntry(member); connectivityTests.push(entry); // Cards added after the bootstrap pass (import / hand-add) test // themselves right away instead of sitting at "Awaiting Test". @@ -424,26 +507,34 @@ const updateConnectivityAlert = (type) => { } }; -// ── Add/remove targets ───────────────────────────────────────────────────── -// The add/import dialog itself lives in ConnectivityAddDialog.vue; this -// component only owns the open flag and per-card removal. +// ── Add / import / manage dialog + per-card removal ─────────────────────── +// The dialog lives in ConnectivityAddDialog.vue; this component owns the +// open flag and the tab it opens on. const addDialogOpen = ref(false); +const dialogTab = ref('import'); +const openAddDialog = (tab) => { + dialogTab.value = tab; + addDialogOpen.value = true; +}; const removeTargetById = (id) => { - const result = removeTarget(userPreferences.value.connectivityTargets || [], id); + const result = removeMember(lists.value, activeListId.value, id); if (result.error) return; - store.updatePreference('connectivityTargets', result.targets); + store.updatePreference('connectivityLists', { + ...userPreferences.value.connectivityLists, + lists: result.lists, + }); trackEvent('Section', 'RemoveCustomTarget', 'Connectivity'); }; // Two-step removal: first click arms "X?" (self-reverting), second click -// deletes. The last remaining target skips the dance — the set must never -// go empty, so the click just explains itself via toast. +// deletes. Mine's last remaining site just toasts — the default list must +// never go empty (other lists may be emptied; deleting them requires it). const confirmingRemoveId = ref(null); let confirmResetTimer = null; const handleRemoveClick = (id) => { clearTimeout(confirmResetTimer); - if ((userPreferences.value.connectivityTargets || []).length <= 1) { + if (activeListId.value === MINE_LIST_ID && (activeList.value?.members.length || 0) <= 1) { confirmingRemoveId.value = null; store.setAlert(true, 'text-warning', t('connectivity.addCustom.LastTargetMessage'), t('connectivity.addCustom.LastTargetTitle')); @@ -469,7 +560,7 @@ const finalizeMultiTestAlert = () => { // `trigger` selects three behaviors (arming the toast = setting alertToShow): // 'boot' — startup auto-run: arm toast, no card reset // 'manual' — section refresh button: arm toast, reset cards -// 'refresh' — global "refresh everything": suppress toast (global alert covers it), reset cards +// 'refresh' — global "refresh everything" + list switches: suppress toast, reset cards // Multi-round mode applies to every trigger, not just boot — with the // connectivity auto-run switch off, the manual/global refresh IS the user's // only entry point, and the rounds preference must still hold there. diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index cf026be13..07a5868a3 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -17,7 +17,6 @@

{{ t('dnsleaktest.Note') }}

-

{{ t('dnsleaktest.Note2') }}

diff --git a/frontend/components/ip-infos/ASNConnectivity.vue b/frontend/components/ip-infos/ASNConnectivity.vue index 5eeb9ab69..b5d220d08 100644 --- a/frontend/components/ip-infos/ASNConnectivity.vue +++ b/frontend/components/ip-infos/ASNConnectivity.vue @@ -23,7 +23,8 @@ + stroke-width="1.2" marker-end="url(#jn-arrow)" opacity="0.6" stroke-linejoin="miter" + :stroke-dasharray="e.kind === 'peering' ? '6 4' : null" /> @@ -44,7 +45,9 @@
- + + {{ item.label }}
@@ -129,7 +132,8 @@ + :class="isDimmedEdge(i) ? 'opacity-10' : 'opacity-60'" + :stroke-dasharray="e.kind === 'peering' ? '6 4' : null" />
- + + {{ item.label }} @@ -290,24 +296,30 @@ watch(isExpanded, (open) => { // Force the captured image to be the full 1:1 topology regardless of the // Drawer viewport or current zoom: reset zoom to 1 and drop every height / -// overflow / max-* constraint that would otherwise clip the SVG. Teardown -// restores the prior inline styles. +// overflow / max-* constraint that would otherwise clip the SVG. The flex-1 +// hosts also need `flex: none`, or flex sizing keeps them at viewport height +// and the capture comes out truncated. Teardown restores prior inline styles. const prepareGraphForCapture = async (cap) => { const scr = cap.querySelector('[data-svg-scroll]'); const svg = scr?.querySelector('svg'); - if (!scr || !svg) return; + const wrap = scr?.parentElement; // the relative flex-1 zoom-controls host + if (!scr || !svg || !wrap) return; const prevZoom = zoom.value; const snapshot = [ { el: cap, css: cap.style.cssText }, + { el: wrap, css: wrap.style.cssText }, { el: scr, css: scr.style.cssText }, { el: svg, css: svg.style.cssText }, ]; zoom.value = 1; // `min-width: max-content` so wide topologies aren't clipped to Drawer width. + cap.style.flex = 'none'; cap.style.height = 'auto'; cap.style.minWidth = 'max-content'; + wrap.style.flex = 'none'; + wrap.style.height = 'auto'; scr.style.height = 'auto'; scr.style.overflow = 'visible'; svg.style.maxHeight = 'none'; @@ -357,10 +369,13 @@ function isDimmedEdge(idx) { } // Shared by inline + drawer views so swatches and labels can't drift. +// Node types render as swatches (swatchClass); edge kinds as line samples (lineClass). const legendItems = computed(() => [ { swatchClass: 'border-2 border-success bg-success/10', label: t('ipInfos.ASNConnectivity.legendOrigin') }, { swatchClass: 'border-2 border-info bg-info/10', label: t('ipInfos.ASNConnectivity.legendTier1') }, { swatchClass: 'border bg-card', label: t('ipInfos.ASNConnectivity.legendIntermediate') }, + { lineClass: 'border-muted-foreground', label: t('ipInfos.ASNConnectivity.legendTransit') }, + { lineClass: 'border-dashed border-muted-foreground', label: t('ipInfos.ASNConnectivity.legendPeering') }, ]); watch( @@ -381,38 +396,34 @@ watch( { immediate: true }, ); -// LR dagre layout + custom Manhattan routing. Three tricks keep edges from overlapping: -// 1. Per-edge source/target ports — multi-edges on a node fan out across its vertical -// side instead of sharing one anchor (no overlapping initial/final horizontals). -// 2. Per-edge channel lanes — each edge that bends in a corridor gets its own x lane -// (no overlapping verticals; replaces the old trunk-and-branches sharing). -// 3. Ports sorted by other-end y, lanes sorted by source y — preserves geometric -// order so segments don't cross unnecessarily. -// We bypass dagre's own spline points entirely; they produce diagonal-looking polylines. +// Dagre layering/ordering + custom column placement + Manhattan trunk routing: +// 1. Own coordinates — Tier 1s re-pinned to one terminal column (wrapped in +// two staggered sub-columns when tall), columns stacked tightly, centered. +// 2. Target-bundled edges — all edges into a node share one corridor lane +// and one entry point: one trunk instead of N parallel lines. +// 3. Geometric sorting (ports and lanes by target y) avoids trunk crossings. function computeLayout(dagre, graph) { const NODE_W = 130; const NODE_H = 46; const PORT_SPAN = NODE_H * 0.75; // ports use middle 75% of node height - + const NODE_SEP = 18; + const COL_GAP = 90; // corridor between columns (lane space) + const SINK_SUBCOL_GAP = 80; // corridor between the two terminal sub-columns + const MARGIN_X = 16; + const MARGIN_Y = 14; + const WRAP_MIN_ROWS = 10; // terminal column wraps in two beyond this + + // Dagre supplies layering and a crossing-minimizing vertical order only; + // its raw positions scatter Tier 1s across columns and leave holes. const g = new dagre.graphlib.Graph(); - g.setGraph({ - rankdir: 'LR', - ranksep: 90, // wide enough to fit multiple lanes per corridor - nodesep: 18, - marginx: 16, - marginy: 14, - }); + g.setGraph({ rankdir: 'LR', ranksep: COL_GAP, nodesep: NODE_SEP }); g.setDefaultEdgeLabel(() => ({})); - for (const n of graph.nodes) { - const opts = { width: NODE_W, height: NODE_H, _data: n }; - // Pin Tier 1s to the rightmost column (bgp.tools look). - // Trade-off: direct origin → Tier 1 edges become long horizontals. - if (n.type === 'tier1') opts.rank = 'sink'; - g.setNode(String(n.asn), opts); + g.setNode(String(n.asn), { width: NODE_W, height: NODE_H, _data: n }); } for (const e of graph.edges) { - g.setEdge(String(e.from), String(e.to)); + // kind ('transit' | 'peering') rides the edge label so it survives dagre. + g.setEdge(String(e.from), String(e.to), { kind: e.kind }); } dagre.layout(g); @@ -431,63 +442,126 @@ function computeLayout(dagre, graph) { return layoutNode; }); + // --- Column assignment. Non-terminals keep their dagre layer (compacted); + // every tier1 node is re-pinned to one terminal column on the far right. + const dagreColXs = [...new Set(nodes.map(n => n.x))].sort((a, b) => a - b); + const dagreColOf = new Map(dagreColXs.map((x, i) => [x, i])); + const isTerminal = (n) => n.type === 'tier1'; + const interCols = [...new Set( + nodes.filter(n => !isTerminal(n)).map(n => dagreColOf.get(n.x)), + )].sort((a, b) => a - b); + const compactCol = new Map(interCols.map((c, i) => [c, i])); + const sinkCol = interCols.length; + + const columns = []; + for (const n of nodes) { + const c = isTerminal(n) ? sinkCol : compactCol.get(dagreColOf.get(n.x)); + (columns[c] ??= []).push(n); + } + // Dagre's vertical order minimizes crossings — keep it per column. + columns.forEach(col => col.sort((a, b) => a.y - b.y)); + + // --- Stacking. Columns stack tightly, centered on the tallest. A big + // terminal column wraps into two half-row-staggered sub-columns, so + // trunks to the far one cross the near one at its row gaps. + const rowStep = NODE_H + NODE_SEP; + const colX = (c) => MARGIN_X + NODE_W / 2 + c * (NODE_W + COL_GAP); + const wrapSink = (columns[sinkCol]?.length ?? 0) >= WRAP_MIN_ROWS; + const heights = columns.map((col, c) => + c === sinkCol && wrapSink + ? (Math.ceil(col.length / 2) - 1) * rowStep + rowStep / 2 + NODE_H + : (col.length - 1) * rowStep + NODE_H); + const graphH = Math.max(...heights); + columns.forEach((col, c) => { + const top = MARGIN_Y + (graphH - heights[c]) / 2 + NODE_H / 2; + col.forEach((n, i) => { + if (c === sinkCol && wrapSink) { + n.x = colX(c) + (i % 2) * (NODE_W + SINK_SUBCOL_GAP); + n.y = top + Math.floor(i / 2) * rowStep + (i % 2) * (rowStep / 2); + } else { + n.x = colX(c); + n.y = top + i * rowStep; + } + }); + }); + // --- Skip-edge stagger. A single-node column crossed by an edge between + // its neighbors (A → B → C plus a direct A → C) sits on that edge's line, + // hiding it under the node's box. Nudge such columns off center, + // alternating direction so consecutive nudged columns don't realign. + const colOfAsn = new Map(); + columns.forEach((col, c) => col.forEach(n => colOfAsn.set(n.asn, c))); + let flip = 1; + columns.forEach((col, c) => { + if (col.length !== 1) return; + const crossed = graph.edges.some(e => + colOfAsn.get(e.from) < c && colOfAsn.get(e.to) > c); + if (!crossed) return; + col[0].y += flip * rowStep * 0.75; + flip = -flip; + }); + // Nudges can poke past the stacked extent — re-anchor and size off nodes. + const shiftY = MARGIN_Y - Math.min(...nodes.map(n => n.y - n.h / 2)); + nodes.forEach(n => { n.y += shiftY; }); + const frame = { + width: colX(columns.length - 1) + (wrapSink ? NODE_W + SINK_SUBCOL_GAP : 0) + + NODE_W / 2 + MARGIN_X, + height: Math.max(...nodes.map(n => n.y + n.h / 2)) + MARGIN_Y, + }; + const colXs = [...new Set(nodes.map(n => n.x))].sort((a, b) => a - b); const colIdxByX = new Map(colXs.map((x, i) => [x, i])); - const rawEdges = g.edges().map(e => ({ v: e.v, w: e.w })); + const rawEdges = g.edges().map(e => ({ v: e.v, w: e.w, kind: g.edge(e).kind })); - // --- Source/target ports. - // Group edges by source and by target, then distribute each group across PORT_SPAN. - // Sort by the OTHER end's y so port order matches geometric order — keeps edges from - // crossing each other right at the node attachment. + // --- Ports. Sources fan edges across PORT_SPAN sorted by target y; + // targets take every edge at their center (single bundled entry). const outgoing = new Map(); - const incoming = new Map(); rawEdges.forEach((e, i) => { if (!outgoing.has(e.v)) outgoing.set(e.v, []); - if (!incoming.has(e.w)) incoming.set(e.w, []); outgoing.get(e.v).push(i); - incoming.get(e.w).push(i); }); const sourceY = new Array(rawEdges.length); - const targetY = new Array(rawEdges.length); + const targetY = rawEdges.map(e => nodeById.get(e.w).y); - function assignPorts(nodeId, edgeIdxs, otherEnd, store) { - const node = nodeById.get(nodeId); + for (const [id, edgeIdxs] of outgoing) { + const node = nodeById.get(id); const sorted = edgeIdxs.slice().sort((a, b) => - nodeById.get(rawEdges[a][otherEnd]).y - nodeById.get(rawEdges[b][otherEnd]).y); + nodeById.get(rawEdges[a].w).y - nodeById.get(rawEdges[b].w).y); const n = sorted.length; sorted.forEach((idx, i) => { const t = n === 1 ? 0.5 : i / (n - 1); - store[idx] = node.y - PORT_SPAN / 2 + t * PORT_SPAN; + sourceY[idx] = node.y - PORT_SPAN / 2 + t * PORT_SPAN; }); } - for (const [id, idxs] of outgoing) assignPorts(id, idxs, 'w', sourceY); - for (const [id, idxs] of incoming) assignPorts(id, idxs, 'v', targetY); - - // --- Channel lanes. - // Every edge bends in the corridor immediately before its target column (so vertical - // segments never cut through intermediate columns). Within each corridor, give each - // edge its own x lane — sorted by source y so lanes don't cross inside the corridor. - const corridors = new Map(); + + // --- Channel lanes. Every edge bends in the corridor right after its + // source, so the long horizontal of a column-skipping edge runs at TARGET + // height — through the terminal column's brick gaps and clear of nudged + // columns — instead of at source-port height under whatever box it meets. + // Edges sharing a target share one x lane per corridor (the trunk), and + // lanes sort by target y so trunks don't cross inside a corridor. + const corridors = new Map(); // corridor idx → Map(target id → edge idxs) rawEdges.forEach((e, idx) => { const srcCol = colIdxByX.get(nodeById.get(e.v).x); const tgtCol = colIdxByX.get(nodeById.get(e.w).x); if (tgtCol > srcCol) { - const cIdx = tgtCol - 1; - if (!corridors.has(cIdx)) corridors.set(cIdx, []); - corridors.get(cIdx).push(idx); + if (!corridors.has(srcCol)) corridors.set(srcCol, new Map()); + const byTarget = corridors.get(srcCol); + if (!byTarget.has(e.w)) byTarget.set(e.w, []); + byTarget.get(e.w).push(idx); } }); const laneX = new Array(rawEdges.length); - for (const [cIdx, idxs] of corridors) { + for (const [cIdx, byTarget] of corridors) { const leftEdge = colXs[cIdx] + NODE_W / 2; const rightEdge = colXs[cIdx + 1] - NODE_W / 2; - const sorted = idxs.slice().sort((a, b) => sourceY[a] - sourceY[b]); - const n = sorted.length; - sorted.forEach((idx, i) => { - const t = (i + 1) / (n + 1); - laneX[idx] = leftEdge + t * (rightEdge - leftEdge); + const targets = [...byTarget.keys()].sort((a, b) => + nodeById.get(a).y - nodeById.get(b).y); + const n = targets.length; + targets.forEach((id, i) => { + const x = leftEdge + ((i + 1) / (n + 1)) * (rightEdge - leftEdge); + for (const idx of byTarget.get(id)) laneX[idx] = x; }); } @@ -501,12 +575,13 @@ function computeLayout(dagre, graph) { const sy = sourceY[idx]; const ty = targetY[idx]; if (Math.abs(sy - ty) < 0.5) { - return { v: e.v, w: e.w, d: `M ${sourceRight} ${sy} L ${targetLeft} ${ty}` }; + return { v: e.v, w: e.w, kind: e.kind, d: `M ${sourceRight} ${sy} L ${targetLeft} ${ty}` }; } const cx = laneX[idx]; return { v: e.v, w: e.w, + kind: e.kind, d: `M ${sourceRight} ${sy} ` + `L ${cx} ${sy} ` + `L ${cx} ${ty} ` @@ -514,8 +589,7 @@ function computeLayout(dagre, graph) { }; }); - const { width, height } = g.graph(); - return { width, height, nodes, edges }; + return { width: frame.width, height: frame.height, nodes, edges }; } function nodeBoxClass(type) { diff --git a/frontend/components/ip-infos/ASNInfo.vue b/frontend/components/ip-infos/ASNInfo.vue index 08c5a174d..22f9fcaaf 100644 --- a/frontend/components/ip-infos/ASNInfo.vue +++ b/frontend/components/ip-infos/ASNInfo.vue @@ -21,6 +21,23 @@ + +
+
+ {{ t('ipInfos.ASNInfo.connectionQuality') }} + +
+
+
+
{{ t(`ipInfos.ASNInfo.${key}`) }}
+
{{ item }}
+
+
+
+
@@ -65,8 +82,9 @@ import { computed } from 'vue'; import getCountryName from '@/data/country-name.js'; import DataPairBar from './DataPairBar.vue'; import { Badge } from '@/components/ui/badge'; +import { JnTooltip } from '@/components/ui/tooltip'; import { Icon } from '@iconify/vue'; -import { Database, ExternalLink, Info } from '@lucide/vue'; +import { CircleQuestionMark, Database, ExternalLink } from '@lucide/vue'; const { t } = useI18n(); const store = useMainStore(); @@ -87,12 +105,26 @@ const props = defineProps({ const basicInfo = computed(() => { const data = props.asnInfos[props.asn]; if (!data) return {}; - const { asnName, asnCountryCode, asnOrgName, estimatedUsers } = data; const info = {}; - if (asnName) info.asnName = asnName; - if (asnCountryCode) info.asnCountryCode = asnCountryCode; - if (asnOrgName) info.asnOrgName = asnOrgName; - if (estimatedUsers) info.estimatedUsers = estimatedUsers; + const keys = [ + 'asnName', 'asnCountryCode', 'asnOrgName', 'estimatedUsers', + 'prefixesV4', 'prefixesV6', 'upstreamCount', 'downstreamCount', 'peerCount', + ]; + for (const key of keys) { + if (data[key]) info[key] = data[key]; + } + return info; +}); + +// Cloudflare speed test aggregates for the AS, pre-formatted by the backend. +const qualityInfo = computed(() => { + const data = props.asnInfos[props.asn]; + if (!data) return {}; + const info = {}; + const keys = ['speedDownload', 'speedUpload', 'latency', 'jitter']; + for (const key of keys) { + if (data[key]) info[key] = data[key]; + } return info; }); diff --git a/frontend/components/ip-infos/CountryTraffic.vue b/frontend/components/ip-infos/CountryTraffic.vue new file mode 100644 index 000000000..72d88aa99 --- /dev/null +++ b/frontend/components/ip-infos/CountryTraffic.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/components/ip-infos/CountryTrafficPanel.vue b/frontend/components/ip-infos/CountryTrafficPanel.vue new file mode 100644 index 000000000..dd6702433 --- /dev/null +++ b/frontend/components/ip-infos/CountryTrafficPanel.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 95a6ab754..f25261e8b 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -42,7 +42,8 @@ + :configs="configs" :is-dark-mode="isDarkMode" map-mode="dialog" + country-traffic-mode="dialog" /> diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 71c88fca9..2018f889b 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -17,6 +17,15 @@ {{ data.country_name }} + + +
@@ -39,7 +48,9 @@ @@ -67,6 +78,20 @@ + + + +
+
+ + +
+
+
+
+
@@ -216,39 +241,18 @@
- - - - - - -
- - - {{ t('ipInfos.Coordinates') }} - - {{ data.longitude }}, {{ data.latitude - }} -
- - Map - -
-
+ + + + diff --git a/frontend/components/ip-infos/LocationMapPanel.vue b/frontend/components/ip-infos/LocationMapPanel.vue new file mode 100644 index 000000000..667a0b38a --- /dev/null +++ b/frontend/components/ip-infos/LocationMapPanel.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/components/widgets/ConnectivityAddDialog.vue b/frontend/components/widgets/ConnectivityAddDialog.vue index f683a9825..d20b5559c 100644 --- a/frontend/components/widgets/ConnectivityAddDialog.vue +++ b/frontend/components/widgets/ConnectivityAddDialog.vue @@ -6,16 +6,19 @@ - + {{ t('connectivity.importDialog.ImportOption') }} {{ t('connectivity.importDialog.CustomOption') }} + + {{ t('connectivity.lists.Tab') }} - +
  • - - - + + + + + + + + {{ listName(dest) }} + + + + + {{ t('connectivity.importDialog.ImportAsNew') }} + + +
- -
+ +
{{ importResult || ' ' }} - - {{ t('connectivity.importDialog.Capacity', { used: targets.length, limit: CONNECTIVITY_TARGET_LIMIT - }) }} -
- +
@@ -74,7 +84,23 @@ :aria-invalid="isUrlError ? 'true' : undefined" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true" @keyup.enter="handleAdd" />
-

{{ t('connectivity.addCustom.Hint') }}

+
+ + +
+
+

{{ t('connectivity.addCustom.Hint') }}

+

{{ t('connectivity.addCustom.ProbeBlockedNote') }}

+

{{ addError }}

@@ -85,83 +111,194 @@
+ + + +
+ + +
+
    +
  • + + +
  • +
+
+ {{ listError || ' ' }} + {{ lists.length }}/{{ CONNECTIVITY_LIST_LIMIT }} +
+

{{ t('connectivity.lists.Hint') }}

+
diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index de701eabc..808586916 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -89,8 +89,8 @@ + runs (no switch). The Connectivity test options live in their own + section below: they apply to manual runs too, not just startup. -->
{{ t('nav.preferences.autoRun') }}
@@ -107,20 +107,53 @@ {{ t('nav.preferences.autoRunTips') }}
- -
- {{ t('nav.preferences.appSettings') }} + +
+ {{ t('nav.Connectivity') }}
- +
+
+ + {{ t('nav.preferences.connectivity.defaultList') }} + +
+

+ {{ t('nav.preferences.connectivity.defaultListTips') }}

+
+ + - + +
+
+ + +
+ {{ t('nav.preferences.appSettings') }} +
@@ -165,6 +198,7 @@ import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; import { clampRetentionDays } from '@/utils/ip-history.js'; import { LOCALES } from '@/utils/locale-registry.js'; +import { MINE_LIST_ID } from '@/data/connectivity-import-lists.js'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Slider } from '@/components/ui/slider'; @@ -172,6 +206,7 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; import { + Activity, AppWindow, Database, History, @@ -231,6 +266,23 @@ const prefLanguage = (value) => { trackEvent('Nav', 'PrefereceClick', 'LanguageChange'); }; +// Which Connectivity list the section opens on; a stale preference falls +// back to Mine (whose display name is localized, never stored). +const connectivityLists = computed(() => userPreferences.value.connectivityLists?.lists || []); +const connectivityListName = (list) => (list.id === MINE_LIST_ID ? t('connectivity.lists.Mine') : list.name); +const currentDefaultListId = computed(() => { + const preferred = userPreferences.value.connectivityDefaultListId; + return connectivityLists.value.some((l) => l.id === preferred) ? preferred : MINE_LIST_ID; +}); +const currentDefaultListName = computed(() => { + const list = connectivityLists.value.find((l) => l.id === currentDefaultListId.value); + return list ? connectivityListName(list) : '—'; +}); +const prefConnectivityDefaultList = (value) => { + store.updatePreference('connectivityDefaultListId', value); + trackEvent('Nav', 'PrefereceClick', 'ConnectivityDefaultList'); +}; + const prefConnectivityMultipleTests = (value) => { store.updatePreference('connectivityMultipleTests', value); // Achievement rule (ResourceHog) lives in data/achievement-rules.js. @@ -259,6 +311,11 @@ const prefconnectivityShowNoti = (value) => { trackEvent('Nav', 'PrefereceClick', 'ConnectivityNotifications'); }; +const prefConnectivityCardTitleOpensSite = (value) => { + store.updatePreference('connectivityCardTitleOpensSite', value); + trackEvent('Nav', 'PrefereceClick', 'ConnectivityCardTitleOpensSite'); +}; + // IP history recorder: on/off + retention days (1–90). The draft ref feeds the // slider's live readout; the preference is written on value-commit only. const ipHistoryDaysDraft = ref(clampRetentionDays(userPreferences.value.ipHistoryDays)); @@ -302,23 +359,27 @@ SectionTitle.props = ['icon']; const SectionTip = (props, { slots }) => h('p', { class: 'mt-2 text-xs text-muted-foreground leading-relaxed' }, slots.default?.()); -// App Settings switch row: label (+ optional tip) on left, Switch on right. +// Preference switch row. Two stacked lines: label + Switch vertically +// centered on the first, the optional tip full-width on the second (so it +// runs under the control instead of wrapping beside it). Tip rows trim the +// bottom padding — the tip's own line-height already provides visual air. const PrefRow = (props, { emit }) => - h('div', { class: 'flex items-start justify-between gap-3 p-3' }, [ - h('div', { class: 'flex-1 min-w-0' }, [ + h('div', { class: props.tip ? 'p-3 pb-2' : 'p-3' }, [ + h('div', { class: 'flex items-center justify-between gap-3' }, [ h('label', { for: props.id, - class: 'text-sm font-medium cursor-pointer select-none', + class: 'min-w-0 flex-1 text-sm font-medium cursor-pointer select-none', }, props.label), - props.tip - ? h('p', { class: 'mt-0.5 text-xs text-muted-foreground leading-relaxed' }, props.tip) - : null, + h(Switch, { + id: props.id, + class: 'shrink-0', + modelValue: props.modelValue, + 'onUpdate:modelValue': (v) => emit('update:modelValue', v), + }), ]), - h(Switch, { - id: props.id, - modelValue: props.modelValue, - 'onUpdate:modelValue': (v) => emit('update:modelValue', v), - }), + props.tip + ? h('p', { class: 'mt-1 text-xs text-muted-foreground leading-relaxed' }, props.tip) + : null, ]); PrefRow.props = ['id', 'label', 'tip', 'modelValue']; PrefRow.emits = ['update:modelValue']; diff --git a/frontend/components/widgets/Pulse.vue b/frontend/components/widgets/Pulse.vue index e7e44f36c..7d7b7ecec 100644 --- a/frontend/components/widgets/Pulse.vue +++ b/frontend/components/widgets/Pulse.vue @@ -130,7 +130,7 @@

- @@ -223,7 +223,7 @@ // // Two-tier gating: the social sections (composer / feed / visitor map) need // the pulse beacon backend (VITE_PULSE_BEACON_URL, build-time); the outage feed -// only needs /api/outages, gated on the runtime `cloudFlare` configs flag. +// only needs /api/cfradar?view=outages, gated on the runtime `cloudFlare` configs flag. // The entry button shows when either half can render. // POST /status on pick; GET /stats on // open and after a send — uncached end to end. The visit beacon is app-level: diff --git a/frontend/components/widgets/PulseOutages.vue b/frontend/components/widgets/PulseOutages.vue index ac273d283..8ee1aa34f 100644 --- a/frontend/components/widgets/PulseOutages.vue +++ b/frontend/components/widgets/PulseOutages.vue @@ -1,5 +1,5 @@