mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(asn): frontend groundwork for the ASN Profile tool
- Registry gate `requiresConfig`: a tool is listed in the Advanced Tools grid and the nav only when its backend config is present; the listing filter is one pure function. ASN Profile requires the Cloudflare key. - `useAsnInfo` owns the Radar ASN fetch and its session cache, shared by the IP cards and the query dialog. - ASN Info's traffic-share and connection-quality blocks become small components over pure helpers, reusable by the profile page. - ASN Profile lands as a mock-data page for now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e6e411beb
commit
c55634c8e0
25 files changed
+1621
-115
No files matched your search
@@ -63,6 +63,28 @@ so callers react programmatically. Cross-component triggers go through the bus
|
||||
never template refs (refs stay for UI chrome). Future advanced tools register
|
||||
at setup (`?tool=` mount); callers `waitForAppCommand` + dispatch.
|
||||
|
||||
### Advanced Tools gates decide listing only
|
||||
|
||||
`data/tools.js` entries opt into deployment gates: `requiresOriginalSite` and/or
|
||||
`requiresConfig: '<flag>'` (a `/api/configs` key, e.g. `'cloudFlare'` on `asn`).
|
||||
The card grid and the nav filter through `isToolAvailable()`
|
||||
(`utils/tool-availability.js`), never the flags directly; a gated tool stays
|
||||
unlisted until configs land. Deep links (`?tool=<slug>` drawer, `/tools/:slug`)
|
||||
are not gated and render immediately. Data that only some deployments have
|
||||
(e.g. ASN Profile's reputation / IXP sections) is not gated on
|
||||
`configs.originalSite` either: the page renders it when the backend response
|
||||
carries it.
|
||||
|
||||
### ASN data has one fetcher
|
||||
|
||||
The Radar ASN summary (`/api/cfradar?view=asn`) goes through
|
||||
`composables/use-asn-info.js` — URL, `ASN_INFO_VERSION` cache-buster, `AS<n>`
|
||||
cache key. Owners create their own session cache with `useAsnInfo()`
|
||||
(IpInfos and QueryIP deliberately don't share one); IpDetailPanel fills the
|
||||
cache it is handed via `loadAsnInfoInto()`. Its traffic-share and
|
||||
connection-quality blocks are `ip-infos/AsnTrafficShares.vue` /
|
||||
`AsnConnectionQuality.vue` (prop: the summary object), shaped by `utils/asn-metrics.js`.
|
||||
|
||||
### Overlays take no keyboard shortcuts
|
||||
|
||||
One document-level dispatcher (`utils/shortcut.js`) over the map
|
||||
|
||||
@@ -96,6 +96,7 @@ import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { ADVANCED_TOOLS, TOOL_BY_SLUG } from '@/data/tools.js';
|
||||
import { isToolAvailable } from '@/utils/tool-availability.js';
|
||||
import { isRunningAsPwa } from '@/utils/pwa.js';
|
||||
import { Drawer, DrawerContent, DrawerClose } from '@/components/ui/drawer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -121,12 +122,9 @@ const router = useRouter();
|
||||
// is mapped from the registry's `emoji` so the template key stays stable.
|
||||
const cards = ADVANCED_TOOLS.map((tool) => ({ ...tool, icon: tool.emoji }));
|
||||
|
||||
// Gate: the invisibility + enhanced DNS-leak tools only show on the original
|
||||
// site (they need the private API + sign-in). Reactive on configs, so they
|
||||
// appear the moment configs land — no fixed timeout needed.
|
||||
const enabledCards = computed(() =>
|
||||
cards.filter((c) => !c.requiresOriginalSite || configs.value.originalSite),
|
||||
);
|
||||
// Deployment gates (original site / configs flag, see utils/tool-availability.js).
|
||||
// Reactive on configs, so gated cards appear the moment configs land.
|
||||
const enabledCards = computed(() => cards.filter((card) => isToolAvailable(card, configs.value)));
|
||||
|
||||
// ── Drawer state, driven by the `?tool=<slug>` query on the home route ───────
|
||||
// `?tool=whois` ⇒ the drawer is open showing Whois. Closing clears the query.
|
||||
|
||||
@@ -41,6 +41,7 @@ import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
|
||||
import { getIPFromIPIP, getIPFromCloudflare_V4, getIPFromCloudflare_V6, getIPFromIPChecking64, getIPFromIPChecking4, getIPFromIPChecking6 } from '@/utils/getips';
|
||||
import { emitAppEvent, waitForAppEvent } from '@/utils/app-events';
|
||||
import { useAppCommand } from '@/composables/use-app-command.js';
|
||||
import { useAsnInfo } from '@/composables/use-asn-info.js';
|
||||
import { authenticatedFetch, fetchErrorLabel, logSourceFetchFailure } from '@/utils/authenticated-fetch';
|
||||
import IPCard from './ip-infos/IPCard.vue';
|
||||
import InfoBanner from './widgets/InfoBanner.vue';
|
||||
@@ -109,8 +110,8 @@ const ipDataCards = reactive([
|
||||
},
|
||||
]);
|
||||
|
||||
// Default ASN information
|
||||
const asnInfos = ref({
|
||||
// ASN information (Radar summary) session cache, with a default entry.
|
||||
const { asnInfos } = useAsnInfo({
|
||||
"AS888888": {
|
||||
"asnName": "Google", "asnOrgName": "GOGL-ARIN", "estimatedUsers": "888888", "IPv4_Pct": "95.35", "IPv6_Pct": "4.65", "HTTP_Pct": "3.16", "HTTPS_Pct": "96.84", "Desktop_Pct": "58.88", "Mobile_Pct": "41.12", "Bot_Pct": "98.46", "Human_Pct": "1.54"
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ import { Icon } from '@iconify/vue';
|
||||
import brandIcon from './svgicons/Brand.vue';
|
||||
import { SECTION_IDS } from '@/data/sections';
|
||||
import { ADVANCED_TOOLS } from '@/data/tools.js';
|
||||
import { isToolAvailable } from '@/utils/tool-availability.js';
|
||||
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
|
||||
import { formatStarCount } from '@/utils/format-star-count.js';
|
||||
import { isRunningAsPwa } from '@/utils/pwa.js';
|
||||
@@ -311,12 +312,10 @@ const isPwa = isRunningAsPwa();
|
||||
|
||||
const navItems = SECTION_IDS;
|
||||
|
||||
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: original-site-
|
||||
// only tools stay hidden on self-hosted instances. Reactive on configs.
|
||||
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: gated tools
|
||||
// stay hidden where the deployment lacks them. Reactive on configs.
|
||||
const configs = computed(() => store.configs);
|
||||
const advancedTools = computed(() =>
|
||||
ADVANCED_TOOLS.filter((tool) => !tool.requiresOriginalSite || configs.value.originalSite),
|
||||
);
|
||||
const advancedTools = computed(() => ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)));
|
||||
|
||||
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
|
||||
const mobileToolsOpen = ref(true);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,30 +22,10 @@
|
||||
</dl>
|
||||
|
||||
<!-- Connection quality (Cloudflare speed test aggregates) -->
|
||||
<div v-if="Object.keys(qualityInfo).length" class="space-y-2 pt-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{{ t('ipInfos.ASNInfo.connectionQuality') }}</span>
|
||||
<JnTooltip :text="t('ipInfos.ASNInfo.connectionQualityTooltip')" side="top"
|
||||
class="hidden md:block">
|
||||
<CircleQuestionMark class="size-3 cursor-help opacity-70" />
|
||||
</JnTooltip>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||||
<div v-for="(item, key) in qualityInfo" :key="key">
|
||||
<dt class="text-xs text-muted-foreground mb-0.5">{{ t(`ipInfos.ASNInfo.${key}`) }}</dt>
|
||||
<dd class="font-normal wrap-break-word">{{ item }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<AsnConnectionQuality :info="asnInfos[asn]" />
|
||||
|
||||
<!-- Pair data visualization -->
|
||||
<div v-if="pairDataList.length" class="space-y-2.5 pt-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ t('ipInfos.ASNInfo.trafficPercentage') }}
|
||||
</div>
|
||||
<DataPairBar v-for="pair in pairDataList" :key="pair.leftLabel" :leftLabel="pair.leftLabel"
|
||||
:leftValue="pair.leftValue" :rightLabel="pair.rightLabel" :rightValue="pair.rightValue" />
|
||||
</div>
|
||||
<!-- Traffic shares (pair bars) -->
|
||||
<AsnTrafficShares :info="asnInfos[asn]" />
|
||||
|
||||
<!-- External links -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
@@ -80,11 +60,11 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useMainStore } from '@/store';
|
||||
import { computed } from 'vue';
|
||||
import getCountryName from '@/data/country-name.js';
|
||||
import DataPairBar from './DataPairBar.vue';
|
||||
import AsnConnectionQuality from './AsnConnectionQuality.vue';
|
||||
import AsnTrafficShares from './AsnTrafficShares.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { CircleQuestionMark, Database, ExternalLink } from '@lucide/vue';
|
||||
import { Database, ExternalLink } from '@lucide/vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
@@ -115,65 +95,4 @@ const basicInfo = computed(() => {
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
// Process pair data
|
||||
const pairData = computed(() => {
|
||||
const data = props.asnInfos[props.asn];
|
||||
if (!data) return {};
|
||||
|
||||
const parsePercentage = (str) => {
|
||||
if (!str) return null;
|
||||
const num = parseFloat(str.replace('%', ''));
|
||||
return isNaN(num) ? null : parseFloat(num.toFixed(2));
|
||||
};
|
||||
|
||||
const pairs = {};
|
||||
|
||||
const ipv4 = parsePercentage(data.IPv4_Pct);
|
||||
const ipv6 = parsePercentage(data.IPv6_Pct);
|
||||
if (ipv4 !== null && ipv6 !== null) pairs.ipVersion = { left: ipv4, right: ipv6 };
|
||||
|
||||
const http = parsePercentage(data.HTTP_Pct);
|
||||
const https = parsePercentage(data.HTTPS_Pct);
|
||||
if (http !== null && https !== null) pairs.httpProtocol = { left: http, right: https };
|
||||
|
||||
const desktop = parsePercentage(data.Desktop_Pct);
|
||||
const mobile = parsePercentage(data.Mobile_Pct);
|
||||
if (desktop !== null && mobile !== null) pairs.deviceType = { left: desktop, right: mobile };
|
||||
|
||||
const human = parsePercentage(data.Human_Pct);
|
||||
const bot = parsePercentage(data.Bot_Pct);
|
||||
if (human !== null && bot !== null) pairs.userType = { left: human, right: bot };
|
||||
|
||||
return pairs;
|
||||
});
|
||||
|
||||
const pairDataList = computed(() => {
|
||||
const list = [
|
||||
{ key: 'ipVersion', leftLabel: 'IPv4_Pct', rightLabel: 'IPv6_Pct' },
|
||||
{ key: 'httpProtocol', leftLabel: 'HTTP_Pct', rightLabel: 'HTTPS_Pct' },
|
||||
{ key: 'deviceType', leftLabel: 'Desktop_Pct', rightLabel: 'Mobile_Pct' },
|
||||
{ key: 'userType', leftLabel: 'Human_Pct', rightLabel: 'Bot_Pct' }
|
||||
];
|
||||
return list
|
||||
.filter(item => pairData.value[item.key])
|
||||
.map(item => ({
|
||||
leftLabel: item.leftLabel,
|
||||
rightLabel: item.rightLabel,
|
||||
leftValue: pairData.value[item.key].left,
|
||||
rightValue: pairData.value[item.key].right
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- Connection quality of an AS (Cloudflare speed-test aggregates): a titled
|
||||
two-column grid of whichever of download / upload / latency / jitter the
|
||||
Radar ASN summary carries. `info` is that summary (`/api/cfradar?view=asn`);
|
||||
renders nothing when no field is present. -->
|
||||
<template>
|
||||
<div v-if="Object.keys(qualityInfo).length" class="space-y-2 pt-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{{ t('ipInfos.ASNInfo.connectionQuality') }}</span>
|
||||
<JnTooltip :text="t('ipInfos.ASNInfo.connectionQualityTooltip')" side="top"
|
||||
class="hidden md:block">
|
||||
<CircleQuestionMark class="size-3 cursor-help opacity-70" />
|
||||
</JnTooltip>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||||
<div v-for="(item, key) in qualityInfo" :key="key">
|
||||
<dt class="text-xs text-muted-foreground mb-0.5">{{ t(`ipInfos.ASNInfo.${key}`) }}</dt>
|
||||
<dd class="font-normal wrap-break-word">{{ item }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { pickConnectionQuality } from '@/utils/asn-metrics.js';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { CircleQuestionMark } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const qualityInfo = computed(() => pickConnectionQuality(props.info));
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- Traffic shares of an AS: a titled stack of DataPairBar rows (IPv4/IPv6,
|
||||
HTTP/HTTPS, desktop/mobile, human/bot). `info` is the Radar ASN summary
|
||||
(`/api/cfradar?view=asn`); a pair shows only when both sides parse, and
|
||||
nothing renders when none does. -->
|
||||
<template>
|
||||
<div v-if="pairDataList.length" class="space-y-2.5 pt-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ t('ipInfos.ASNInfo.trafficPercentage') }}
|
||||
</div>
|
||||
<DataPairBar v-for="pair in pairDataList" :key="pair.leftLabel" :leftLabel="pair.leftLabel"
|
||||
:leftValue="pair.leftValue" :rightLabel="pair.rightLabel" :rightValue="pair.rightValue" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { buildTrafficPairs } from '@/utils/asn-metrics.js';
|
||||
import DataPairBar from './DataPairBar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const pairDataList = computed(() => buildTrafficPairs(props.info));
|
||||
</script>
|
||||
@@ -313,6 +313,7 @@ import { useMainStore } from '@/store';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
|
||||
import { loadAsnInfoInto } from '@/composables/use-asn-info.js';
|
||||
import { toBgpPrefix } from '@/utils/bgp-prefix.js';
|
||||
import { getZoneUtcOffset, getZoneLocalTime, formatIsoDate } from '@/utils/time-utils.js';
|
||||
import { buildScoreExplanation, listScoreTags } from '@/utils/ip-score-details.js';
|
||||
@@ -572,21 +573,11 @@ const onPanelOpenChange = (open) => {
|
||||
isPanelOpen.value = open;
|
||||
};
|
||||
|
||||
// Cache-buster: bump on response-shape changes, same rationale as
|
||||
// ASN_CONNECTIVITY_VERSION below.
|
||||
const ASN_INFO_VERSION = 2;
|
||||
|
||||
// Fetch + versioning live in composables/use-asn-info.js; the cache is the
|
||||
// owner's (IpInfos / QueryIP), handed down as the asnInfos prop.
|
||||
const getASNInfo = async (asn) => {
|
||||
trackEvent('IPCheck', 'ASNInfoClick', 'Show ASN Info');
|
||||
try {
|
||||
if (props.asnInfos[asn]) return;
|
||||
asn = asn.replace('AS', '');
|
||||
const response = await fetchWithTimeout(`/api/cfradar?view=asn&asn=${asn}&v=${ASN_INFO_VERSION}`);
|
||||
const data = await response.json();
|
||||
props.asnInfos['AS' + asn] = data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching ASN info:', error);
|
||||
}
|
||||
await loadAsnInfoInto(props.asnInfos, asn);
|
||||
};
|
||||
|
||||
const getASNHistory = async (prefix) => {
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
// - No Copy button (the IP was typed by the user — copying it is pointless).
|
||||
// - Map & country-traffic expand inline instead of opening dialogs (this IS a Dialog already).
|
||||
// - The panel's "view usage" link closes this dialog first, for the same reason.
|
||||
// - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard).
|
||||
// - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard);
|
||||
// asnInfos comes from useAsnInfo().
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { isValidIP, isUsablePublicIP } from '@/utils/valid-ip.js';
|
||||
@@ -76,6 +77,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import IpDetailPanel from '../ip-infos/IpDetailPanel.vue';
|
||||
import { useAsnInfo } from '@/composables/use-asn-info.js';
|
||||
import { Monitor, Search } from '@lucide/vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -91,7 +93,7 @@ const modalQueryResult = ref(null);
|
||||
const modalQueryError = ref('');
|
||||
const isChecking = ref('idle');
|
||||
const ipGeoSource = ref(userPreferences.value.ipGeoSource);
|
||||
const asnInfos = ref({});
|
||||
const { asnInfos } = useAsnInfo();
|
||||
const asnHistoryInfos = ref({});
|
||||
const asnConnectivityInfos = ref({});
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// ASN summary from Cloudflare Radar (`/api/cfradar?view=asn`) with a
|
||||
// per-owner session cache keyed 'AS<n>'. Owners (IpInfos, QueryIP, ASN
|
||||
// Profile) each create their own cache with useAsnInfo(); IpDetailPanel
|
||||
// fills whichever cache it is handed through loadAsnInfoInto().
|
||||
//
|
||||
// Relative imports keep this file importable from the Node test runner.
|
||||
import { reactive } from 'vue';
|
||||
import { fetchWithTimeout } from '../utils/fetch-with-timeout.js';
|
||||
|
||||
// Cache-buster: bump on response-shape changes (sent as `v=`).
|
||||
export const ASN_INFO_VERSION = 2;
|
||||
|
||||
export const asnInfoUrl = (asnNumber) => `/api/cfradar?view=asn&asn=${asnNumber}&v=${ASN_INFO_VERSION}`;
|
||||
|
||||
// Fetch `asn` ('AS13335') into `cache` unless already there; resolves to the
|
||||
// cached body, or null on a network / parse failure (nothing cached, so the
|
||||
// next call retries). Any parsed body is cached, error bodies included.
|
||||
export const loadAsnInfoInto = async (cache, asn) => {
|
||||
try {
|
||||
if (cache[asn]) return cache[asn];
|
||||
const asnNumber = asn.replace('AS', '');
|
||||
const response = await fetchWithTimeout(asnInfoUrl(asnNumber));
|
||||
const data = await response.json();
|
||||
cache['AS' + asnNumber] = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching ASN info:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// A fresh session cache (optionally seeded) and a loader bound to it.
|
||||
export const useAsnInfo = (initial = {}) => {
|
||||
const asnInfos = reactive({ ...initial });
|
||||
const loadAsnInfo = (asn) => loadAsnInfoInto(asnInfos, asn);
|
||||
return { asnInfos, loadAsnInfo };
|
||||
};
|
||||
@@ -18,6 +18,11 @@
|
||||
// component — lazy import of the tool's .vue (drawer + standalone)
|
||||
// requiresOriginalSite — gate: only shown on the original site (private API +
|
||||
// sign-in required); omitted == public tool
|
||||
// requiresConfig — gate: only shown when this `/api/configs` flag is
|
||||
// true (e.g. 'cloudFlare' for the Radar-backed tools)
|
||||
//
|
||||
// Gates decide listing only (card grid + nav), via isToolAvailable() in
|
||||
// utils/tool-availability.js; deep links (?tool= / /tools/:slug) ignore them.
|
||||
|
||||
export const ADVANCED_TOOLS = [
|
||||
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
|
||||
@@ -26,6 +31,7 @@ export const ADVANCED_TOOLS = [
|
||||
{ slug: 'dnsresolver', emoji: '📟', titleKey: 'dnsresolver.Title', noteKey: 'advancedtools.DNSResolverNote', component: () => import('@/components/advanced-tools/DnsResolver.vue') },
|
||||
{ slug: 'censorshipcheck', emoji: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', component: () => import('@/components/advanced-tools/CensorshipCheck.vue') },
|
||||
{ slug: 'whois', emoji: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', component: () => import('@/components/advanced-tools/Whois.vue') },
|
||||
{ slug: 'asn', emoji: '🛂', titleKey: 'asnprofile.Title', noteKey: 'advancedtools.AsnProfile', component: () => import('@/components/advanced-tools/AsnProfile.vue'), requiresConfig: 'cloudFlare' },
|
||||
{ slug: 'macchecker', emoji: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', component: () => import('@/components/advanced-tools/MacChecker.vue') },
|
||||
{ slug: 'ipcalculator', emoji: '🔢', titleKey: 'ipcalculator.Title', noteKey: 'advancedtools.IpCalculator', component: () => import('@/components/advanced-tools/IpCalculator.vue') },
|
||||
{ slug: 'browserinfo', emoji: '🖥️', titleKey: 'browserinfo.Title', noteKey: 'advancedtools.BrowserInfo', component: () => import('@/components/advanced-tools/BrowserInfo.vue') },
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "Check which countries a website is blocked in",
|
||||
"ServiceStatus": "Check the live availability of well-known AI and developer services, and whether a recent incident explains the outage you're seeing",
|
||||
"Whois": "Search for domain/IP/ASN registration information",
|
||||
"AsnProfile": "Identity, scale, announced prefixes, global footprint, connectivity and registration of any AS",
|
||||
"InvisibilityTest": "Check if you are using a proxy or VPN",
|
||||
"MacChecker": "Query information of a physical address",
|
||||
"IpCalculator": "Subnet math, notation conversion and IPv6 insight for any IP, prefix or range",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "Unable to fetch query results or query results are empty",
|
||||
"Provider": "Information Provider"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "ASN Profile"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "In-depth DNS Leak Test",
|
||||
"Note": "The homepage DNS leak test already gave you a rough idea of whether you're leaking and where your DNS traffic exits. This deeper test adds more dimensions: it captures more of your resolver exit IPs, checks whether any of them forwards a prefix of your real IP upstream via ECS, and checks whether they support DNSSEC — so you come away with a more complete picture of your DNS chain's security posture.",
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "Vérifier dans quels pays un site est bloqué",
|
||||
"ServiceStatus": "Vérifiez la disponibilité en temps réel des services d'IA et de développement connus, et si un incident récent explique la panne que vous constatez",
|
||||
"Whois": "Recherche d'informations sur l'enregistrement de domaine/IP/AS",
|
||||
"AsnProfile": "Identité, taille, préfixes annoncés, présence mondiale, interconnexions et enregistrement de n'importe quel AS",
|
||||
"InvisibilityTest": "Vérifiez si vous utilisez un proxy ou un VPN",
|
||||
"MacChecker": "Requête d'informations d'une adresse physique",
|
||||
"IpCalculator": "Calcul de sous-réseau, conversion de notation et analyse IPv6 pour toute IP, préfixe ou plage",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "Impossible de récupérer les résultats de la requête ou les résultats de la requête sont vides",
|
||||
"Provider": "Fournisseur d'informations"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "Profil ASN"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "Test de fuite DNS approfondi",
|
||||
"Note": "Le test de fuite DNS de la page d'accueil vous a déjà donné une idée générale : avez-vous une fuite, et par quel pays votre trafic DNS sort. Ce test approfondi ajoute plusieurs dimensions : nous capturons davantage d'IPs de sortie de vos résolveurs, vérifions si certains d'entre eux transmettent un préfixe de votre IP réelle en amont via ECS, et vérifions s'ils prennent en charge DNSSEC — pour que vous obteniez une vue plus complète de la sécurité de votre chaîne DNS.",
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "Verifique em quais países um site está bloqueado",
|
||||
"ServiceStatus": "Verifique a disponibilidade ao vivo de serviços conhecidos de IA e desenvolvimento, e se um incidente recente explica a falha que você está vendo",
|
||||
"Whois": "Pesquise informações de registro de domínio/IP/AS",
|
||||
"AsnProfile": "Identidade, porte, prefixos anunciados, presença global, conectividade e registro de qualquer AS",
|
||||
"InvisibilityTest": "Verifique se você está usando proxy ou VPN",
|
||||
"MacChecker": "Consultar informações de um endereço físico",
|
||||
"IpCalculator": "Cálculo de sub-rede, conversão de notação e análise IPv6 para qualquer IP, prefixo ou intervalo",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "Não foi possível obter os resultados da consulta, ou os resultados estão vazios",
|
||||
"Provider": "Provedor das informações"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "Perfil de ASN"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "Teste aprofundado de vazamento de DNS",
|
||||
"Note": "O teste de vazamento de DNS da página inicial já deu uma ideia geral de se você está vazando e por onde seu tráfego DNS sai. Este teste mais profundo adiciona mais dimensões: captura mais IPs de saída dos seus resolvedores, verifica se algum deles encaminha upstream um prefixo do seu IP real via ECS e verifica se eles oferecem suporte a DNSSEC — assim você obtém um quadro mais completo da postura de segurança da sua cadeia DNS.",
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "Проверка блокировки сайта в каких странах",
|
||||
"ServiceStatus": "Проверка доступности известных сервисов AI и разработчиков, а также недавних инцидентов, которые могут объяснить сбой",
|
||||
"Whois": "Поиск регистрационных сведений о домене, IP или AS",
|
||||
"AsnProfile": "Сведения о любой AS: владелец, масштаб, анонсируемые префиксы, география, связность и регистрация",
|
||||
"InvisibilityTest": "Проверка использования прокси или VPN",
|
||||
"MacChecker": "Поиск сведений о физическом адресе",
|
||||
"IpCalculator": "Расчёт подсетей, преобразование записей и анализ IPv6 для любого IP, префикса или диапазона",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "Не удалось получить результаты запроса или они пусты",
|
||||
"Provider": "Поставщик данных"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "Профиль ASN"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "Углублённый тест утечки DNS",
|
||||
"Note": "Тест утечки DNS на главной странице уже даёт общее представление о наличии утечки и точке выхода DNS-трафика. Этот углублённый тест собирает больше выходных IP ваших резолверов, проверяет, передаёт ли кто-либо из них вышестоящим серверам префикс вашего реального IP через ECS, и поддерживают ли они DNSSEC. В итоге вы получите более полную картину безопасности цепочки DNS.",
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "檢查一個網站在全球哪些國家被封鎖",
|
||||
"ServiceStatus": "檢視知名 AI 與開發者服務的即時可用性,確認你遇到的中斷是不是對方的故障",
|
||||
"Whois": "對網域名稱、IP 或 AS 號碼進行 Whois 查詢",
|
||||
"AsnProfile": "查看任意自治系統的身分、規模、宣告前綴、全球分布、互連關係和註冊資訊",
|
||||
"InvisibilityTest": "猜猜看我是否知道你掛了代理",
|
||||
"MacChecker": "查詢實體位址的歸屬資訊",
|
||||
"IpCalculator": "任意 IP、前綴或範圍的子網路計算、進位轉換與 IPv6 解讀",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "無法取得查詢結果或查詢結果為空",
|
||||
"Provider": "資訊服務商"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "ASN 檔案"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "深度 DNS 洩漏測試",
|
||||
"Note": "首頁的 DNS 洩漏測試已讓你初步了解是否發生洩漏,以及 DNS 流量從哪裡送出。這項進階測試會進一步偵測更多遞迴 DNS 解析器的出口 IP、檢查解析器是否透過 ECS 將你真實 IP 網段的前綴轉送至上游權威 DNS 伺服器,以及是否支援 DNSSEC,讓你更完整地了解 DNS 連線的安全狀況。",
|
||||
|
||||
@@ -870,6 +870,7 @@
|
||||
"CensorshipCheck": "检查一个网站在全球哪些国家被封锁",
|
||||
"ServiceStatus": "查看知名 AI 与开发者服务的实时可用性,确认你遇到的中断是不是对方的故障",
|
||||
"Whois": "对域名、IP 或 AS 号进行 Whois 查询",
|
||||
"AsnProfile": "查看任意自治系统的身份、规模、宣告前缀、全球分布、互联关系和注册信息",
|
||||
"InvisibilityTest": "猜猜看我是否知道你挂了代理",
|
||||
"MacChecker": "查询物理地址的归属信息",
|
||||
"IpCalculator": "任意 IP、前缀或范围的子网计算、进制转换与 IPv6 解读",
|
||||
@@ -1043,6 +1044,9 @@
|
||||
"fetchError": "无法获取查询结果或查询结果为空",
|
||||
"Provider": "信息服务商"
|
||||
},
|
||||
"asnprofile": {
|
||||
"Title": "ASN 档案"
|
||||
},
|
||||
"enhanceddnsleaktest": {
|
||||
"Title": "深度 DNS 泄露测试",
|
||||
"Note": "你已经在首页的 DNS 泄露测试中大致了解了当前是否存在 DNS 泄露,以及 DNS 请求的出口地理归属。在这个深度测试里,我们会从更多维度进行检测:捕获更多 DNS 解析器出口 IP、检查这些解析器是否通过 ECS 把你真实 IP 段的前缀转发给了权威服务器、以及它们是否支持 DNSSEC,从而帮助你对自己的 DNS 链路安全性形成更完整的认识。",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Pure shaping of the Radar ASN summary (`/api/cfradar?view=asn`) for the
|
||||
// traffic-share bars and the connection-quality grid (AsnTrafficShares.vue,
|
||||
// AsnConnectionQuality.vue). The backend sends both pre-formatted as strings
|
||||
// ('95.35%', '312.4 Mbps').
|
||||
|
||||
// '95.35%' / 95.35 → 95.35 (two decimals); missing or unparseable → null.
|
||||
export const parsePercentage = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = parseFloat(String(value).replace('%', ''));
|
||||
return isNaN(num) ? null : parseFloat(num.toFixed(2));
|
||||
};
|
||||
|
||||
// Bar order; labels double as the ipInfos.ASNInfo.* i18n keys.
|
||||
const TRAFFIC_PAIRS = [
|
||||
{ leftLabel: 'IPv4_Pct', rightLabel: 'IPv6_Pct' },
|
||||
{ leftLabel: 'HTTP_Pct', rightLabel: 'HTTPS_Pct' },
|
||||
{ leftLabel: 'Desktop_Pct', rightLabel: 'Mobile_Pct' },
|
||||
{ leftLabel: 'Human_Pct', rightLabel: 'Bot_Pct' },
|
||||
];
|
||||
|
||||
// DataPairBar props per pair; a pair is dropped unless both sides parse.
|
||||
export const buildTrafficPairs = (info) => {
|
||||
if (!info) return [];
|
||||
return TRAFFIC_PAIRS.flatMap(({ leftLabel, rightLabel }) => {
|
||||
const leftValue = parsePercentage(info[leftLabel]);
|
||||
const rightValue = parsePercentage(info[rightLabel]);
|
||||
if (leftValue === null || rightValue === null) return [];
|
||||
return [{ leftLabel, rightLabel, leftValue, rightValue }];
|
||||
});
|
||||
};
|
||||
|
||||
const QUALITY_KEYS = ['speedDownload', 'speedUpload', 'latency', 'jitter'];
|
||||
|
||||
// The quality fields present (truthy) on `info`, in display order.
|
||||
export const pickConnectionQuality = (info) => {
|
||||
const picked = {};
|
||||
if (!info) return picked;
|
||||
for (const key of QUALITY_KEYS) {
|
||||
if (info[key]) picked[key] = info[key];
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// Pure helpers for the ASN Profile tool: input parsing (re-exported) and
|
||||
// announced-space sizing over a prefix list. CIDR arithmetic comes from
|
||||
// ip-math.js.
|
||||
|
||||
import { parseCidr, aggregateCidrs, addressCount } from './ip-math.js';
|
||||
|
||||
// ASN input parsing lives in asn-input.js (shared with the Whois tool).
|
||||
export { MAX_ASN, parseAsnInput } from './asn-input.js';
|
||||
|
||||
const IPV4_SPACE = 2 ** 32;
|
||||
|
||||
// Size of one prefix: IPv4 in addresses, IPv6 in /48s (the longest prefix
|
||||
// the global table carries). A v6 prefix longer than /48 has count null.
|
||||
// Invalid input → null.
|
||||
export const prefixSize = (cidr) => {
|
||||
const parsed = parseCidr(typeof cidr === 'string' ? cidr.trim() : cidr);
|
||||
if (!parsed) return null;
|
||||
const { family, prefix } = parsed;
|
||||
if (family === 4) return { family, prefix, count: 2 ** (32 - prefix) };
|
||||
return { family, prefix, count: prefix <= 48 ? 2 ** (48 - prefix) : null };
|
||||
};
|
||||
|
||||
// Distinct IPv4 addresses a prefix list covers. Overlaps (a covering route
|
||||
// plus its more-specifics) count once; IPv6 and junk entries are ignored.
|
||||
export const announcedIpv4 = (prefixes) => aggregateCidrs(prefixes).v4
|
||||
.reduce((sum, cidr) => sum + Number(addressCount(parseCidr(cidr).prefix, 4)), 0);
|
||||
|
||||
// Share of the whole IPv4 space (2^32), as a percentage.
|
||||
export const ipv4SharePercent = (addresses) => {
|
||||
const count = Number(addresses);
|
||||
if (!Number.isFinite(count) || count <= 0) return 0;
|
||||
return (Math.min(count, IPV4_SPACE) / IPV4_SPACE) * 100;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// Deployment gates for the Advanced Tools registry (data/tools.js): decides
|
||||
// whether a tool is listed in the card grid and the nav. A tool can require
|
||||
// the original site (`requiresOriginalSite`) and/or one `/api/configs` flag
|
||||
// (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch
|
||||
// lands), so a gated tool reads as unavailable until then. Deep links
|
||||
// (`?tool=` drawer, `/tools/:slug`) are not gated.
|
||||
|
||||
export const isToolAvailable = (tool, configs) => {
|
||||
if (!tool) return false;
|
||||
if (tool.requiresOriginalSite && !configs?.originalSite) return false;
|
||||
if (tool.requiresConfig && !configs?.[tool.requiresConfig]) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// Tests for frontend/utils/asn-metrics.js — percentage parsing, traffic-pair
|
||||
// building and connection-quality picking over the Radar ASN summary.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { parsePercentage, buildTrafficPairs, pickConnectionQuality } from '../frontend/utils/asn-metrics.js';
|
||||
|
||||
describe('parsePercentage', () => {
|
||||
it('parses backend strings with or without the % sign', () => {
|
||||
assert.equal(parsePercentage('95.35%'), 95.35);
|
||||
assert.equal(parsePercentage('4.65'), 4.65);
|
||||
assert.equal(parsePercentage('0.00%'), 0);
|
||||
});
|
||||
|
||||
it('rounds to two decimals', () => {
|
||||
assert.equal(parsePercentage('33.33333%'), 33.33);
|
||||
assert.equal(parsePercentage('66.666%'), 66.67);
|
||||
});
|
||||
|
||||
it('accepts numbers', () => {
|
||||
assert.equal(parsePercentage(72.456), 72.46);
|
||||
assert.equal(parsePercentage(0), 0);
|
||||
});
|
||||
|
||||
it('missing or unparseable input is null', () => {
|
||||
assert.equal(parsePercentage(undefined), null);
|
||||
assert.equal(parsePercentage(null), null);
|
||||
assert.equal(parsePercentage(''), null);
|
||||
assert.equal(parsePercentage('NaN%'), null);
|
||||
assert.equal(parsePercentage('n/a'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTrafficPairs', () => {
|
||||
const FULL = {
|
||||
IPv4_Pct: '95.35%', IPv6_Pct: '4.65%',
|
||||
HTTP_Pct: '3.16%', HTTPS_Pct: '96.84%',
|
||||
Desktop_Pct: '58.88%', Mobile_Pct: '41.12%',
|
||||
Bot_Pct: '98.46%', Human_Pct: '1.54%',
|
||||
};
|
||||
|
||||
it('emits the four pairs in display order, human on the left of bot', () => {
|
||||
assert.deepEqual(buildTrafficPairs(FULL), [
|
||||
{ leftLabel: 'IPv4_Pct', rightLabel: 'IPv6_Pct', leftValue: 95.35, rightValue: 4.65 },
|
||||
{ leftLabel: 'HTTP_Pct', rightLabel: 'HTTPS_Pct', leftValue: 3.16, rightValue: 96.84 },
|
||||
{ leftLabel: 'Desktop_Pct', rightLabel: 'Mobile_Pct', leftValue: 58.88, rightValue: 41.12 },
|
||||
{ leftLabel: 'Human_Pct', rightLabel: 'Bot_Pct', leftValue: 1.54, rightValue: 98.46 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a pair unless both sides parse', () => {
|
||||
const pairs = buildTrafficPairs({ ...FULL, IPv6_Pct: undefined, Bot_Pct: 'NaN%' });
|
||||
assert.deepEqual(pairs.map((pair) => pair.leftLabel), ['HTTP_Pct', 'Desktop_Pct']);
|
||||
});
|
||||
|
||||
it('keeps a pair whose side is 0%', () => {
|
||||
const pairs = buildTrafficPairs({ HTTP_Pct: '0.00%', HTTPS_Pct: '100.00%' });
|
||||
assert.deepEqual(pairs, [{ leftLabel: 'HTTP_Pct', rightLabel: 'HTTPS_Pct', leftValue: 0, rightValue: 100 }]);
|
||||
});
|
||||
|
||||
it('no data gives no pairs', () => {
|
||||
assert.deepEqual(buildTrafficPairs(undefined), []);
|
||||
assert.deepEqual(buildTrafficPairs(null), []);
|
||||
assert.deepEqual(buildTrafficPairs({ error: 'upstream' }), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickConnectionQuality', () => {
|
||||
it('picks the present fields in display order', () => {
|
||||
const picked = pickConnectionQuality({
|
||||
jitter: '4.0 ms', asnName: 'X', speedDownload: '312.4 Mbps', latency: '18 ms', speedUpload: '98.1 Mbps',
|
||||
});
|
||||
assert.deepEqual(Object.entries(picked), [
|
||||
['speedDownload', '312.4 Mbps'], ['speedUpload', '98.1 Mbps'], ['latency', '18 ms'], ['jitter', '4.0 ms'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips missing / empty fields', () => {
|
||||
assert.deepEqual(pickConnectionQuality({ speedDownload: '', latency: '18 ms' }), { latency: '18 ms' });
|
||||
});
|
||||
|
||||
it('no data gives an empty object', () => {
|
||||
assert.deepEqual(pickConnectionQuality(undefined), {});
|
||||
assert.deepEqual(pickConnectionQuality({}), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Tests for frontend/utils/asn-profile.js — per-prefix sizing (v4
|
||||
// addresses, v6 /48s) and announced-IPv4 totals / share. ASN input parsing
|
||||
// is covered in asn-input.test.js; here only its re-export.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
parseAsnInput, prefixSize, announcedIpv4, ipv4SharePercent,
|
||||
} from '../frontend/utils/asn-profile.js';
|
||||
import { parseAsnInput as parseAsnInputDirect } from '../frontend/utils/asn-input.js';
|
||||
|
||||
it('re-exports parseAsnInput from asn-input.js', () => {
|
||||
assert.equal(parseAsnInput, parseAsnInputDirect);
|
||||
});
|
||||
|
||||
describe('prefixSize', () => {
|
||||
it('counts IPv4 addresses', () => {
|
||||
assert.deepEqual(prefixSize('198.51.100.0/24'), { family: 4, prefix: 24, count: 256 });
|
||||
assert.deepEqual(prefixSize('104.16.0.0/13'), { family: 4, prefix: 13, count: 524288 });
|
||||
assert.deepEqual(prefixSize('192.0.2.1/32'), { family: 4, prefix: 32, count: 1 });
|
||||
assert.deepEqual(prefixSize('0.0.0.0/0'), { family: 4, prefix: 0, count: 4294967296 });
|
||||
});
|
||||
|
||||
it('counts IPv6 in /48s', () => {
|
||||
assert.deepEqual(prefixSize('2001:db8::/32'), { family: 6, prefix: 32, count: 65536 });
|
||||
assert.deepEqual(prefixSize('2001:db8:1::/48'), { family: 6, prefix: 48, count: 1 });
|
||||
assert.deepEqual(prefixSize('2a06:98c0::/29'), { family: 6, prefix: 29, count: 524288 });
|
||||
});
|
||||
|
||||
it('an IPv6 prefix longer than /48 has no /48 count', () => {
|
||||
assert.deepEqual(prefixSize('2001:db8:1:2::/64'), { family: 6, prefix: 64, count: null });
|
||||
});
|
||||
|
||||
it('invalid input is null', () => {
|
||||
for (const raw of ['', '198.51.100.0', '198.51.100.0/33', '2001:db8::/129', 'AS13335', null, 42]) {
|
||||
assert.equal(prefixSize(raw), null, String(raw));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('announcedIpv4', () => {
|
||||
it('sums disjoint IPv4 prefixes', () => {
|
||||
assert.equal(announcedIpv4(['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24']), 768);
|
||||
});
|
||||
|
||||
it('counts a covering route and its more-specifics once', () => {
|
||||
assert.equal(announcedIpv4(['198.18.0.0/15', '198.18.0.0/24', '198.19.128.0/18']), 131072);
|
||||
});
|
||||
|
||||
it('ignores IPv6 and junk', () => {
|
||||
assert.equal(announcedIpv4(['192.0.2.0/24', '2001:db8::/32', 'nonsense']), 256);
|
||||
});
|
||||
|
||||
it('empty or missing list is 0', () => {
|
||||
assert.equal(announcedIpv4([]), 0);
|
||||
assert.equal(announcedIpv4(undefined), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ipv4SharePercent', () => {
|
||||
it('is the percentage of 2^32', () => {
|
||||
assert.equal(ipv4SharePercent(2 ** 32), 100);
|
||||
assert.equal(ipv4SharePercent(2 ** 24), 100 / 256);
|
||||
assert.equal(ipv4SharePercent(256), (256 / 2 ** 32) * 100);
|
||||
});
|
||||
|
||||
it('clamps and guards odd input', () => {
|
||||
assert.equal(ipv4SharePercent(2 ** 33), 100);
|
||||
assert.equal(ipv4SharePercent(0), 0);
|
||||
assert.equal(ipv4SharePercent(-5), 0);
|
||||
assert.equal(ipv4SharePercent(NaN), 0);
|
||||
assert.equal(ipv4SharePercent(undefined), 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Tests for frontend/composables/use-asn-info.js: the request URL (incl. the
|
||||
// `v=` cache-buster), the 'AS<n>' cache key, cache hits skipping the network,
|
||||
// and failures staying uncached. globalThis.fetch is stubbed per test.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import { isReactive } from 'vue';
|
||||
import {
|
||||
ASN_INFO_VERSION, asnInfoUrl, loadAsnInfoInto, useAsnInfo,
|
||||
} from '../frontend/composables/use-asn-info.js';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
const realConsoleError = console.error;
|
||||
let calls;
|
||||
|
||||
const stubFetch = (impl) => {
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push(url);
|
||||
return impl(url, init);
|
||||
};
|
||||
};
|
||||
const jsonResponse = (body, status = 200) => ({ ok: status < 400, status, json: async () => body });
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
console.error = () => {};
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
console.error = realConsoleError;
|
||||
});
|
||||
|
||||
describe('asnInfoUrl', () => {
|
||||
it('targets the Radar asn view with the version param', () => {
|
||||
assert.equal(asnInfoUrl('13335'), `/api/cfradar?view=asn&asn=13335&v=${ASN_INFO_VERSION}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAsnInfoInto', () => {
|
||||
it('fetches the numeric ASN and caches under AS<n>', async () => {
|
||||
stubFetch(() => jsonResponse({ asnName: 'CLOUDFLARENET' }));
|
||||
const cache = {};
|
||||
const data = await loadAsnInfoInto(cache, 'AS13335');
|
||||
assert.deepEqual(calls, [`/api/cfradar?view=asn&asn=13335&v=${ASN_INFO_VERSION}`]);
|
||||
assert.deepEqual(cache, { AS13335: { asnName: 'CLOUDFLARENET' } });
|
||||
assert.equal(data, cache.AS13335);
|
||||
});
|
||||
|
||||
it('a cached ASN resolves without a request', async () => {
|
||||
stubFetch(() => jsonResponse({}));
|
||||
const cache = { AS64500: { asnName: 'CACHED' } };
|
||||
const data = await loadAsnInfoInto(cache, 'AS64500');
|
||||
assert.equal(calls.length, 0);
|
||||
assert.deepEqual(data, { asnName: 'CACHED' });
|
||||
});
|
||||
|
||||
it('caches any parsed body, error responses included', async () => {
|
||||
stubFetch(() => jsonResponse({ error: 'upstream' }, 500));
|
||||
const cache = {};
|
||||
await loadAsnInfoInto(cache, 'AS64501');
|
||||
assert.deepEqual(cache.AS64501, { error: 'upstream' });
|
||||
});
|
||||
|
||||
it('a network failure resolves null, caches nothing and stays retryable', async () => {
|
||||
stubFetch(() => { throw new TypeError('network down'); });
|
||||
const cache = {};
|
||||
assert.equal(await loadAsnInfoInto(cache, 'AS64502'), null);
|
||||
assert.deepEqual(cache, {});
|
||||
|
||||
stubFetch(() => jsonResponse({ asnName: 'BACK' }));
|
||||
await loadAsnInfoInto(cache, 'AS64502');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(cache.AS64502, { asnName: 'BACK' });
|
||||
});
|
||||
|
||||
it('an unparseable body resolves null and caches nothing', async () => {
|
||||
stubFetch(() => ({ ok: true, json: async () => { throw new SyntaxError('bad json'); } }));
|
||||
const cache = {};
|
||||
assert.equal(await loadAsnInfoInto(cache, 'AS64503'), null);
|
||||
assert.deepEqual(cache, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAsnInfo', () => {
|
||||
it('creates a reactive cache, optionally seeded, without sharing it', () => {
|
||||
const seed = { AS1: { asnName: 'SEED' } };
|
||||
const a = useAsnInfo(seed);
|
||||
const b = useAsnInfo();
|
||||
assert.ok(isReactive(a.asnInfos));
|
||||
assert.deepEqual({ ...a.asnInfos }, seed);
|
||||
assert.deepEqual({ ...b.asnInfos }, {});
|
||||
a.asnInfos.AS2 = {};
|
||||
assert.equal('AS2' in seed, false, 'the seed object is copied');
|
||||
});
|
||||
|
||||
it('the bound loader fills its own cache only', async () => {
|
||||
stubFetch(() => jsonResponse({ asnName: 'BOUND' }));
|
||||
const a = useAsnInfo();
|
||||
const b = useAsnInfo();
|
||||
await a.loadAsnInfo('AS64504');
|
||||
assert.deepEqual(a.asnInfos.AS64504, { asnName: 'BOUND' });
|
||||
assert.equal(b.asnInfos.AS64504, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Tests for frontend/utils/tool-availability.js — the listing gate the
|
||||
// Advanced Tools card grid and nav filter through — plus the gate fields the
|
||||
// registry in frontend/data/tools.js declares.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { isToolAvailable } from '../frontend/utils/tool-availability.js';
|
||||
import { ADVANCED_TOOLS, TOOL_BY_SLUG } from '../frontend/data/tools.js';
|
||||
|
||||
const PUBLIC = { slug: 'public' };
|
||||
const ORIGINAL = { slug: 'original', requiresOriginalSite: true };
|
||||
const RADAR = { slug: 'radar', requiresConfig: 'cloudFlare' };
|
||||
const BOTH = { slug: 'both', requiresOriginalSite: true, requiresConfig: 'cloudFlare' };
|
||||
|
||||
describe('isToolAvailable', () => {
|
||||
it('an ungated tool is always available', () => {
|
||||
assert.equal(isToolAvailable(PUBLIC, {}), true);
|
||||
assert.equal(isToolAvailable(PUBLIC, undefined), true);
|
||||
assert.equal(isToolAvailable(PUBLIC, { originalSite: false, cloudFlare: false }), true);
|
||||
});
|
||||
|
||||
it('a gated tool is unavailable until configs land', () => {
|
||||
assert.equal(isToolAvailable(ORIGINAL, {}), false);
|
||||
assert.equal(isToolAvailable(RADAR, {}), false);
|
||||
assert.equal(isToolAvailable(RADAR, null), false);
|
||||
});
|
||||
|
||||
it('requiresOriginalSite follows configs.originalSite', () => {
|
||||
assert.equal(isToolAvailable(ORIGINAL, { originalSite: true }), true);
|
||||
assert.equal(isToolAvailable(ORIGINAL, { originalSite: false }), false);
|
||||
});
|
||||
|
||||
it('requiresConfig follows the named configs flag', () => {
|
||||
assert.equal(isToolAvailable(RADAR, { cloudFlare: true }), true);
|
||||
assert.equal(isToolAvailable(RADAR, { cloudFlare: false }), false);
|
||||
assert.equal(isToolAvailable(RADAR, { map: true }), false);
|
||||
});
|
||||
|
||||
it('both gates must pass', () => {
|
||||
assert.equal(isToolAvailable(BOTH, { originalSite: true, cloudFlare: true }), true);
|
||||
assert.equal(isToolAvailable(BOTH, { originalSite: true, cloudFlare: false }), false);
|
||||
assert.equal(isToolAvailable(BOTH, { originalSite: false, cloudFlare: true }), false);
|
||||
});
|
||||
|
||||
it('no tool is unavailable', () => {
|
||||
assert.equal(isToolAvailable(null, { cloudFlare: true }), false);
|
||||
assert.equal(isToolAvailable(undefined, {}), false);
|
||||
});
|
||||
|
||||
it('filters a list in registry order', () => {
|
||||
const tools = [PUBLIC, ORIGINAL, RADAR, BOTH];
|
||||
const listed = (configs) => tools.filter((tool) => isToolAvailable(tool, configs)).map((tool) => tool.slug);
|
||||
assert.deepEqual(listed({}), ['public']);
|
||||
assert.deepEqual(listed({ originalSite: false, cloudFlare: true }), ['public', 'radar']);
|
||||
assert.deepEqual(listed({ originalSite: true, cloudFlare: true }), ['public', 'original', 'radar', 'both']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry gates (data/tools.js)', () => {
|
||||
it('ASN Profile is gated on the Cloudflare Radar key only', () => {
|
||||
const asn = TOOL_BY_SLUG.get('asn');
|
||||
assert.ok(asn, 'asn tool is registered');
|
||||
assert.equal(asn.requiresConfig, 'cloudFlare');
|
||||
assert.equal(asn.requiresOriginalSite, undefined);
|
||||
assert.equal(isToolAvailable(asn, { cloudFlare: false, originalSite: true }), false);
|
||||
assert.equal(isToolAvailable(asn, { cloudFlare: true, originalSite: false }), true);
|
||||
});
|
||||
|
||||
it('every requiresConfig is a non-empty string', () => {
|
||||
for (const tool of ADVANCED_TOOLS.filter((entry) => 'requiresConfig' in entry)) {
|
||||
assert.equal(typeof tool.requiresConfig, 'string', tool.slug);
|
||||
assert.ok(tool.requiresConfig.length > 0, tool.slug);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user