-
-
-
-
- {{ t('ipInfos.type') }}
-
- - {{ data.type }}
-
+
+
+
+
+
-
+
+ {{ t('ipInfos.type') }}
+
+ - {{ data.type }}
+
-
-
-
-
- {{ t('ipInfos.isProxy') }}
-
- -
- {{ data.isProxy }}
-
- -
- {{ data.proxyProvider }} {{ data.proxyProtocol
- &&
- data.proxyProtocol !== 'unknown'
- ?
- '· ' + data.proxyProtocol : '' }}
-
-
+
+
-
+
+ {{ t('ipInfos.anonymity.label') }}
+
+ -
+ {{ data.anonymity }}
+
+
+ -
+ {{ anonymityService }}
+
+
-
-
-
-
- {{ t('ipInfos.advancedData.Nativeness') }}
-
-
-
-
- -
-
- {{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') :
- t('ipInfos.advancedData.NativeIPNo') }}
-
-
+
+
-
+
+ {{ t('ipInfos.advancedData.Nativeness') }}
+
+
+
+
+ -
+
+ {{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') :
+ t('ipInfos.advancedData.NativeIPNo') }}
+
+
-
-
-
-
- {{ t('ipInfos.qualityScore') }}
-
-
-
-
- {{ t('ipInfos.qualityScoreUnknown') }}
-
-
-
-
{{ data.qualityScore }}/100
+
+
-
+
+ {{ t('ipInfos.qualityScore') }}
+
+
-
+
+ {{ t('ipInfos.qualityScoreUnknown') }}
+
+
+
+ {{ data.qualityScore }}/100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('ipInfos.scoreDetails.explainTitle') }}
+
+
+
+
+ {{ sentence.params.ip }}
+ {{ sentence.params.operator }}
+ {{ sentence.params.score }}
+ {{ sentence.params.date }}
+ {{ i === scoreExplanation.length - 1 ? '' : sentenceGap }}
+
+
+
+
+
+
+
{{ t('ipInfos.scoreDetails.tagsTitle') }}
+
+
+
-
+ {{ t('ipInfos.scoreDetails.tags.' + tag.key) }}
+
+
+ -
+
+ {{ t('ipInfos.scoreDetails.tagState.' + tag.state) }}
+
+
+
+
+
-
-
-
+
+
+
@@ -256,13 +308,14 @@
// Used by IPCard (homepage card grid) and QueryIP (manual IP lookup dialog).
// Hero IP is NOT part of this panel — consumers render their own hero row since affordances
// (copy button, etc.) differ.
-import { ref, computed } from 'vue';
+import { ref, computed, watch } from 'vue';
import { useMainStore } from '@/store';
-import { useI18n } from 'vue-i18n';
+import { useI18n, I18nT } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { toBgpPrefix } from '@/utils/bgp-prefix.js';
-import { getZoneUtcOffset, getZoneLocalTime } from '@/utils/time-utils.js';
+import { getZoneUtcOffset, getZoneLocalTime, formatIsoDate } from '@/utils/time-utils.js';
+import { buildScoreExplanation, listScoreTags } from '@/utils/ip-score-details.js';
import ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import CountryTraffic from './CountryTraffic.vue';
@@ -281,6 +334,8 @@ import { Icon } from '@iconify/vue';
import {
Activity,
Building2,
+ Check,
+ ChevronDown,
CornerUpRight,
Equal,
EqualNot,
@@ -295,6 +350,7 @@ import {
MapPin,
ShieldCheck,
SignalHigh,
+ X,
CircleQuestionMark,
Clock,
FolderClock,
@@ -367,13 +423,13 @@ const isGatedValue = (value) => value === 'sign_in_required' || value === 'quota
// of rendering four individual gated rows. The two sentinels pick different copy.
const allAdvancedLocked = computed(() =>
props.data.type === 'sign_in_required' &&
- props.data.isProxy === 'sign_in_required' &&
+ props.data.anonymity === 'sign_in_required' &&
props.data.isNativeIP === 'sign_in_required' &&
props.data.qualityScore === 'sign_in_required'
);
const allAdvancedQuotaExceeded = computed(() =>
props.data.type === 'quota_exceeded' &&
- props.data.isProxy === 'quota_exceeded' &&
+ props.data.anonymity === 'quota_exceeded' &&
props.data.isNativeIP === 'quota_exceeded' &&
props.data.qualityScore === 'quota_exceeded'
);
@@ -382,9 +438,12 @@ const showTypeBadge = computed(() =>
props.data.type && !isGatedValue(props.data.type)
&& props.data.type !== t('ipInfos.advancedData.type.unknownType')
);
-const showProxyBadge = computed(() =>
- props.data.isProxy && !isGatedValue(props.data.isProxy)
- && props.data.isProxy !== t('ipInfos.advancedData.proxyUnknown')
+const showAnonymityBadge = computed(() =>
+ Boolean(props.data.anonymity) && !isGatedValue(props.data.anonymity)
+);
+// "Provider · protocol"; both are null unless the API named a service.
+const anonymityService = computed(() =>
+ [props.data.anonymityProvider, props.data.anonymityProtocol].filter(Boolean).join(' · ')
);
const showNativeBadge = computed(() =>
props.data.isNativeIP !== undefined && !isGatedValue(props.data.isNativeIP)
@@ -396,7 +455,7 @@ const showQualityScore = computed(() =>
// Locked field preview: the 4 advanced fields shown as "label + ***" for signed-out users.
const lockedFieldList = computed(() => [
{ key: 'type', icon: SignalHigh, label: t('ipInfos.type') },
- { key: 'proxy', icon: ShieldCheck, label: t('ipInfos.isProxy') },
+ { key: 'anonymity', icon: ShieldCheck, label: t('ipInfos.anonymity.label') },
{ key: 'native', icon: House, label: t('ipInfos.advancedData.Nativeness') },
{ key: 'quality', icon: Gauge, label: t('ipInfos.qualityScore') },
]);
@@ -410,6 +469,30 @@ const qualityTone = computed(() => {
return 'fail';
});
+// Score-details panel (IPCheck.ing source): explanation sentences (need
+// `dimensions`) and attribute rows. Both are empty for gated data, and the
+// toggle only shows when either has content.
+const scoreExplanation = computed(() => buildScoreExplanation(props.data, {
+ t,
+ formatDate: (day) => formatIsoDate(day, locale.value),
+}));
+const scoreTagRows = computed(() => listScoreTags(props.data.scoreTags));
+// Row state → icon; the question mark marks a suspected residential proxy.
+const TAG_STATE_ICONS = { yes: Check, no: X, suspected: CircleQuestionMark };
+const hasScoreDetails = computed(() =>
+ showAdvancedBlock.value && (scoreExplanation.value.length > 0 || scoreTagRows.value.length > 0)
+);
+// CJK sentences run together; others take a space.
+const sentenceGap = computed(() => (/^(zh|ja|ko)/.test(locale.value) ? '' : ' '));
+
+const isScoreDetailsOpen = ref(false);
+const toggleScoreDetails = () => {
+ if (!isScoreDetailsOpen.value) trackEvent('IPCheck', 'ScoreDetailsClick', 'Show Score Details');
+ isScoreDetailsOpen.value = !isScoreDetailsOpen.value;
+};
+// A different IP starts collapsed.
+watch(() => props.data.ip, () => { isScoreDetailsOpen.value = false; });
+
// Consumer opt-in + a Cloudflare key on the deployment + a country to query.
const canShowCountryTraffic = computed(() =>
props.countryTrafficMode !== 'off' && !props.collapsed
diff --git a/frontend/components/report/ReportPage.vue b/frontend/components/report/ReportPage.vue
index f41a640ce..610a951fa 100644
--- a/frontend/components/report/ReportPage.vue
+++ b/frontend/components/report/ReportPage.vue
@@ -145,7 +145,8 @@ const loadReport = async () => {
// The API serves { expiresAt, report }; expiresAt is best-effort.
const payload = await response.json();
const data = payload?.report;
- // Older viewers can't know how to render a newer schema.
+ // A viewer renders only its own schema version; older and newer
+ // links both land in the unsupported state.
if (data?.v !== REPORT_VERSION || !data.sections) {
errorKey.value = 'reportPage.Unsupported';
state.value = 'error';
diff --git a/frontend/components/report/sections/ReportIpinfo.vue b/frontend/components/report/sections/ReportIpinfo.vue
index 815449660..ccaf7eed2 100644
--- a/frontend/components/report/sections/ReportIpinfo.vue
+++ b/frontend/components/report/sections/ReportIpinfo.vue
@@ -9,7 +9,7 @@
{{ t('ipInfos.TimeZone') }} |
ASN |
{{ t('reportPage.Col.ISP') }} |
- {{ t('ipInfos.isProxy') }} |
+ {{ t('ipInfos.anonymity.label') }} |
{{ t('ipInfos.type') }} |
{{ t('ipInfos.advancedData.Nativeness') }} |
{{ t('ipInfos.qualityScore') }} |
@@ -27,13 +27,13 @@
{{ card.asn || '—' }} |
{{ card.isp || '—' }} |
-
-
-
- {{ t(PROXY_KEYS[card.isProxy]) }}
+ |
+
+
+ {{ t(ANONYMITY_I18N_KEYS[card.anonymity]) }}
-
- {{ [card.proxyProvider, card.proxyProtocol].filter(Boolean).join(' · ') }}
+
+ {{ [card.anonymityProvider, card.anonymityProtocol].filter(Boolean).join(' · ') }}
—
@@ -58,24 +58,19 @@
diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue
index 808586916..c26c88b69 100644
--- a/frontend/components/widgets/Preferences.vue
+++ b/frontend/components/widgets/Preferences.vue
@@ -177,7 +177,7 @@
{{ ipHistoryDaysDraft }}
- { if (v?.[0] != null) ipHistoryDaysDraft = v[0]; }"
@value-commit="(v) => prefIpHistoryDays(v?.[0])" />
@@ -316,7 +316,7 @@ const prefConnectivityCardTitleOpensSite = (value) => {
trackEvent('Nav', 'PrefereceClick', 'ConnectivityCardTitleOpensSite');
};
-// IP history recorder: on/off + retention days (1–90). The draft ref feeds the
+// IP history recorder: on/off + retention days (1–30). The draft ref feeds the
// slider's live readout; the preference is written on value-commit only.
const ipHistoryDaysDraft = ref(clampRetentionDays(userPreferences.value.ipHistoryDays));
watch(() => userPreferences.value.ipHistoryDays, (v) => {
diff --git a/frontend/composables/use-fit-text.js b/frontend/composables/use-fit-text.js
index b5cd27d20..97055b336 100644
--- a/frontend/composables/use-fit-text.js
+++ b/frontend/composables/use-fit-text.js
@@ -1,6 +1,7 @@
// Fit-to-width class picker: picks the largest tier whose rendered
// extent fits inside the element, recomputing on container resize and
-// text change.
+// text change. All instances share one ResizeObserver and one batched
+// measure pass, so a long list costs a handful of layouts, not one per row.
import { ref, watch, onBeforeUnmount } from 'vue';
@@ -11,53 +12,122 @@ import { ref, watch, onBeforeUnmount } from 'vue';
export const HERO_TIERS = ['text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
export const INLINE_TIERS = ['text-base', 'text-sm', 'text-xs'];
+// With maxLines > 1 the caller must set `line-clamp-N` on the element so
+// `clientHeight` caps at N lines while `scrollHeight` keeps the full
+// content height — that's what lets the vertical overflow check work.
+const fits = ({ el, maxLines }) => maxLines <= 1
+ ? el.scrollWidth <= el.clientWidth
+ : el.scrollHeight <= el.clientHeight;
+
+// Leave exactly one tier on the element. Also strips tiers left by an
+// interrupted run — Vue's :class reconciliation won't remove classes it
+// didn't write itself.
+const applyTier = ({ el, tiers }, index) => {
+ for (const t of tiers) if (t !== tiers[index]) el.classList.remove(t);
+ el.classList.add(tiers[index]);
+};
+
+// Fit a batch of jobs ({ el, tiers, maxLines, onFit }). Each pass reads
+// every unsettled element before writing any, then steps the misfits down
+// one tier — layouts are bounded by the tier count, not the job count.
+export const fitAll = (jobs) => {
+ let active = jobs.filter(({ el }) => el && el.isConnected !== false);
+ for (const job of active) {
+ job.index = 0;
+ applyTier(job, 0);
+ }
+ while (active.length) {
+ const fitted = active.map(fits);
+ const next = [];
+ active.forEach((job, i) => {
+ if (fitted[i] || job.index === job.tiers.length - 1) {
+ job.onFit(job.tiers[job.index]);
+ } else {
+ job.index += 1;
+ applyTier(job, job.index);
+ next.push(job);
+ }
+ });
+ active = next;
+ }
+};
+
+/* Shared scheduler: mounts and text changes queue into one microtask
+ (still before paint); resizes are fitted inside the observer callback. */
+const pending = new Set();
+const jobsByParent = new Map();
+let observer = null;
+
+const flush = () => {
+ const batch = [...pending];
+ pending.clear();
+ fitAll(batch);
+};
+
+const schedule = (job) => {
+ if (!pending.size) queueMicrotask(flush);
+ pending.add(job);
+};
+
+const getObserver = () => {
+ if (!observer && typeof ResizeObserver !== 'undefined') {
+ observer = new ResizeObserver((entries) => {
+ const batch = new Set();
+ for (const { target } of entries) {
+ for (const job of jobsByParent.get(target) || []) batch.add(job);
+ }
+ fitAll([...batch]);
+ });
+ }
+ return observer;
+};
+
+const register = (job) => {
+ const parent = job.el.parentElement;
+ job.parent = parent;
+ if (!jobsByParent.has(parent)) {
+ jobsByParent.set(parent, new Set());
+ getObserver()?.observe(parent);
+ }
+ jobsByParent.get(parent).add(job);
+ schedule(job);
+};
+
+const unregister = (job) => {
+ pending.delete(job);
+ const jobs = jobsByParent.get(job.parent);
+ if (!jobs) return;
+ jobs.delete(job);
+ if (!jobs.size) {
+ jobsByParent.delete(job.parent);
+ observer?.unobserve(job.parent);
+ }
+};
+
export function useFitText(elRef, textSource, tiers, options = {}) {
- // With maxLines > 1 the caller must set `line-clamp-N` on the
- // element so `clientHeight` caps at N lines while `scrollHeight`
- // keeps the full content height — that's what lets the vertical
- // overflow check work.
- const maxLines = options.maxLines ?? 1;
const cls = ref(tiers[0]);
- let ro = null;
-
- const fitsAtCurrentTier = (el) => maxLines <= 1
- ? el.scrollWidth <= el.clientWidth
- : el.scrollHeight <= el.clientHeight;
-
- const measure = () => {
- const el = elRef.value;
- if (!el) return;
- for (const t of tiers) el.classList.remove(t);
- let chosen = tiers[tiers.length - 1];
- for (const t of tiers) {
- el.classList.add(t);
- if (fitsAtCurrentTier(el)) { chosen = t; break; }
- el.classList.remove(t);
- }
- // Strip any tier left on the element by an interrupted run —
- // Vue's :class reconciliation won't remove classes it didn't
- // write itself.
- for (const t of tiers) if (t !== chosen) el.classList.remove(t);
- if (!el.classList.contains(chosen)) el.classList.add(chosen);
- cls.value = chosen;
- };
+ let job = null;
- const disconnect = () => { if (ro) { ro.disconnect(); ro = null; } };
+ const release = () => {
+ if (job) unregister(job);
+ job = null;
+ };
watch(elRef, (el, _prev, onCleanup) => {
- disconnect();
+ release();
if (!el || !el.parentElement) return;
- ro = new ResizeObserver(() => measure());
- ro.observe(el.parentElement);
- // RO's own initial callback is async; fire one sync pass so the
- // first paint is already at the right tier.
- measure();
- onCleanup(disconnect);
+ job = {
+ el, tiers,
+ maxLines: options.maxLines ?? 1,
+ onFit: (tier) => { cls.value = tier; },
+ };
+ register(job);
+ onCleanup(release);
}, { immediate: true, flush: 'post' });
- watch(textSource, () => measure(), { flush: 'post' });
+ watch(textSource, () => { if (job) schedule(job); }, { flush: 'post' });
- onBeforeUnmount(disconnect);
+ onBeforeUnmount(release);
return cls;
}
diff --git a/frontend/composables/use-ip-history.js b/frontend/composables/use-ip-history.js
index f7a571d50..381cfad6e 100644
--- a/frontend/composables/use-ip-history.js
+++ b/frontend/composables/use-ip-history.js
@@ -2,7 +2,7 @@
// localStorage history grouped by day, and exposes it reactively for the
// IPHistory panel. Local-only by design: the record never syncs to the user's
// account. Gated by user preferences: ipHistoryEnabled stops recording,
-// ipHistoryDays (1–90) sets retention. Pure logic lives in utils/ip-history.js.
+// ipHistoryDays (1–30) sets retention. Pure logic lives in utils/ip-history.js.
import { ref, computed, watch } from 'vue';
import { emitAppEvent } from '@/utils/app-events.js';
import {
diff --git a/frontend/composables/use-persona-collector.js b/frontend/composables/use-persona-collector.js
index 393c9c290..1a2dd619c 100644
--- a/frontend/composables/use-persona-collector.js
+++ b/frontend/composables/use-persona-collector.js
@@ -41,7 +41,6 @@ const normalizeIpinfo = (payload) => ({
asn: card.asn || undefined,
isp: card.isp || undefined,
ipType: card.ipTypeCode || undefined,
- isProxy: card.proxyCode || undefined,
version: isIPv6(card.ip) ? 6 : 4,
})),
});
diff --git a/frontend/composables/use-world-map-chart.js b/frontend/composables/use-world-map-chart.js
new file mode 100644
index 000000000..40b7b9a52
--- /dev/null
+++ b/frontend/composables/use-world-map-chart.js
@@ -0,0 +1,58 @@
+// Serialize lazy world-map renders. Hide skips new work but keeps a live
+// chart on its canvas so a closing animation can finish; destroy when the
+// canvas leaves or the scope is disposed.
+import { ref, watch, onScopeDispose, toValue } from 'vue';
+import { renderWorldMapChart } from '../utils/world-map-chart.js';
+
+export const useWorldMapChart = ({ canvas, visible, options, theme }, { render = renderWorldMapChart } = {}) => {
+ const ready = ref(false);
+ const failed = ref(false);
+ let chart = null;
+ let queue = Promise.resolve();
+ let generation = 0;
+ let disposed = false;
+
+ const destroy = () => {
+ chart?.destroy();
+ chart = null;
+ ready.value = false;
+ };
+
+ watch([canvas, visible, options, () => toValue(theme)], () => {
+ const current = ++generation;
+ if (!toValue(canvas)) {
+ failed.value = false;
+ destroy();
+ return;
+ }
+ if (!toValue(visible)) return;
+ failed.value = false;
+ queue = queue.then(async () => {
+ const isActive = () => !disposed && current === generation;
+ if (!isActive()) return;
+ try {
+ const next = await render({ ...toValue(options), canvas: toValue(canvas), chart, isActive });
+ if (!isActive()) {
+ if (next && next !== chart) next.destroy();
+ return;
+ }
+ chart = next;
+ ready.value = Boolean(chart);
+ failed.value = !chart;
+ } catch {
+ if (isActive()) {
+ destroy();
+ failed.value = true;
+ }
+ }
+ });
+ }, { immediate: true, flush: 'post' });
+
+ onScopeDispose(() => {
+ disposed = true;
+ generation += 1;
+ destroy();
+ });
+
+ return { ready, failed };
+};
diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json
index 7220c69df..0eec8c9fe 100644
--- a/frontend/data/changelog.json
+++ b/frontend/data/changelog.json
@@ -2136,5 +2136,33 @@
}
}
]
+ },
+ {
+ "version": "v7.7.0",
+ "date": "Beta",
+ "content": [
+ {
+ "type": "improve",
+ "change": {
+ "en": "IP History can now be viewed by IP or date",
+ "zh": "IP 历史记录可以按 IP/日期视图进行展示",
+ "zh-TW": "IP 歷史記錄可以按 IP/日期視圖進行展示",
+ "fr": "L'historique des IP peut être affiché par IP ou date",
+ "ru": "История IP может быть отображена по IP или дате",
+ "pt-BR": "O histórico de IP pode ser exibido por IP ou data"
+ }
+ },
+ {
+ "type": "fix",
+ "change": {
+ "en": "Fixed some issues",
+ "zh": "修复了一些问题",
+ "zh-TW": "修正了一些問題",
+ "fr": "Correction de divers problèmes",
+ "ru": "Исправлены некоторые проблемы",
+ "pt-BR": "Corrigidos alguns problemas"
+ }
+ }
+ ]
}
]
diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js
index e03e7ee1e..03b240f38 100644
--- a/frontend/data/default-preferences.js
+++ b/frontend/data/default-preferences.js
@@ -23,9 +23,9 @@ export const DEFAULT_PREFERENCES = Object.freeze({
popupConnectivityNotifications: false,
ipCardsToShow: 2,
ipGeoSource: 0,
- // Local IP-history recorder (see use-ip-history.js). Days: 1–90.
+ // Local IP-history recorder (see use-ip-history.js). Days: 1–30.
ipHistoryEnabled: true,
- ipHistoryDays: 90,
+ ipHistoryDays: 30,
lang: 'auto', // auto | zh | en | fr | ru
// Multi-list Connectivity model: { schemaVersion, lists }. null until
// first load — store.loadPreferences() runs it through sanitizeLists().
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index f21ad96e5..dc43879d1 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -12,8 +12,9 @@
"ViewUsage": "View usage",
"Usage": {
"Title": "Usage Stats",
- "Note": "Advanced features come with a free monthly quota, which resets automatically at the start of each month (UTC). IP advanced data counts unique IPs only — looking up an IP you've already queried this month never consumes quota.",
- "IpinfoFeature": "IP Advanced Data (unique IPs)",
+ "Note": "Advanced features come with a free monthly quota, which resets automatically at the start of each month (UTC). Repeat IP advanced data queries are served from cache and don't use your quota.",
+ "IpinfoFeature": "IP Advanced Data",
+ "PercentUsed": "{percent}% used",
"SponsorNote": "Sponsoring on GitHub raises your monthly quotas."
},
"MyAchievements": "My Achievements",
@@ -1183,7 +1184,7 @@
"NotFound": "Report not found or expired",
"NotFoundNote": "Shared reports auto-delete when they expire.",
"LoadFailed": "Failed to load the report, please retry later.",
- "Unsupported": "This report was created with a newer version and cannot be displayed.",
+ "Unsupported": "This report was created with a different version of the site and cannot be displayed.",
"GeneratedBy": "Diagnostic report generated at {origin} on {time}",
"Stale": "This report is more than 3 days old — the network may have changed since.",
"TestMine": "Test my own network",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "Unknown",
"NoMatch": "No records match the selected filters.",
"ClearAll": "Clear History",
- "ClearConfirm": "Click again to confirm"
+ "ClearConfirm": "Click again to confirm",
+ "ViewMode": "History view",
+ "ByIP": "By IP",
+ "ByDate": "By date",
+ "UniqueIPCounts": "Tag counts: unique IPs across all retained records.",
+ "DailyRecordCounts": "Tag counts: records across all retained dates (one per IP per day).",
+ "LastSeen": "Last seen: {date}",
+ "FirstSeen": "First seen: {date}",
+ "RecordDates": "All recorded dates",
+ "MapTitle": "IP countries / regions",
+ "MapUnavailable": "Map unavailable. You can still use the country filters.",
+ "MapSelect": "Click to filter this country / region",
+ "MapUnselect": "Click to remove this country / region filter",
+ "ViewMap": "View map"
},
"ipInfos": {
"Title": "IP Infos",
@@ -1231,11 +1245,22 @@
"IPv4Error": "Fetch Failed or No IPv4 Address",
"IPv6Error": "Fetch Failed or No IPv6 Address",
"type": "Type",
- "isProxy": "Proxy",
+ "anonymity": {
+ "label": "Anonymizer",
+ "tor": "Tor exit",
+ "relay": "iCloud Private Relay",
+ "residential": "Residential proxy",
+ "suspectedResidential": "Suspected residential proxy",
+ "proxy": "Proxy",
+ "proxySuspected": "Possible proxy",
+ "vpn": "VPN",
+ "vpnSuspected": "Possible VPN",
+ "none": "Not detected"
+ },
"qualityScore": "IP Quality",
"qualityScoreUnknown": "Score Unknown",
"advancedUnlockCta": "To avoid abuse, sign in to view the following information",
- "advancedQuotaCta": "Monthly quota for new IPs used up",
+ "advancedQuotaCta": "Monthly quota for IP advanced data used up",
"ASNInfo": {
"trafficPercentage": "Traffic Percentage: ",
"note": "Data for this AS : ",
@@ -1287,10 +1312,6 @@
"zoomReset": "Reset zoom"
},
"advancedData": {
- "proxyYes": "Flagged as proxy / VPN",
- "proxyMaybe": "Possible proxy / VPN",
- "proxyNo": "Not flagged",
- "proxyUnknown": "Unknown type",
"type": {
"Residential": "Residential",
"Wireless": "Wireless",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "Mismatched",
"nativenessTooltip": "Whether the ASN's registered country matches the IP's geolocation country"
},
+ "scoreDetails": {
+ "toggle": "Score details",
+ "explainTitle": "How the score was reached",
+ "explain": {
+ "unknownOperator": "an unidentified operator",
+ "summary": {
+ "lowRisk": "IP address {ip} is operated by {operator} and has a quality score of {score}/100, which we consider low risk.",
+ "mediumRisk": "IP address {ip} is operated by {operator} and has a quality score of {score}/100, which we consider moderate risk.",
+ "highRisk": "IP address {ip} is operated by {operator} and has a quality score of {score}/100, which we consider high risk."
+ },
+ "network": {
+ "datacenter": "It belongs to a hosting / data center network rather than a standard home or mobile connection.",
+ "mobile": "It is on a mobile network, where one address is often shared by many users.",
+ "isp": "It is on an ISP access network (home or business broadband), not a hosting provider."
+ },
+ "anonymity": {
+ "none": "No VPN, proxy, Tor or private relay use has been detected on it.",
+ "tor": "It is part of the Tor anonymity network.",
+ "relay": "It is an iCloud Private Relay exit, shared by many Apple users.",
+ "proxySingle": "A single source identifies it as a proxy.",
+ "proxyMulti": "Multiple independent sources identify it as a proxy.",
+ "residentialProxy": "It has been identified as a residential proxy exit.",
+ "residentialProxySuspected": "This IP was observed on {date} as a suspected residential (home) proxy exit.",
+ "residentialProxySuspectedUndated": "This IP has been observed as a suspected residential (home) proxy exit.",
+ "vpnSingle": "A single source identifies it as a VPN or anonymizing service.",
+ "vpnMulti": "Multiple independent sources identify it as a VPN or anonymizing service."
+ },
+ "anonymityLastSeen": "This was last observed on {date}.",
+ "reputation": {
+ "none": "No significant abuse reports or blocklist entries were found for it.",
+ "low": "There is minor abuse evidence against it (reports or blocklist entries).",
+ "lowDated": "There is minor abuse evidence against it; the latest abuse report is from {date}.",
+ "medium": "There is notable abuse evidence against it (reports or blocklist entries).",
+ "mediumDated": "There is notable abuse evidence against it; the latest abuse report is from {date}.",
+ "high": "There is strong abuse evidence against it (reports or blocklist entries).",
+ "highDated": "There is strong abuse evidence against it; the latest abuse report is from {date}."
+ },
+ "asn": {
+ "none": "Its network (ASN) shows no more abuse or VPN activity than is typical across the Internet.",
+ "low": "Its network (ASN) shows somewhat more abuse or VPN activity than is typical.",
+ "medium": "Its network (ASN) shows clearly elevated abuse or VPN activity.",
+ "high": "Its network (ASN) shows very high abuse or VPN activity."
+ }
+ },
+ "tagsTitle": "All attributes",
+ "tagState": {
+ "yes": "Yes",
+ "no": "No",
+ "suspected": "Suspected"
+ },
+ "tags": {
+ "isDatacenter": "Data center",
+ "isResidential": "Residential line",
+ "isMobile": "Mobile network",
+ "isSatellite": "Satellite",
+ "isProxy": "Proxy",
+ "isVPN": "VPN",
+ "isResidentialProxy": "Residential proxy",
+ "isRelay": "iCloud Private Relay",
+ "isTor": "Tor",
+ "isAbuser": "Abuse history",
+ "isCrawler": "Crawler",
+ "isNative": "ASN-Geo match"
+ }
+ },
"countryTraffic": {
"subtitle": "Typical weekly online activity in this country/region — view human-generated traffic only, or all traffic (including bot/automated traffic).",
"human": "Human Only",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index 1b2f800b5..e526116cf 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -12,8 +12,9 @@
"ViewUsage": "Voir l'utilisation",
"Usage": {
"Title": "Statistiques d'utilisation",
- "Note": "Les fonctionnalités avancées disposent d'un quota mensuel gratuit, réinitialisé automatiquement au début de chaque mois (UTC). Les données IP avancées ne comptent que les IP uniques — interroger à nouveau une IP déjà consultée ce mois-ci ne consomme pas de quota.",
- "IpinfoFeature": "Données IP avancées (IP uniques)",
+ "Note": "Les fonctionnalités avancées disposent d'un quota mensuel gratuit, réinitialisé automatiquement au début de chaque mois (UTC). Les requêtes répétées de données IP avancées sont servies depuis le cache et ne consomment pas de quota.",
+ "IpinfoFeature": "Données IP avancées",
+ "PercentUsed": "{percent} % utilisé",
"SponsorNote": "Sponsoriser le projet sur GitHub augmente vos quotas mensuels."
},
"MyAchievements": "Succès",
@@ -1183,7 +1184,7 @@
"NotFound": "Rapport introuvable ou expiré",
"NotFoundNote": "Les rapports partagés sont automatiquement supprimés à expiration.",
"LoadFailed": "Échec du chargement du rapport, veuillez réessayer plus tard.",
- "Unsupported": "Ce rapport a été créé avec une version plus récente et ne peut pas être affiché.",
+ "Unsupported": "Ce rapport a été créé avec une autre version du site et ne peut pas être affiché.",
"GeneratedBy": "Rapport de diagnostic généré sur {origin} le {time}",
"Stale": "Ce rapport date de plus de 3 jours — le réseau peut avoir changé depuis.",
"TestMine": "Tester mon propre réseau",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "Inconnu",
"NoMatch": "Aucun enregistrement ne correspond aux filtres sélectionnés.",
"ClearAll": "Effacer l'historique",
- "ClearConfirm": "Cliquez à nouveau pour confirmer"
+ "ClearConfirm": "Cliquez à nouveau pour confirmer",
+ "ViewMode": "Vue de l’historique",
+ "ByIP": "Par IP",
+ "ByDate": "Par date",
+ "UniqueIPCounts": "Compteurs des filtres : IP uniques dans l’ensemble des enregistrements conservés.",
+ "DailyRecordCounts": "Compteurs des filtres : enregistrements de toutes les dates conservées (un par IP et par jour).",
+ "LastSeen": "Dernière détection : {date}",
+ "FirstSeen": "Première détection : {date}",
+ "RecordDates": "Toutes les dates enregistrées",
+ "MapTitle": "Pays / régions des IP",
+ "MapUnavailable": "Carte indisponible. Les filtres par pays restent disponibles.",
+ "MapSelect": "Cliquer pour filtrer ce pays / cette région",
+ "MapUnselect": "Cliquer pour retirer le filtre de ce pays / cette région",
+ "ViewMap": "Voir la carte"
},
"ipInfos": {
"Title": "Infos IP",
@@ -1231,11 +1245,22 @@
"IPv4Error": "Échec de récupération ou pas d'adresse IPv4",
"IPv6Error": "Échec de récupération ou pas d'adresse IPv6",
"type": "Type",
- "isProxy": "Proxy",
+ "anonymity": {
+ "label": "Anonymisation",
+ "tor": "Nœud de sortie Tor",
+ "relay": "Relais privé iCloud",
+ "residential": "Proxy résidentiel",
+ "suspectedResidential": "Proxy résidentiel suspecté",
+ "proxy": "Proxy",
+ "proxySuspected": "Proxy probable",
+ "vpn": "VPN",
+ "vpnSuspected": "VPN probable",
+ "none": "Non détecté"
+ },
"qualityScore": "Qualité IP",
"qualityScoreUnknown": "Score inconnu",
"advancedUnlockCta": "Pour éviter l'abus, connectez-vous pour voir les informations suivantes",
- "advancedQuotaCta": "Quota mensuel de nouvelles IP épuisé",
+ "advancedQuotaCta": "Quota mensuel de données IP avancées épuisé",
"ASNInfo": {
"trafficPercentage": "Pourcentage de trafic : ",
"note": "Données associées à cet AS : ",
@@ -1287,10 +1312,6 @@
"zoomReset": "Réinitialiser le zoom"
},
"advancedData": {
- "proxyYes": "Signalé comme proxy / VPN",
- "proxyMaybe": "Probablement proxy / VPN",
- "proxyNo": "Non signalé",
- "proxyUnknown": "Type inconnu",
"type": {
"Residential": "Résidentiel",
"Wireless": "Sans fil",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "Décalé",
"nativenessTooltip": "Indique si le pays d'enregistrement de l'ASN correspond au pays de géolocalisation de l'IP"
},
+ "scoreDetails": {
+ "toggle": "Détails du score",
+ "explainTitle": "Comment le score est obtenu",
+ "explain": {
+ "unknownOperator": "un opérateur non identifié",
+ "summary": {
+ "lowRisk": "L'adresse IP {ip} est exploitée par {operator} et obtient un score de qualité de {score}/100, ce que nous considérons comme un risque faible.",
+ "mediumRisk": "L'adresse IP {ip} est exploitée par {operator} et obtient un score de qualité de {score}/100, ce que nous considérons comme un risque modéré.",
+ "highRisk": "L'adresse IP {ip} est exploitée par {operator} et obtient un score de qualité de {score}/100, ce que nous considérons comme un risque élevé."
+ },
+ "network": {
+ "datacenter": "Elle appartient à un réseau d'hébergement / centre de données plutôt qu'à une connexion résidentielle ou mobile classique.",
+ "mobile": "Elle se trouve sur un réseau mobile, où une même adresse est souvent partagée par de nombreux utilisateurs.",
+ "isp": "Elle se trouve sur un réseau d'accès de FAI (haut débit résidentiel ou professionnel), et non chez un hébergeur."
+ },
+ "anonymity": {
+ "none": "Aucune utilisation de VPN, de proxy, de Tor ou de relais privé n'a été détectée.",
+ "tor": "Elle fait partie du réseau d'anonymisation Tor.",
+ "relay": "C'est une sortie du Relais privé iCloud, partagée par de nombreux utilisateurs Apple.",
+ "proxySingle": "Une seule source l'identifie comme un proxy.",
+ "proxyMulti": "Plusieurs sources indépendantes l'identifient comme un proxy.",
+ "residentialProxy": "Elle a été identifiée comme sortie de proxy résidentiel.",
+ "residentialProxySuspected": "Cette IP a été observée le {date} comme sortie suspectée d'un proxy résidentiel (domestique).",
+ "residentialProxySuspectedUndated": "Cette IP a été observée comme sortie suspectée d'un proxy résidentiel (domestique).",
+ "vpnSingle": "Une seule source l'identifie comme un VPN ou un service d'anonymisation.",
+ "vpnMulti": "Plusieurs sources indépendantes l'identifient comme un VPN ou un service d'anonymisation."
+ },
+ "anonymityLastSeen": "Dernière observation le {date}.",
+ "reputation": {
+ "none": "Aucun signalement d'abus ni aucune inscription significative sur liste de blocage n'a été trouvé.",
+ "low": "Il existe quelques indices d'abus (signalements ou listes de blocage).",
+ "lowDated": "Il existe quelques indices d'abus ; le dernier signalement date du {date}.",
+ "medium": "Il existe des indices d'abus notables (signalements ou listes de blocage).",
+ "mediumDated": "Il existe des indices d'abus notables ; le dernier signalement date du {date}.",
+ "high": "Il existe de forts indices d'abus (signalements ou listes de blocage).",
+ "highDated": "Il existe de forts indices d'abus ; le dernier signalement date du {date}."
+ },
+ "asn": {
+ "none": "Son réseau (ASN) ne présente pas plus d'activité abusive ou VPN que la moyenne d'Internet.",
+ "low": "Son réseau (ASN) présente un peu plus d'activité abusive ou VPN que la moyenne.",
+ "medium": "Son réseau (ASN) présente une activité abusive ou VPN nettement élevée.",
+ "high": "Son réseau (ASN) présente une activité abusive ou VPN très élevée."
+ }
+ },
+ "tagsTitle": "Tous les attributs",
+ "tagState": {
+ "yes": "Oui",
+ "no": "Non",
+ "suspected": "Suspecté"
+ },
+ "tags": {
+ "isDatacenter": "Centre de données",
+ "isResidential": "Ligne résidentielle",
+ "isMobile": "Réseau mobile",
+ "isSatellite": "Satellite",
+ "isProxy": "Proxy",
+ "isVPN": "VPN",
+ "isResidentialProxy": "Proxy résidentiel",
+ "isRelay": "Relais privé iCloud",
+ "isTor": "Tor",
+ "isAbuser": "Historique d'abus",
+ "isCrawler": "Robot d'indexation",
+ "isNative": "Correspondance ASN-Geo"
+ }
+ },
"countryTraffic": {
"subtitle": "Répartition hebdomadaire de l'activité en ligne de ce pays/région — affichez uniquement le trafic généré par des humains, ou tout le trafic (bots et trafic automatisé inclus).",
"human": "Humains uniquement",
diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json
index 4242ae377..16a5a1545 100644
--- a/frontend/locales/pt-BR.json
+++ b/frontend/locales/pt-BR.json
@@ -12,8 +12,9 @@
"ViewUsage": "Ver uso",
"Usage": {
"Title": "Estatísticas de uso",
- "Note": "Recursos avançados incluem uma cota mensal gratuita, redefinida automaticamente no início de cada mês (UTC). Dados avançados de IP contam apenas IPs únicos — consultar um IP que você já pesquisou neste mês nunca consome cota.",
- "IpinfoFeature": "Dados avançados de IP (IPs únicos)",
+ "Note": "Recursos avançados incluem uma cota mensal gratuita, redefinida automaticamente no início de cada mês (UTC). Consultas repetidas de dados avançados de IP são atendidas pelo cache e não consomem cota.",
+ "IpinfoFeature": "Dados avançados de IP",
+ "PercentUsed": "{percent}% usado",
"SponsorNote": "Patrocinar no GitHub aumenta suas cotas mensais."
},
"MyAchievements": "Minhas conquistas",
@@ -1183,7 +1184,7 @@
"NotFound": "Relatório não encontrado ou expirado",
"NotFoundNote": "Relatórios compartilhados são excluídos automaticamente quando expiram.",
"LoadFailed": "Falha ao carregar o relatório. Tente novamente mais tarde.",
- "Unsupported": "Este relatório foi criado com uma versão mais recente e não pode ser exibido.",
+ "Unsupported": "Este relatório foi criado com outra versão do site e não pode ser exibido.",
"GeneratedBy": "Relatório diagnóstico gerado em {origin} em {time}",
"Stale": "Este relatório tem mais de 3 dias — a rede pode ter mudado desde então.",
"TestMine": "Testar minha própria rede",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "Desconhecido",
"NoMatch": "Nenhum registro corresponde aos filtros selecionados.",
"ClearAll": "Limpar histórico",
- "ClearConfirm": "Clique novamente para confirmar"
+ "ClearConfirm": "Clique novamente para confirmar",
+ "ViewMode": "Visualização do histórico",
+ "ByIP": "Por IP",
+ "ByDate": "Por data",
+ "UniqueIPCounts": "Contagens dos filtros: IPs únicos em todos os registros mantidos.",
+ "DailyRecordCounts": "Contagens dos filtros: registros de todas as datas mantidas (um por IP por dia).",
+ "LastSeen": "Última detecção: {date}",
+ "FirstSeen": "Primeira detecção: {date}",
+ "RecordDates": "Todas as datas registradas",
+ "MapTitle": "Países / regiões dos IPs",
+ "MapUnavailable": "Mapa indisponível. Os filtros por país continuam disponíveis.",
+ "MapSelect": "Clique para filtrar este país / região",
+ "MapUnselect": "Clique para remover o filtro deste país / região",
+ "ViewMap": "Ver mapa"
},
"ipInfos": {
"Title": "Informações de IP",
@@ -1231,11 +1245,22 @@
"IPv4Error": "Busca falhou ou não há IPv4",
"IPv6Error": "Busca falhou ou não há IPv6",
"type": "Tipo",
- "isProxy": "Proxy",
+ "anonymity": {
+ "label": "Anonimizador",
+ "tor": "Nó de saída Tor",
+ "relay": "Retransmissão Privada do iCloud",
+ "residential": "Proxy residencial",
+ "suspectedResidential": "Suspeita de proxy residencial",
+ "proxy": "Proxy",
+ "proxySuspected": "Possível proxy",
+ "vpn": "VPN",
+ "vpnSuspected": "Possível VPN",
+ "none": "Não detectado"
+ },
"qualityScore": "Qualidade do IP",
"qualityScoreUnknown": "Pontuação desconhecida",
"advancedUnlockCta": "Para evitar abuso, faça login para ver as informações a seguir",
- "advancedQuotaCta": "Cota mensal para novos IPs esgotada",
+ "advancedQuotaCta": "Cota mensal de dados avançados de IP esgotada",
"ASNInfo": {
"trafficPercentage": "Porcentagem de tráfego: ",
"note": "Dados deste AS: ",
@@ -1287,10 +1312,6 @@
"zoomReset": "Redefinir zoom"
},
"advancedData": {
- "proxyYes": "Sinalizado como proxy / VPN",
- "proxyMaybe": "Possível proxy / VPN",
- "proxyNo": "Não sinalizado",
- "proxyUnknown": "Tipo desconhecido",
"type": {
"Residential": "Residencial",
"Wireless": "Sem fio",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "Divergente",
"nativenessTooltip": "Se o país registrado do ASN corresponde ao país de geolocalização do IP"
},
+ "scoreDetails": {
+ "toggle": "Detalhes da pontuação",
+ "explainTitle": "Como a pontuação foi obtida",
+ "explain": {
+ "unknownOperator": "uma operadora não identificada",
+ "summary": {
+ "lowRisk": "O endereço IP {ip} é operado por {operator} e tem pontuação de qualidade {score}/100, o que consideramos risco baixo.",
+ "mediumRisk": "O endereço IP {ip} é operado por {operator} e tem pontuação de qualidade {score}/100, o que consideramos risco moderado.",
+ "highRisk": "O endereço IP {ip} é operado por {operator} e tem pontuação de qualidade {score}/100, o que consideramos risco alto."
+ },
+ "network": {
+ "datacenter": "Ele pertence a uma rede de hospedagem / data center, e não a uma conexão residencial ou móvel comum.",
+ "mobile": "Ele está em uma rede móvel, onde um mesmo endereço costuma ser compartilhado por muitos usuários.",
+ "isp": "Ele está em uma rede de acesso de provedor (banda larga residencial ou empresarial), e não em um provedor de hospedagem."
+ },
+ "anonymity": {
+ "none": "Nenhum uso de VPN, proxy, Tor ou retransmissão privada foi detectado.",
+ "tor": "Ele faz parte da rede de anonimato Tor.",
+ "relay": "É uma saída da Retransmissão Privada do iCloud, compartilhada por muitos usuários da Apple.",
+ "proxySingle": "Uma única fonte o identifica como proxy.",
+ "proxyMulti": "Várias fontes independentes o identificam como proxy.",
+ "residentialProxy": "Ele foi identificado como saída de proxy residencial.",
+ "residentialProxySuspected": "Este IP foi observado em {date} como suposta saída de proxy residencial (doméstico).",
+ "residentialProxySuspectedUndated": "Este IP foi observado como suposta saída de proxy residencial (doméstico).",
+ "vpnSingle": "Uma única fonte o identifica como VPN ou serviço de anonimização.",
+ "vpnMulti": "Várias fontes independentes o identificam como VPN ou serviço de anonimização."
+ },
+ "anonymityLastSeen": "Última observação em {date}.",
+ "reputation": {
+ "none": "Nenhuma denúncia de abuso ou entrada relevante em listas de bloqueio foi encontrada.",
+ "low": "Há poucos indícios de abuso (denúncias ou listas de bloqueio).",
+ "lowDated": "Há poucos indícios de abuso; a denúncia mais recente é de {date}.",
+ "medium": "Há indícios notáveis de abuso (denúncias ou listas de bloqueio).",
+ "mediumDated": "Há indícios notáveis de abuso; a denúncia mais recente é de {date}.",
+ "high": "Há fortes indícios de abuso (denúncias ou listas de bloqueio).",
+ "highDated": "Há fortes indícios de abuso; a denúncia mais recente é de {date}."
+ },
+ "asn": {
+ "none": "Sua rede (ASN) não mostra mais atividade de abuso ou VPN do que o típico na Internet.",
+ "low": "Sua rede (ASN) mostra um pouco mais de atividade de abuso ou VPN do que o típico.",
+ "medium": "Sua rede (ASN) mostra atividade de abuso ou VPN claramente elevada.",
+ "high": "Sua rede (ASN) mostra atividade de abuso ou VPN muito alta."
+ }
+ },
+ "tagsTitle": "Todos os atributos",
+ "tagState": {
+ "yes": "Sim",
+ "no": "Não",
+ "suspected": "Suspeito"
+ },
+ "tags": {
+ "isDatacenter": "Data center",
+ "isResidential": "Linha residencial",
+ "isMobile": "Rede móvel",
+ "isSatellite": "Satélite",
+ "isProxy": "Proxy",
+ "isVPN": "VPN",
+ "isResidentialProxy": "Proxy residencial",
+ "isRelay": "Retransmissão Privada do iCloud",
+ "isTor": "Tor",
+ "isAbuser": "Histórico de abuso",
+ "isCrawler": "Rastreador",
+ "isNative": "Alinhamento ASN-Geo"
+ }
+ },
"countryTraffic": {
"subtitle": "Distribuição semanal da atividade online deste país/região — veja apenas o tráfego gerado por humanos ou todo o tráfego (incluindo bots/tráfego automatizado).",
"human": "Somente humanos",
diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json
index a81e0a9ae..571d0ec07 100644
--- a/frontend/locales/ru.json
+++ b/frontend/locales/ru.json
@@ -12,8 +12,9 @@
"ViewUsage": "Посмотреть использование",
"Usage": {
"Title": "Статистика использования",
- "Note": "У расширенных функций есть бесплатная месячная квота, которая автоматически обновляется в начале каждого месяца (UTC). Для расширенных данных IP учитываются только уникальные IP — повторный запрос IP, уже проверенного в этом месяце, не расходует квоту.",
- "IpinfoFeature": "Расширенные данные IP (уникальные IP)",
+ "Note": "У расширенных функций есть бесплатная месячная квота, которая автоматически обновляется в начале каждого месяца (UTC). Повторные запросы расширенных данных IP обслуживаются из кэша и не расходуют квоту.",
+ "IpinfoFeature": "Расширенные данные IP",
+ "PercentUsed": "Использовано {percent}%",
"SponsorNote": "Спонсорство проекта на GitHub увеличивает месячные квоты."
},
"MyAchievements": "Достижения",
@@ -1183,7 +1184,7 @@
"NotFound": "Отчёт не найден или срок его действия истёк",
"NotFoundNote": "Отчёты, которыми поделились, автоматически удаляются по истечении срока.",
"LoadFailed": "Не удалось загрузить отчёт. Повторите попытку позже.",
- "Unsupported": "Этот отчёт создан в более новой версии и не может быть показан.",
+ "Unsupported": "Этот отчёт создан в другой версии сайта и не может быть показан.",
"GeneratedBy": "Диагностический отчёт создан на {origin}, {time}",
"Stale": "Этому отчёту больше 3 дней — с тех пор сеть могла измениться.",
"TestMine": "Проверить мою сеть",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "Неизвестно",
"NoMatch": "Нет записей, соответствующих выбранным фильтрам.",
"ClearAll": "Очистить историю",
- "ClearConfirm": "Нажмите ещё раз для подтверждения"
+ "ClearConfirm": "Нажмите ещё раз для подтверждения",
+ "ViewMode": "Вид истории",
+ "ByIP": "По IP",
+ "ByDate": "По дате",
+ "UniqueIPCounts": "Числа в фильтрах: уникальные IP среди всех сохранённых записей.",
+ "DailyRecordCounts": "Числа в фильтрах: записи за все сохранённые даты (одна на IP в день).",
+ "LastSeen": "Последнее обнаружение: {date}",
+ "FirstSeen": "Первое обнаружение: {date}",
+ "RecordDates": "Все даты записей",
+ "MapTitle": "Страны / регионы IP",
+ "MapUnavailable": "Карта недоступна. Фильтры по странам по-прежнему доступны.",
+ "MapSelect": "Нажмите, чтобы выбрать страну / регион",
+ "MapUnselect": "Нажмите, чтобы снять фильтр по стране / региону",
+ "ViewMap": "Посмотреть карту"
},
"ipInfos": {
"Title": "Сведения об IP",
@@ -1231,11 +1245,22 @@
"IPv4Error": "Не удалось получить IPv4 или адрес отсутствует",
"IPv6Error": "Не удалось получить IPv6 или адрес отсутствует",
"type": "Тип",
- "isProxy": "Прокси",
+ "anonymity": {
+ "label": "Анонимайзер",
+ "tor": "Выходной узел Tor",
+ "relay": "Частный узел iCloud",
+ "residential": "Резидентный прокси",
+ "suspectedResidential": "Возможный резидентный прокси",
+ "proxy": "Прокси",
+ "proxySuspected": "Возможный прокси",
+ "vpn": "VPN",
+ "vpnSuspected": "Возможный VPN",
+ "none": "Не обнаружен"
+ },
"qualityScore": "Качество IP",
"qualityScoreUnknown": "Оценка неизвестна",
"advancedUnlockCta": "Чтобы предотвратить злоупотребления, войдите для просмотра следующих сведений",
- "advancedQuotaCta": "Месячная квота на новые IP исчерпана",
+ "advancedQuotaCta": "Месячная квота на расширенные данные IP исчерпана",
"ASNInfo": {
"trafficPercentage": "Доля трафика: ",
"note": "Данные для этой AS: ",
@@ -1287,10 +1312,6 @@
"zoomReset": "Сбросить масштаб"
},
"advancedData": {
- "proxyYes": "Определён как прокси / VPN",
- "proxyMaybe": "Возможный прокси / VPN",
- "proxyNo": "Не отмечен",
- "proxyUnknown": "Неизвестный тип",
"type": {
"Residential": "Домашний",
"Wireless": "Беспроводной",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "Не совпадает",
"nativenessTooltip": "Совпадает ли страна регистрации ASN со страной геолокации IP"
},
+ "scoreDetails": {
+ "toggle": "Подробности оценки",
+ "explainTitle": "Как получена оценка",
+ "explain": {
+ "unknownOperator": "не установлен",
+ "summary": {
+ "lowRisk": "IP-адрес {ip} (оператор: {operator}) имеет оценку качества {score}/100, что мы считаем низким риском.",
+ "mediumRisk": "IP-адрес {ip} (оператор: {operator}) имеет оценку качества {score}/100, что мы считаем умеренным риском.",
+ "highRisk": "IP-адрес {ip} (оператор: {operator}) имеет оценку качества {score}/100, что мы считаем высоким риском."
+ },
+ "network": {
+ "datacenter": "Он относится к сети хостинга / дата-центра, а не к обычному домашнему или мобильному подключению.",
+ "mobile": "Он находится в мобильной сети, где один адрес часто используют многие абоненты.",
+ "isp": "Он находится в абонентской сети провайдера (домашний или корпоративный интернет), а не у хостинг-провайдера."
+ },
+ "anonymity": {
+ "none": "Использование VPN, прокси, Tor или частного узла не обнаружено.",
+ "tor": "Он входит в анонимную сеть Tor.",
+ "relay": "Это выходной узел Частного узла iCloud, общий для многих пользователей Apple.",
+ "proxySingle": "Один источник определяет его как прокси.",
+ "proxyMulti": "Несколько независимых источников определяют его как прокси.",
+ "residentialProxy": "Он определён как выходной узел резидентного прокси.",
+ "residentialProxySuspected": "{date} этот IP был замечен как предполагаемый выходной узел резидентного (домашнего) прокси.",
+ "residentialProxySuspectedUndated": "Этот IP был замечен как предполагаемый выходной узел резидентного (домашнего) прокси.",
+ "vpnSingle": "Один источник определяет его как VPN или сервис анонимизации.",
+ "vpnMulti": "Несколько независимых источников определяют его как VPN или сервис анонимизации."
+ },
+ "anonymityLastSeen": "{date} — дата последнего наблюдения.",
+ "reputation": {
+ "none": "Существенных жалоб на злоупотребления или записей в чёрных списках не найдено.",
+ "low": "Есть незначительные признаки злоупотреблений (жалобы или чёрные списки).",
+ "lowDated": "Есть незначительные признаки злоупотреблений; {date} — дата последней жалобы.",
+ "medium": "Есть заметные признаки злоупотреблений (жалобы или чёрные списки).",
+ "mediumDated": "Есть заметные признаки злоупотреблений; {date} — дата последней жалобы.",
+ "high": "Есть серьёзные признаки злоупотреблений (жалобы или чёрные списки).",
+ "highDated": "Есть серьёзные признаки злоупотреблений; {date} — дата последней жалобы."
+ },
+ "asn": {
+ "none": "В его сети (ASN) злоупотреблений и VPN не больше, чем в среднем по интернету.",
+ "low": "В его сети (ASN) злоупотреблений или VPN немного больше среднего.",
+ "medium": "В его сети (ASN) заметно повышена доля злоупотреблений или VPN.",
+ "high": "В его сети (ASN) очень высокая доля злоупотреблений или VPN."
+ }
+ },
+ "tagsTitle": "Все атрибуты",
+ "tagState": {
+ "yes": "Да",
+ "no": "Нет",
+ "suspected": "Предположительно"
+ },
+ "tags": {
+ "isDatacenter": "Дата-центр",
+ "isResidential": "Домашняя линия",
+ "isMobile": "Мобильная сеть",
+ "isSatellite": "Спутниковая связь",
+ "isProxy": "Прокси",
+ "isVPN": "VPN",
+ "isResidentialProxy": "Резидентный прокси",
+ "isRelay": "Частный узел iCloud",
+ "isTor": "Tor",
+ "isAbuser": "История злоупотреблений",
+ "isCrawler": "Поисковый робот",
+ "isNative": "Совпадение ASN и геолокации"
+ }
+ },
"countryTraffic": {
"subtitle": "Недельное распределение онлайн-активности в этой стране/регионе — можно смотреть только трафик, создаваемый людьми, или весь трафик (включая ботов и автоматизированный трафик).",
"human": "Только люди",
diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json
index 98aca6d43..6296a3547 100644
--- a/frontend/locales/zh-TW.json
+++ b/frontend/locales/zh-TW.json
@@ -12,8 +12,9 @@
"ViewUsage": "檢視用量",
"Usage": {
"Title": "用量統計",
- "Note": "進階功能設有每月免費額度,每月(UTC)自動重置。IP 進階資料僅按獨立 IP 計數——本月已查詢過的 IP 再次查詢不消耗額度。",
- "IpinfoFeature": "IP 進階資料(按獨立 IP 計)",
+ "Note": "進階功能設有每月免費額度,每月(UTC)自動重置。IP 進階資料的重複查詢會命中快取,不消耗額度。",
+ "IpinfoFeature": "IP 進階資料",
+ "PercentUsed": "已用 {percent}%",
"SponsorNote": "在 GitHub 贊助本專案可獲得更高額度。"
},
"MyAchievements": "我的成就",
@@ -1183,7 +1184,7 @@
"NotFound": "報告不存在或已過期",
"NotFoundNote": "分享報告到期後會自動刪除。",
"LoadFailed": "報告載入失敗,請稍後重試。",
- "Unsupported": "此報告由更新版本產生,無法顯示。",
+ "Unsupported": "此報告由其他版本的網站產生,無法顯示。",
"GeneratedBy": "由 {origin} 於 {time} 產生的診斷報告",
"Stale": "此報告已超過 3 天,網路狀況可能已發生變化。",
"TestMine": "測一測我自己的網路",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "未知",
"NoMatch": "沒有符合篩選條件的記錄。",
"ClearAll": "清空記錄",
- "ClearConfirm": "再次點選確認清空"
+ "ClearConfirm": "再次點選確認清空",
+ "ViewMode": "歷史記錄檢視方式",
+ "ByIP": "依 IP",
+ "ByDate": "依日期",
+ "UniqueIPCounts": "標籤數量:所有保留記錄中的不重複 IP 數。",
+ "DailyRecordCounts": "標籤數量:所有保留日期的記錄數(同一 IP 每天計一筆)。",
+ "LastSeen": "最近:{date}",
+ "FirstSeen": "首次:{date}",
+ "RecordDates": "所有記錄日期",
+ "MapTitle": "IP 所屬國家 / 地區",
+ "MapUnavailable": "地圖暫時無法使用,仍可使用國家篩選。",
+ "MapSelect": "點擊篩選此國家 / 地區",
+ "MapUnselect": "點擊取消此國家 / 地區篩選",
+ "ViewMap": "查看地圖"
},
"ipInfos": {
"Title": "IP 資訊",
@@ -1231,11 +1245,22 @@
"IPv4Error": "取得失敗或不存在 IPv4 位址",
"IPv6Error": "取得失敗或不存在 IPv6 位址",
"type": "型別",
- "isProxy": "代理",
+ "anonymity": {
+ "label": "匿名網路",
+ "tor": "Tor 出口",
+ "relay": "iCloud 私密轉送",
+ "residential": "住宅代理",
+ "suspectedResidential": "疑似住宅代理",
+ "proxy": "代理",
+ "proxySuspected": "疑似代理",
+ "vpn": "VPN",
+ "vpnSuspected": "疑似 VPN",
+ "none": "未偵測到"
+ },
"qualityScore": "IP 品質評分",
"qualityScoreUnknown": "未知分數",
"advancedUnlockCta": "為避免濫用,登入後可檢視以下資訊",
- "advancedQuotaCta": "本月新 IP 查詢額度已用完",
+ "advancedQuotaCta": "本月 IP 進階資料額度已用完",
"ASNInfo": {
"trafficPercentage": "流量比例:",
"note": "此 AS 的相關資料:",
@@ -1287,10 +1312,6 @@
"zoomReset": "重置縮放"
},
"advancedData": {
- "proxyYes": "已識別為代理 / VPN",
- "proxyMaybe": "疑似代理 / VPN",
- "proxyNo": "未識別為代理",
- "proxyUnknown": "未知型別",
"type": {
"Residential": "住宅線路",
"Wireless": "無線網路",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "不一致",
"nativenessTooltip": "判斷 ASN 的註冊國家是否與 IP 的歸屬國家一致"
},
+ "scoreDetails": {
+ "toggle": "評分詳情",
+ "explainTitle": "評分依據",
+ "explain": {
+ "unknownOperator": "未知營運方",
+ "summary": {
+ "lowRisk": "IP 位址 {ip} 由 {operator} 營運,品質評分為 {score}/100,我們認為風險較低。",
+ "mediumRisk": "IP 位址 {ip} 由 {operator} 營運,品質評分為 {score}/100,我們認為存在一定風險。",
+ "highRisk": "IP 位址 {ip} 由 {operator} 營運,品質評分為 {score}/100,我們認為風險較高。"
+ },
+ "network": {
+ "datacenter": "它屬於託管 / 資料中心網路,而不是一般的家用或行動網路連線。",
+ "mobile": "它位於行動網路,這類網路中一個 IP 往往由許多使用者共用。",
+ "isp": "它位於電信業者的接取網路(家用或企業寬頻),而不是託管服務商。"
+ },
+ "anonymity": {
+ "none": "未偵測到它被用於 VPN、代理、Tor 或隱私轉送。",
+ "tor": "它屬於 Tor 匿名網路。",
+ "relay": "它是 iCloud 私密轉送的出口,由大量 Apple 使用者共用。",
+ "proxySingle": "單一來源將它識別為代理。",
+ "proxyMulti": "多個獨立來源將它識別為代理。",
+ "residentialProxy": "它被識別為住宅代理的出口。",
+ "residentialProxySuspected": "該 IP 曾在 {date} 被觀察到疑似作為住宅代理(家庭代理)的出口。",
+ "residentialProxySuspectedUndated": "該 IP 曾被觀察到疑似作為住宅代理(家庭代理)的出口。",
+ "vpnSingle": "單一來源將它識別為 VPN 或匿名服務。",
+ "vpnMulti": "多個獨立來源將它識別為 VPN 或匿名服務。"
+ },
+ "anonymityLastSeen": "最近一次觀測到是在 {date}。",
+ "reputation": {
+ "none": "未發現明顯的濫用檢舉或黑名單紀錄。",
+ "low": "它有少量濫用證據(檢舉或黑名單紀錄)。",
+ "lowDated": "它有少量濫用證據,最近一次濫用檢舉在 {date}。",
+ "medium": "它有較明顯的濫用證據(檢舉或黑名單紀錄)。",
+ "mediumDated": "它有較明顯的濫用證據,最近一次濫用檢舉在 {date}。",
+ "high": "它有嚴重的濫用證據(檢舉或黑名單紀錄)。",
+ "highDated": "它有嚴重的濫用證據,最近一次濫用檢舉在 {date}。"
+ },
+ "asn": {
+ "none": "它所在網路(ASN)的濫用與 VPN 活動不高於網際網路的一般水準。",
+ "low": "它所在網路(ASN)的濫用或 VPN 活動略高於一般水準。",
+ "medium": "它所在網路(ASN)的濫用或 VPN 活動明顯高於一般水準。",
+ "high": "它所在網路(ASN)的濫用或 VPN 活動遠高於一般水準。"
+ }
+ },
+ "tagsTitle": "全部屬性",
+ "tagState": {
+ "yes": "是",
+ "no": "否",
+ "suspected": "疑似"
+ },
+ "tags": {
+ "isDatacenter": "資料中心",
+ "isResidential": "住宅線路",
+ "isMobile": "行動網路",
+ "isSatellite": "衛星網路",
+ "isProxy": "代理",
+ "isVPN": "VPN",
+ "isResidentialProxy": "住宅代理",
+ "isRelay": "iCloud 私密轉送",
+ "isTor": "Tor",
+ "isAbuser": "濫用紀錄",
+ "isCrawler": "爬蟲",
+ "isNative": "ASN 與地理位置一致性"
+ }
+ },
"countryTraffic": {
"subtitle": "該國家/地區的每週網路活躍分布——你可以選擇僅看人類所產生的流量或全部流量(包含機器人/自動化流量)。",
"human": "僅人類流量",
diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json
index 97a71c65a..3732ea67e 100644
--- a/frontend/locales/zh.json
+++ b/frontend/locales/zh.json
@@ -12,8 +12,9 @@
"ViewUsage": "查看用量",
"Usage": {
"Title": "用量统计",
- "Note": "高级功能设有每月免费额度,每月(UTC)自动重置。IP 高级数据仅按独立 IP 计数——本月已查询过的 IP 再次查询不消耗额度。",
- "IpinfoFeature": "IP 高级数据(按独立 IP 计)",
+ "Note": "高级功能设有每月免费额度,每月(UTC)自动重置。IP 高级数据的重复查询会命中缓存,不消耗额度。",
+ "IpinfoFeature": "IP 高级数据",
+ "PercentUsed": "已用 {percent}%",
"SponsorNote": "在 GitHub 赞助本项目可获得更高额度。"
},
"MyAchievements": "我的成就",
@@ -1183,7 +1184,7 @@
"NotFound": "报告不存在或已过期",
"NotFoundNote": "分享报告到期后会自动删除。",
"LoadFailed": "报告加载失败,请稍后重试。",
- "Unsupported": "此报告由更新版本生成,无法显示。",
+ "Unsupported": "此报告由其他版本的网站生成,无法显示。",
"GeneratedBy": "由 {origin} 于 {time} 生成的诊断报告",
"Stale": "此报告已超过 3 天,网络状况可能已发生变化。",
"TestMine": "测一测我自己的网络",
@@ -1215,7 +1216,20 @@
"UnknownCountry": "未知",
"NoMatch": "没有符合筛选条件的记录。",
"ClearAll": "清空记录",
- "ClearConfirm": "再次点击确认清空"
+ "ClearConfirm": "再次点击确认清空",
+ "ViewMode": "历史记录视图",
+ "ByIP": "按 IP",
+ "ByDate": "按日期",
+ "UniqueIPCounts": "标签数量:全部保留记录中的独立 IP 数。",
+ "DailyRecordCounts": "标签数量:全部保留日期的记录数(同一 IP 每天计一条)。",
+ "LastSeen": "最近:{date}",
+ "FirstSeen": "首次:{date}",
+ "RecordDates": "全部记录日期",
+ "MapTitle": "IP 所属国家 / 地区",
+ "MapUnavailable": "地图暂不可用,仍可使用国家筛选。",
+ "MapSelect": "点击筛选此国家 / 地区",
+ "MapUnselect": "点击取消此国家 / 地区筛选",
+ "ViewMap": "查看地图"
},
"ipInfos": {
"Title": "IP 信息",
@@ -1231,11 +1245,22 @@
"IPv4Error": "获取失败或不存在 IPv4 地址",
"IPv6Error": "获取失败或不存在 IPv6 地址",
"type": "类型",
- "isProxy": "代理",
+ "anonymity": {
+ "label": "匿名网络",
+ "tor": "Tor 出口",
+ "relay": "iCloud 专用代理",
+ "residential": "住宅代理",
+ "suspectedResidential": "疑似住宅代理",
+ "proxy": "代理",
+ "proxySuspected": "疑似代理",
+ "vpn": "VPN",
+ "vpnSuspected": "疑似 VPN",
+ "none": "未检测到"
+ },
"qualityScore": "IP 质量分",
"qualityScoreUnknown": "未知分数",
"advancedUnlockCta": "为避免滥用,登录后可查看以下信息",
- "advancedQuotaCta": "本月新 IP 查询额度已用完",
+ "advancedQuotaCta": "本月 IP 高级数据额度已用完",
"ASNInfo": {
"trafficPercentage": "流量比例:",
"note": "此 AS 的相关数据:",
@@ -1287,10 +1312,6 @@
"zoomReset": "重置缩放"
},
"advancedData": {
- "proxyYes": "已识别为代理 / VPN",
- "proxyMaybe": "疑似代理 / VPN",
- "proxyNo": "未识别为代理",
- "proxyUnknown": "未知类型",
"type": {
"Residential": "住宅线路",
"Wireless": "无线网络",
@@ -1303,6 +1324,71 @@
"NativeIPNo": "非原生",
"nativenessTooltip": "判断 ASN 的注册国家是否与 IP 的归属国家一致"
},
+ "scoreDetails": {
+ "toggle": "评分详情",
+ "explainTitle": "评分依据",
+ "explain": {
+ "unknownOperator": "未知运营方",
+ "summary": {
+ "lowRisk": "IP 地址 {ip} 由 {operator} 运营,质量评分为 {score}/100,我们认为风险较低。",
+ "mediumRisk": "IP 地址 {ip} 由 {operator} 运营,质量评分为 {score}/100,我们认为存在一定风险。",
+ "highRisk": "IP 地址 {ip} 由 {operator} 运营,质量评分为 {score}/100,我们认为风险较高。"
+ },
+ "network": {
+ "datacenter": "它属于托管 / 数据中心网络,而不是普通的家庭或移动网络连接。",
+ "mobile": "它位于移动网络,这类网络中一个 IP 往往由许多用户共用。",
+ "isp": "它位于运营商接入网络(家庭或企业宽带),而不是托管服务商。"
+ },
+ "anonymity": {
+ "none": "未检测到它被用于 VPN、代理、Tor 或隐私中继。",
+ "tor": "它属于 Tor 匿名网络。",
+ "relay": "它是 iCloud 专用代理的出口,由大量 Apple 用户共用。",
+ "proxySingle": "单一来源将它识别为代理。",
+ "proxyMulti": "多个独立来源将它识别为代理。",
+ "residentialProxy": "它被识别为住宅代理的出口。",
+ "residentialProxySuspected": "该 IP 曾在 {date} 被观察到疑似作为住宅代理(家庭代理)的出口。",
+ "residentialProxySuspectedUndated": "该 IP 曾被观察到疑似作为住宅代理(家庭代理)的出口。",
+ "vpnSingle": "单一来源将它识别为 VPN 或匿名服务。",
+ "vpnMulti": "多个独立来源将它识别为 VPN 或匿名服务。"
+ },
+ "anonymityLastSeen": "最近一次观测到是在 {date}。",
+ "reputation": {
+ "none": "未发现明显的滥用举报或黑名单记录。",
+ "low": "它有少量滥用证据(举报或黑名单记录)。",
+ "lowDated": "它有少量滥用证据,最近一次滥用举报在 {date}。",
+ "medium": "它有较明显的滥用证据(举报或黑名单记录)。",
+ "mediumDated": "它有较明显的滥用证据,最近一次滥用举报在 {date}。",
+ "high": "它有严重的滥用证据(举报或黑名单记录)。",
+ "highDated": "它有严重的滥用证据,最近一次滥用举报在 {date}。"
+ },
+ "asn": {
+ "none": "它所在网络(ASN)的滥用与 VPN 活动不高于互联网的一般水平。",
+ "low": "它所在网络(ASN)的滥用或 VPN 活动略高于一般水平。",
+ "medium": "它所在网络(ASN)的滥用或 VPN 活动明显高于一般水平。",
+ "high": "它所在网络(ASN)的滥用或 VPN 活动远高于一般水平。"
+ }
+ },
+ "tagsTitle": "全部属性",
+ "tagState": {
+ "yes": "是",
+ "no": "否",
+ "suspected": "疑似"
+ },
+ "tags": {
+ "isDatacenter": "数据中心",
+ "isResidential": "住宅线路",
+ "isMobile": "移动网络",
+ "isSatellite": "卫星网络",
+ "isProxy": "代理",
+ "isVPN": "VPN",
+ "isResidentialProxy": "住宅代理",
+ "isRelay": "iCloud 专用代理",
+ "isTor": "Tor",
+ "isAbuser": "滥用记录",
+ "isCrawler": "爬虫",
+ "isNative": "原生性"
+ }
+ },
"countryTraffic": {
"subtitle": "该国家/地区的每周网络活跃分布—— 你可以选择仅看人类所产生的流量或全部流量(包含机器人/自动化流量)。",
"human": "仅人类流量",
diff --git a/frontend/store.js b/frontend/store.js
index 54e5dbc80..e60fd306f 100644
--- a/frontend/store.js
+++ b/frontend/store.js
@@ -119,10 +119,7 @@ export const useMainStore = defineStore('main', {
// blocked and their components use this as a pre-flight gate.
quotaExceeded: (state) => {
const features = state.remoteUserInfo?.quota?.features || {};
- const exceeded = (key) => {
- const feature = features[key];
- return Boolean(feature && feature.limit > 0 && feature.used >= feature.limit);
- };
+ const exceeded = (key) => features[key]?.exhausted === true;
return {
ipinfo: exceeded('ipinfo'),
invisibility_test: exceeded('invisibility_test'),
@@ -349,11 +346,12 @@ export const useMainStore = defineStore('main', {
this.triggerUserBenefits = value;
},
// Backend replied 429 quota_exceeded for a feature: pin the local snapshot
- // to its limit so the quotaExceeded getter flips without a refetch.
+ // to exhausted so the quotaExceeded getter flips without a refetch.
markQuotaExhausted(feature) {
const quotaFeature = this.remoteUserInfo?.quota?.features?.[feature];
- if (quotaFeature && typeof quotaFeature.limit === 'number') {
- quotaFeature.used = Math.max(quotaFeature.used ?? 0, quotaFeature.limit);
+ if (quotaFeature) {
+ quotaFeature.percent = 100;
+ quotaFeature.exhausted = true;
}
},
// trigger remote fetch user info
diff --git a/frontend/utils/ip-history.js b/frontend/utils/ip-history.js
index 94ab2b950..b18c018d0 100644
--- a/frontend/utils/ip-history.js
+++ b/frontend/utils/ip-history.js
@@ -6,12 +6,12 @@
import { isValidIP, isIPv6 } from './valid-ip.js';
export const IP_HISTORY_STORAGE_KEY = 'ipHistory';
-export const IP_HISTORY_RETENTION_DAYS = 90;
+export const IP_HISTORY_RETENTION_DAYS = 30;
export const IP_HISTORY_MIN_DAYS = 1;
const CURRENT_VERSION = 1;
-// Clamp a user-configured retention value to a whole day in [1, 90];
-// anything non-numeric falls back to the 90-day default.
+// Clamp a user-configured retention value to a whole day in [1, 30];
+// anything non-numeric falls back to the 30-day default.
export const clampRetentionDays = (value) => {
const n = Number(value);
if (!Number.isFinite(n)) return IP_HISTORY_RETENTION_DAYS;
@@ -130,15 +130,50 @@ export const sortedHistoryDays = (history) =>
.sort(([a], [b]) => (a < b ? 1 : -1))
.map(([day, entries]) => ({ day, entries }));
+// Aggregate retained (optionally filtered) records without changing storage.
+// Keep the latest matching record's metadata together: combining fields from
+// different days could invent a country/location/ASN combination never seen.
+export const groupHistoryByIP = (days) => {
+ const byIp = new Map();
+ for (const { day, entries } of [...days].sort((a, b) => b.day.localeCompare(a.day))) {
+ for (const entry of entries) {
+ if (!byIp.has(entry.ip)) {
+ byIp.set(entry.ip, { ...entry, dates: new Set() });
+ }
+ byIp.get(entry.ip).dates.add(day);
+ }
+ }
+ return [...byIp.values()]
+ .map(({ dates, ...entry }) => {
+ const recordedDays = [...dates];
+ return {
+ ...entry,
+ dates: recordedDays,
+ firstSeen: recordedDays.at(-1),
+ lastSeen: recordedDays[0],
+ dayCount: recordedDays.length,
+ };
+ })
+ .sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || a.ip.localeCompare(b.ip));
+};
+
// Aggregate entry counts per country over a sortedHistoryDays() list, most
// entries first (code asc as tie-breaker). Entries without a country roll up
// under ''. Takes the day list rather than the history object so facets can
// be computed on an already-filtered view (e.g. after the IP-type filter).
-export const countryFacets = (days) => {
+// Unique mode counts each IP once per country it was recorded in, so a
+// historical country remains discoverable even if the latest record differs.
+export const countryFacets = (days, { uniqueIPs = false } = {}) => {
const counts = new Map();
+ const seen = new Map();
for (const { entries } of days) {
- for (const { country } of entries) {
+ for (const { ip, country } of entries) {
const code = country || '';
+ if (uniqueIPs) {
+ if (!seen.has(code)) seen.set(code, new Set());
+ if (seen.get(code).has(ip)) continue;
+ seen.get(code).add(ip);
+ }
counts.set(code, (counts.get(code) || 0) + 1);
}
}
@@ -159,12 +194,15 @@ export const distinctCountryCount = (history) => {
return codes.size;
};
-// Entry counts per IP version over a sortedHistoryDays() list.
-export const ipVersionCounts = (days) => {
+// Entry counts per IP version, optionally deduplicated across all days.
+export const ipVersionCounts = (days, { uniqueIPs = false } = {}) => {
let v4 = 0;
let v6 = 0;
+ const seen = new Set();
for (const { entries } of days) {
for (const { ip } of entries) {
+ if (uniqueIPs && seen.has(ip)) continue;
+ seen.add(ip);
if (isIPv6(ip)) v6 += 1;
else v4 += 1;
}
diff --git a/frontend/utils/ip-score-details.js b/frontend/utils/ip-score-details.js
new file mode 100644
index 000000000..7f4203943
--- /dev/null
+++ b/frontend/utils/ip-score-details.js
@@ -0,0 +1,115 @@
+// Score-details panel for IPCheck.ing results (IpDetailPanel): a plain-
+// language explanation of the quality score, built as a list of i18n
+// sentences, and the attribute rows below it. Pure — no Vue, no fetch.
+//
+// Inputs are the transformed card fields (transform-ip-data.js): `scoreTags`,
+// `scoreDimensions`, `qualityScore`, `isp`, `asn`, `ip`. Gated responses (or
+// any without `dimensions`) yield nothing.
+
+const KEY = 'ipInfos.scoreDetails';
+
+const LEVELS = new Set(['none', 'low', 'medium', 'high']);
+const ANONYMITY_KEYS = { tor: 'tor', relay: 'relay', residential_proxy: 'residentialProxy' };
+
+const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+const isIsoDay = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
+
+// Residential proxy seen only in our own sighting history: no other source
+// agrees (isAnyAnonymizer stays false), so the copy says "suspected".
+export const isSuspectedResidentialProxy = (tags) =>
+ isPlainObject(tags) && tags.isResidentialProxy === true && tags.isAnyAnonymizer !== true;
+
+// Attribute rows in display order: network type, anonymity, then behaviour
+// and other. Left out: isBogon (meaningless for the public IPs looked up
+// here), isAnyAnonymizer (only summarises the anonymity rows) and isBenignBot.
+export const SCORE_TAG_KEYS = [
+ 'isDatacenter', 'isResidential', 'isMobile', 'isSatellite',
+ 'isProxy', 'isVPN', 'isResidentialProxy', 'isRelay', 'isTor',
+ 'isAbuser', 'isCrawler', 'isNative',
+];
+
+// Boolean tags as { key, state }: 'yes' / 'no', or 'suspected' for a
+// residential proxy only our sighting history reports.
+export const listScoreTags = (tags) => {
+ if (!isPlainObject(tags)) return [];
+ const suspected = isSuspectedResidentialProxy(tags);
+ return SCORE_TAG_KEYS
+ .filter((key) => typeof tags[key] === 'boolean')
+ .map((key) => ({
+ key,
+ state: key === 'isResidentialProxy' && suspected ? 'suspected' : tags[key] ? 'yes' : 'no',
+ }));
+};
+
+// Same cut-offs as the score bar's colour tiers.
+export const scoreRiskTier = (score) => {
+ if (score >= 80) return 'lowRisk';
+ if (score >= 50) return 'mediumRisk';
+ return 'highRisk';
+};
+
+// One sentence per dimension, each { key, params }. Empty unless the card
+// carries the v3 `dimensions` block and a numeric score. `t` translates the
+// fallback operator name; `formatDate` renders a YYYY-MM-DD day.
+export const buildScoreExplanation = (data, { t = (key) => key, formatDate = (day) => day } = {}) => {
+ const dims = data?.scoreDimensions;
+ const rawScore = data?.qualityScore;
+ const score = typeof rawScore === 'number' || /^\d+(\.\d+)?$/.test(rawScore ?? '') ? Number(rawScore) : NaN;
+ if (!isPlainObject(dims) || !Number.isFinite(score)) return [];
+
+ const tags = isPlainObject(data.scoreTags) ? data.scoreTags : {};
+ const sentences = [];
+ const add = (key, params = {}) => sentences.push({ key: `${KEY}.explain.${key}`, params });
+
+ // Summary: who runs it + the verdict.
+ const org = data.isp || '';
+ const asn = data.asn || '';
+ const operator = org && asn ? `${org} (${asn})` : org || asn || t(`${KEY}.explain.unknownOperator`);
+ add(`summary.${scoreRiskTier(score)}`, { ip: data.ip || '', operator, score: String(Math.round(score)) });
+
+ // Network type.
+ if (tags.isDatacenter === true) add('network.datacenter');
+ else if (tags.isMobile === true) add('network.mobile');
+ else add('network.isp');
+
+ // Anonymity class, then when it was last observed. A suspected residential
+ // proxy carries its date in the one sentence.
+ const anonymity = isPlainObject(dims.anonymity) ? dims.anonymity : null;
+ if (anonymity) {
+ const cls = anonymity.class;
+ const suspected = cls === 'residential_proxy' && isSuspectedResidentialProxy(tags);
+ if (suspected) {
+ if (isIsoDay(anonymity.lastSeen)) {
+ add('anonymity.residentialProxySuspected', { date: formatDate(anonymity.lastSeen) });
+ } else {
+ add('anonymity.residentialProxySuspectedUndated');
+ }
+ } else if (cls === 'vpn' || cls === 'proxy') {
+ // No counts in the copy: only single vs multiple sources.
+ add(`anonymity.${cls}${anonymity.sources === 'multi' ? 'Multi' : 'Single'}`);
+ } else if (ANONYMITY_KEYS[cls]) {
+ add(`anonymity.${ANONYMITY_KEYS[cls]}`);
+ } else if (cls == null) {
+ add('anonymity.none');
+ }
+ if (cls != null && !suspected && isIsoDay(anonymity.lastSeen)) {
+ add('anonymityLastSeen', { date: formatDate(anonymity.lastSeen) });
+ }
+ }
+
+ // Reputation (abuse evidence), dated by the latest report when there is one.
+ const reputation = isPlainObject(dims.reputation) ? dims.reputation : null;
+ if (reputation && LEVELS.has(reputation.level)) {
+ if (reputation.level !== 'none' && isIsoDay(reputation.lastSeen)) {
+ add(`reputation.${reputation.level}Dated`, { date: formatDate(reputation.lastSeen) });
+ } else {
+ add(`reputation.${reputation.level}`);
+ }
+ }
+
+ // ASN neighbourhood.
+ const network = isPlainObject(dims.network) ? dims.network : null;
+ if (network && LEVELS.has(network.level)) add(`asn.${network.level}`);
+
+ return sentences;
+};
diff --git a/frontend/utils/report-builders.js b/frontend/utils/report-builders.js
index 0bd80b75a..9bf9459a9 100644
--- a/frontend/utils/report-builders.js
+++ b/frontend/utils/report-builders.js
@@ -7,7 +7,7 @@
// holds nothing worth reporting.
import { isValidIP } from './valid-ip.js';
-import { CONNECTIVITY_STATUS } from './report-schema.js';
+import { CONNECTIVITY_STATUS, ANONYMITY_CODES } from './report-schema.js';
// --- small shared normalizers ----------------------------------------------
@@ -40,13 +40,14 @@ const compact = (obj) => {
// --- per-section builders ---------------------------------------------------
// ipinfo:finished — { cards: [{ source, ip, country_code, region, city,
-// timezone, asn, isp, proxyCode?, ipTypeCode?, isNativeIP?, qualityScore?,
-// proxyProtocol?, proxyProvider? }] }. Cards whose ip never resolved (placeholder text in the
-// slot) are dropped. The optional enrichments only exist on the IPCheck.ing
-// source and stay absent when gated ('sign_in_required' / 'quota_exceeded'
-// upstream values never turn into codes — see transform-ip-data.js). 'unknown' codes
-// are noise in a report ("Unknown type" says nothing) — dropped like absent.
-const PROXY_CODES = new Set(['yes', 'maybe', 'no']);
+// timezone, asn, isp, anonymityCode?, ipTypeCode?, isNativeIP?, qualityScore?,
+// anonymityProtocol?, anonymityProvider? }] }. Cards whose ip never resolved
+// (placeholder text in the slot) are dropped. The optional enrichments only
+// exist on the IPCheck.ing source and stay absent when gated
+// ('sign_in_required' / 'quota_exceeded' upstream values never turn into
+// codes — see transform-ip-data.js). An 'unknown' line type is noise in a
+// report ("Unknown type" says nothing) — dropped like absent.
+const ANONYMITY_CODE_SET = new Set(ANONYMITY_CODES);
const IP_TYPE_CODES = new Set(['business', 'residential', 'wireless', 'hosting']);
const buildIpinfo = (payload) => {
const cards = (payload?.cards ?? [])
@@ -63,14 +64,12 @@ const buildIpinfo = (payload) => {
timezone: card.timezone ? clip(card.timezone, 64) : undefined,
asn: clip(card.asn, 16),
isp: clip(card.isp, 128),
- isProxy: PROXY_CODES.has(card.proxyCode) ? card.proxyCode : undefined,
+ anonymity: ANONYMITY_CODE_SET.has(card.anonymityCode) ? card.anonymityCode : undefined,
ipType: IP_TYPE_CODES.has(card.ipTypeCode) ? card.ipTypeCode : undefined,
nativeIP: typeof card.isNativeIP === 'boolean' ? card.isNativeIP : undefined,
qualityScore: finiteNum(Number(card.qualityScore), 0, 100),
- proxyProtocol: card.proxyProtocol && card.proxyProtocol !== 'unknown'
- ? clip(card.proxyProtocol, 32) : undefined,
- proxyProvider: card.proxyProvider && card.proxyProvider !== 'unknown'
- ? clip(card.proxyProvider, 64) : undefined,
+ anonymityProtocol: clip(card.anonymityProtocol || undefined, 32),
+ anonymityProvider: clip(card.anonymityProvider || undefined, 64),
}));
return cards.length ? { cards } : null;
};
diff --git a/frontend/utils/report-export.js b/frontend/utils/report-export.js
index 645bf9e91..997a72171 100644
--- a/frontend/utils/report-export.js
+++ b/frontend/utils/report-export.js
@@ -72,7 +72,7 @@ const MTR_HOP_STATS = ['lossPct', 'sntCount', 'drop', 'rcv', 'lastMs', 'avgMs',
// ipinfo's IPCheck.ing enrichment columns only render when some card
// carries them (other sources / signed-out runs never do).
-const IPINFO_EXTRA_COLS = ['isProxy', 'ipType', 'nativeIP', 'qualityScore', 'proxyProtocol', 'proxyProvider'];
+const IPINFO_EXTRA_COLS = ['anonymity', 'ipType', 'nativeIP', 'qualityScore', 'anonymityProtocol', 'anonymityProvider'];
const SECTION_RENDERERS = {
ipinfo: (section) => {
diff --git a/frontend/utils/report-schema.js b/frontend/utils/report-schema.js
index c7ac27581..8c00ee5ce 100644
--- a/frontend/utils/report-schema.js
+++ b/frontend/utils/report-schema.js
@@ -8,6 +8,7 @@ export {
REPORT_MAX_BYTES,
REPORT_SECTION_IDS,
CONNECTIVITY_STATUS,
+ ANONYMITY_CODES,
validateReport,
maskIpTail,
maskReportIps,
diff --git a/frontend/utils/time-utils.js b/frontend/utils/time-utils.js
index 93bcb93c0..8bc8c4317 100644
--- a/frontend/utils/time-utils.js
+++ b/frontend/utils/time-utils.js
@@ -209,17 +209,25 @@ export const isoToDateTime = (iso, locale) => {
// "YYYY-MM-DD" string — the changelog and every date-only ISO surface
// (OONI windows, IP-history day headers). Formatted in UTC so the calendar
// date never shifts a day for west-of-UTC viewers; non-ISO strings (the
-// changelog's "Beta" placeholder) pass through untouched.
+// changelog's "Beta" placeholder) pass through untouched. Formatters are
+// cached per locale: long lists (IP history) call this hundreds of times.
+const isoDateFormatters = new Map();
+
export const formatIsoDate = (isoDate, locale) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || '');
if (!match) return isoDate || '';
const [, year, month, day] = match;
+ const key = locale || '';
try {
- return new Intl.DateTimeFormat(locale || undefined, {
- dateStyle: 'medium',
- timeZone: 'UTC',
- }).format(new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))));
+ if (!isoDateFormatters.has(key)) {
+ isoDateFormatters.set(key, new Intl.DateTimeFormat(locale || undefined, {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ }));
+ }
+ return isoDateFormatters.get(key)
+ .format(new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))));
} catch {
return isoDate;
}
diff --git a/frontend/utils/transform-ip-data.js b/frontend/utils/transform-ip-data.js
index c7e2100bc..fb5cda311 100644
--- a/frontend/utils/transform-ip-data.js
+++ b/frontend/utils/transform-ip-data.js
@@ -56,48 +56,71 @@ function transformDataFromIPapi(data, ipGeoSource, t, mapLanguage) {
const gatedSentinel = (value) =>
value === 'sign_in_required' || value === 'quota_exceeded' ? value : null;
-// Parse proxy data
+const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+// Anonymity verdict code → display text key: one map for the card and the
+// report renderer (ReportIpinfo.vue), so the two can't disagree. The codes
+// themselves live in the report schema (ANONYMITY_CODES).
+export const ANONYMITY_I18N_KEYS = {
+ tor: 'ipInfos.anonymity.tor',
+ relay: 'ipInfos.anonymity.relay',
+ residential: 'ipInfos.anonymity.residential',
+ suspected_residential: 'ipInfos.anonymity.suspectedResidential',
+ proxy: 'ipInfos.anonymity.proxy',
+ proxy_suspected: 'ipInfos.anonymity.proxySuspected',
+ vpn: 'ipInfos.anonymity.vpn',
+ vpn_suspected: 'ipInfos.anonymity.vpnSuspected',
+ none: 'ipInfos.anonymity.none',
+};
+
+// Anonymizing service / protocol the API named: null when absent or gated.
+const optionalName = (value) => (typeof value === 'string' && value && !gatedSentinel(value) ? value : null);
+
+// Parse the advanced (IPCheck.ing-only) fields
function extractAdvancedData(advancedData = {}, t) {
- const isProxy = determineIsProxy(advancedData, t);
+ const anonymityCode = determineAnonymityCode(advancedData);
+ const anonymity = gatedSentinel(advancedData.tags) || t(ANONYMITY_I18N_KEYS[anonymityCode]);
const type = determineType(advancedData, t);
const qualityScore = gatedSentinel(advancedData.score) || advancedData.score;
- const proxyProtocol = advancedData.proxyProtocol || "";
- const proxyProvider = advancedData.proxyProvider || "";
+ const anonymityProvider = optionalName(advancedData.anonymityProvider);
+ const anonymityProtocol = optionalName(advancedData.anonymityProtocol);
const isNativeIP = gatedSentinel(advancedData.tags) || advancedData.tags.isNative;
- // Locale-free twins of isProxy / type for the diagnostic report payload
+ // Locale-free twins of anonymity / type for the diagnostic report payload
// (the display fields above are t()-localized at capture time; the report
// schema only stores enums and renders in the VIEWER's language).
- const proxyCode = determineProxyCode(advancedData);
const ipTypeCode = determineTypeCode(advancedData);
- return { isProxy, type, qualityScore, proxyProtocol, proxyProvider, isNativeIP, proxyCode, ipTypeCode };
-}
+ // Raw inputs for the score-details panel (utils/ip-score-details.js);
+ // undefined when gated.
+ const scoreTags = isPlainObject(advancedData.tags) ? advancedData.tags : undefined;
+ const scoreDimensions = isPlainObject(advancedData.dimensions) ? advancedData.dimensions : undefined;
+ const scoreVersion = Number.isFinite(advancedData.scoreVersion) ? advancedData.scoreVersion : undefined;
-// Determine if it is a proxy
-function determineIsProxy(advancedData, t) {
-
- if (gatedSentinel(advancedData.tags)) {
- return advancedData.tags;
- } else if (advancedData.tags.isProxyOrVPN && advancedData.proxyProtocol !== 'unknown') {
- return t('ipInfos.advancedData.proxyYes');
- } else if (advancedData.tags.isProxyOrVPN) {
- return t('ipInfos.advancedData.proxyMaybe');
- } else if (!advancedData.tags.isProxyOrVPN) {
- return t('ipInfos.advancedData.proxyNo');
- } else {
- return t('ipInfos.advancedData.proxyUnknown');
- }
+ return {
+ anonymity, anonymityCode, anonymityProvider, anonymityProtocol,
+ type, qualityScore, isNativeIP, ipTypeCode,
+ scoreTags, scoreDimensions, scoreVersion,
+ };
}
-// Locale-free code for determineIsProxy — same branch order, enum output.
-// undefined when the data is gated (the report then omits the field).
-function determineProxyCode(advancedData) {
- if (gatedSentinel(advancedData.tags)) return undefined;
- if (advancedData.tags.isProxyOrVPN && advancedData.proxyProtocol !== 'unknown') return 'yes';
- if (advancedData.tags.isProxyOrVPN) return 'maybe';
- if (!advancedData.tags.isProxyOrVPN) return 'no';
- return 'unknown';
+// Locale-free anonymity verdict; undefined when gated (the report then omits
+// it). Tor and iCloud Private Relay first, then the public anonymity class,
+// flagged only when several independent sources agree ('multi'). A
+// residential proxy only our sighting history reports stays suspected.
+function determineAnonymityCode(advancedData) {
+ const tags = advancedData.tags;
+ if (gatedSentinel(tags)) return undefined;
+ const anonymity = advancedData.dimensions?.anonymity;
+ const cls = isPlainObject(anonymity) ? anonymity.class : null;
+ const multi = isPlainObject(anonymity) && anonymity.sources === 'multi';
+ if (tags.isTor) return 'tor';
+ if (tags.isRelay) return 'relay';
+ if (tags.isAnyAnonymizer && cls === 'residential_proxy') return 'residential';
+ if (cls === 'proxy') return multi ? 'proxy' : 'proxy_suspected';
+ if (cls === 'vpn') return multi ? 'vpn' : 'vpn_suspected';
+ if (tags.isResidentialProxy) return 'suspected_residential';
+ return 'none';
}
// Locale-free code for determineType.
diff --git a/frontend/utils/world-map-chart.js b/frontend/utils/world-map-chart.js
index 6fe4abc72..f60b12532 100644
--- a/frontend/utils/world-map-chart.js
+++ b/frontend/utils/world-map-chart.js
@@ -16,9 +16,13 @@
// below the name; return a string array for multi-line
// tooltipOnMissing — default true; false hides the tooltip entirely on
// countries that carry no value
-// }) → Promise (null only if the lazy modules failed to load)
-import { NUMERIC_TO_ALPHA2 } from '@/data/country-numeric.js';
-import getCountryName from '@/data/country-name.js';
+// uniformColor — true uses the info token for every recorded country
+// selectedCountries — ISO alpha-2 codes to outline
+// onCountryClick — optional callback, only called for countries with data
+// isActive — optional guard checked after lazy loading
+// }) → Promise (null on load failure or a stale render)
+import { NUMERIC_TO_ALPHA2 } from '../data/country-numeric.js';
+import getCountryName from '../data/country-name.js';
let chartCtor = null;
let worldFeatures = null;
@@ -90,14 +94,50 @@ export const renderWorldMapChart = async ({
canvas, chart, values, lang, colorFrom, colorTo, formatValue,
tooltipOnMissing = true,
projectionScale = 1.2, projectionOffset = [-18, 16],
+ uniformColor = false, selectedCountries = [], onCountryClick,
+ isActive = () => true,
}) => {
if (!(await loadModules()) || !canvas) return chart ?? null;
+ if (!isActive()) return null;
const data = worldFeatures.map((feature) => ({
feature,
value: values[NUMERIC_TO_ALPHA2[feature.id]],
}));
+ // Selection changes the outline only; IPv6 churn cannot change fill weight.
+ const selected = new Set(selectedCountries);
+ const border = cssColor('--border', '#e5e7eb');
+ const foreground = cssColor('--foreground', '#171717');
+ const fill = cssColor('--info', '#0ea5e9');
+ const missing = cssColor('--muted', '#f4f4f5');
+ const dataset = {
+ data,
+ borderColor: data.map(({ feature }) => selected.has(NUMERIC_TO_ALPHA2[feature.id]) ? foreground : border),
+ borderWidth: data.map(({ feature }) => selected.has(NUMERIC_TO_ALPHA2[feature.id]) ? 2 : 0.5),
+ ...(uniformColor ? { backgroundColor: data.map(({ value }) => value === undefined ? missing : fill) } : {}),
+ };
+ const clickedCountry = (elements, instance) => {
+ const hit = elements[0];
+ const item = hit && instance.data.datasets[hit.datasetIndex]?.data[hit.index];
+ return item?.value !== undefined ? NUMERIC_TO_ALPHA2[item.feature?.id] : undefined;
+ };
+ const onClick = onCountryClick ? (_event, elements, instance) => {
+ const code = clickedCountry(elements, instance);
+ if (code) onCountryClick(code);
+ } : undefined;
+ const onHover = onCountryClick ? (_event, elements, instance) => {
+ instance.canvas.style.cursor = clickedCountry(elements, instance) ? 'pointer' : 'default';
+ } : undefined;
+ const tooltip = {
+ displayColors: false,
+ filter: tooltipOnMissing ? undefined : (item) => item.raw?.value !== undefined,
+ callbacks: {
+ title: (items) => (items[0]?.raw?.feature ? featureName(items[0].raw.feature, lang) : ''),
+ label: (ctx) => formatValue(ctx.raw.value, NUMERIC_TO_ALPHA2[ctx.raw.feature?.id]),
+ },
+ };
+
// A kept instance may point at an unmounted canvas (panels destroy their
// content on close) — rebuild then; update in place otherwise.
if (chart && chart.canvas !== canvas) {
@@ -105,13 +145,18 @@ export const renderWorldMapChart = async ({
chart = null;
}
if (chart) {
- chart.data.datasets[0].data = data;
+ chart.data.datasets[0] = dataset;
+ chart.options.onClick = onClick;
+ chart.options.onHover = onHover;
+ chart.options.plugins.tooltip = tooltip;
+ chart.options.scales.color.missing = missing;
+ chart.canvas.style.cursor = 'default';
chart.update();
return chart;
}
- const from = hexToRgb(colorFrom);
- const to = hexToRgb(colorTo);
+ const from = hexToRgb(colorFrom || '#0ea5e9');
+ const to = hexToRgb(colorTo || '#0ea5e9');
const interpolate = (v) => {
const t = Math.min(1, Math.max(0, v));
const mix = from.map((c, i) => Math.round(c + (to[i] - c) * t));
@@ -121,36 +166,18 @@ export const renderWorldMapChart = async ({
return new chartCtor(canvas, {
type: 'choropleth',
data: {
- datasets: [{
- data,
- borderColor: cssColor('--border', '#e5e7eb'),
- borderWidth: 0.5,
- }],
+ datasets: [dataset],
},
options: {
responsive: true,
maintainAspectRatio: false,
showOutline: false,
showGraticule: false,
+ onClick,
+ onHover,
plugins: {
legend: { display: false },
- tooltip: {
- displayColors: false,
- // With every item filtered out, Chart.js suppresses the
- // tooltip box — no-data countries then get no hover UI.
- filter: tooltipOnMissing
- ? undefined
- : (item) => item.raw?.value !== undefined,
- callbacks: {
- // Line 1: localized name (from the raw feature — the
- // tooltip item's own .label is unreliable for the
- // choropleth controller); below: caller-formatted.
- title: (items) => (items[0]?.raw?.feature
- ? featureName(items[0].raw.feature, lang)
- : ''),
- label: (ctx) => formatValue(ctx.raw.value, NUMERIC_TO_ALPHA2[ctx.raw.feature?.id]),
- },
- },
+ tooltip,
},
scales: {
projection: {
@@ -164,7 +191,7 @@ export const renderWorldMapChart = async ({
display: false,
min: 0,
interpolate,
- missing: cssColor('--muted', '#f4f4f5'),
+ missing,
},
},
},
diff --git a/package.json b/package.json
index c0ceeccef..e46b819c3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "myip",
"private": true,
- "version": "7.6.0",
+ "version": "7.7.0",
"type": "module",
"packageManager": "pnpm@12.4.2",
"engines": {
diff --git a/tests/composable-fit-text.test.js b/tests/composable-fit-text.test.js
new file mode 100644
index 000000000..f40ee8b73
--- /dev/null
+++ b/tests/composable-fit-text.test.js
@@ -0,0 +1,78 @@
+// Specs for fitAll in composables/use-fit-text.js — the batched tier picker
+// behind FitText. Fake elements stand in for the DOM: rendered width comes
+// from the current tier, and every read of scrollWidth / scrollHeight is
+// counted so the batching (reads grouped per pass) is observable.
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { fitAll, INLINE_TIERS } from '../frontend/composables/use-fit-text.js';
+
+const WIDTH = { 'text-base': 300, 'text-sm': 260, 'text-xs': 220 };
+
+// A fake element whose content width depends on its tier class.
+const fakeEl = (clientWidth, log, { connected = true, extra = [] } = {}) => {
+ const classes = new Set(extra);
+ const tier = () => INLINE_TIERS.find((t) => classes.has(t));
+ return {
+ isConnected: connected,
+ clientWidth,
+ clientHeight: 20,
+ classList: {
+ add: (c) => { log.push(`w:${c}`); classes.add(c); },
+ remove: (c) => { classes.delete(c); },
+ contains: (c) => classes.has(c),
+ },
+ get scrollWidth() { log.push('r'); return WIDTH[tier()]; },
+ get scrollHeight() { log.push('r'); return 20; },
+ classes,
+ };
+};
+
+const job = (el, results, maxLines = 1) => ({
+ el, tiers: INLINE_TIERS, maxLines, onFit: (t) => results.push(t),
+});
+
+describe('fitAll', () => {
+ it('picks the largest tier that fits', () => {
+ const log = [];
+ const results = [];
+ const wide = fakeEl(400, log);
+ const mid = fakeEl(270, log);
+ const narrow = fakeEl(100, log);
+ fitAll([job(wide, results), job(mid, results), job(narrow, results)]);
+ assert.deepEqual([...wide.classes], ['text-base']);
+ assert.deepEqual([...mid.classes], ['text-sm']);
+ // Nothing fits: floor at the last tier.
+ assert.deepEqual([...narrow.classes], ['text-xs']);
+ assert.deepEqual(results.sort(), ['text-base', 'text-sm', 'text-xs']);
+ });
+
+ it('groups reads per pass instead of interleaving them with writes', () => {
+ const log = [];
+ const jobs = Array.from({ length: 50 }, () => job(fakeEl(100, log), []));
+ fitAll(jobs);
+ // Collapse runs: each pass is one block of reads followed by writes.
+ const runs = log.map((e) => (e === 'r' ? 'r' : 'w'))
+ .filter((e, i, all) => e !== all[i - 1]);
+ const readBlocks = runs.filter((e) => e === 'r').length;
+ assert.equal(readBlocks, INLINE_TIERS.length);
+ });
+
+ it('strips stale tiers left on the element', () => {
+ const el = fakeEl(400, [], { extra: ['text-xs', 'text-sm'] });
+ fitAll([job(el, [])]);
+ assert.deepEqual([...el.classes], ['text-base']);
+ });
+
+ it('skips detached elements', () => {
+ const results = [];
+ fitAll([job(fakeEl(400, [], { connected: false }), results), job(null, results)]);
+ assert.deepEqual(results, []);
+ });
+
+ it('checks height in multi-line mode', () => {
+ const results = [];
+ // Narrow width but content height fits: first tier wins.
+ fitAll([job(fakeEl(10, []), results, 2)]);
+ assert.deepEqual(results, ['text-base']);
+ });
+});
diff --git a/tests/composable-world-map-chart.test.js b/tests/composable-world-map-chart.test.js
new file mode 100644
index 000000000..2f00d86d1
--- /dev/null
+++ b/tests/composable-world-map-chart.test.js
@@ -0,0 +1,115 @@
+// Verify lazy map rendering stays bounded across updates, folds and unmounts.
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { effectScope, ref, shallowRef, nextTick } from 'vue';
+import { useWorldMapChart } from '../frontend/composables/use-world-map-chart.js';
+
+const flush = async () => {
+ await nextTick();
+ await new Promise((resolve) => setImmediate(resolve));
+};
+const fixture = () => {
+ const scope = effectScope();
+ const canvas = shallowRef(null);
+ const visible = ref(true);
+ const options = ref({ values: { US: 1 } });
+ const theme = ref(false);
+ const pending = [];
+ const state = scope.run(() => useWorldMapChart({ canvas, visible, options, theme }, {
+ render: (args) => new Promise((resolve, reject) => pending.push({ args, resolve, reject })),
+ }));
+ return { scope, canvas, visible, options, theme, pending, ...state };
+};
+const chart = () => ({ destroyed: false, destroy() { this.destroyed = true; } });
+
+test('waits for a canvas, then serializes changes and renders only the latest state', async () => {
+ const f = fixture();
+ await flush();
+ assert.equal(f.pending.length, 0);
+ f.canvas.value = {};
+ await flush();
+ assert.equal(f.pending.length, 1);
+ f.options.value = { values: { DE: 1 } };
+ await flush();
+ f.options.value = { values: { SG: 1 } };
+ await flush();
+ assert.equal(f.pending.length, 1);
+ assert.equal(f.pending[0].args.isActive(), false);
+ const stale = chart();
+ f.pending[0].resolve(stale);
+ await flush();
+ assert.equal(stale.destroyed, true);
+ assert.equal(f.pending.length, 2);
+ assert.deepEqual(f.pending[1].args.values, { SG: 1 });
+ const current = chart();
+ f.pending[1].resolve(current);
+ await flush();
+ assert.equal(f.ready.value, true);
+ f.scope.stop();
+ assert.equal(current.destroyed, true);
+});
+
+test('folding during load discards the render; a live chart survives fold until the canvas leaves', async () => {
+ const f = fixture();
+ f.canvas.value = {};
+ await flush();
+ f.visible.value = false;
+ await flush();
+ assert.equal(f.pending[0].args.isActive(), false);
+ f.pending[0].resolve(null);
+ await flush();
+ assert.equal(f.ready.value, false);
+ assert.equal(f.failed.value, false);
+ f.visible.value = true;
+ await flush();
+ const current = chart();
+ f.pending[1].resolve(current);
+ await flush();
+ assert.equal(f.ready.value, true);
+ f.visible.value = false;
+ await flush();
+ assert.equal(current.destroyed, false);
+ assert.equal(f.ready.value, true);
+ f.canvas.value = null;
+ await flush();
+ assert.equal(current.destroyed, true);
+ assert.equal(f.ready.value, false);
+ f.scope.stop();
+});
+
+test('scope disposal invalidates an unfinished render and destroys any late result', async () => {
+ const f = fixture();
+ f.canvas.value = {};
+ await flush();
+ f.scope.stop();
+ assert.equal(f.pending[0].args.isActive(), false);
+ const late = chart();
+ f.pending[0].resolve(late);
+ await flush();
+ assert.equal(late.destroyed, true);
+ assert.equal(f.ready.value, false);
+});
+
+test('failure is recoverable and theme changes update the existing instance', async () => {
+ const f = fixture();
+ f.canvas.value = {};
+ await flush();
+ f.pending[0].reject(new Error('chunk unavailable'));
+ await flush();
+ assert.equal(f.failed.value, true);
+ f.visible.value = false;
+ await flush();
+ f.visible.value = true;
+ await flush();
+ const current = chart();
+ f.pending[1].resolve(current);
+ await flush();
+ assert.equal(f.failed.value, false);
+ f.theme.value = true;
+ await flush();
+ assert.equal(f.pending[2].args.chart, current);
+ f.pending[2].resolve(current);
+ await flush();
+ assert.equal(f.ready.value, true);
+ f.scope.stop();
+});
diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js
index e85298934..b46300171 100644
--- a/tests/default-preferences.test.js
+++ b/tests/default-preferences.test.js
@@ -23,7 +23,7 @@ describe('DEFAULT_PREFERENCES', () => {
ipCardsToShow: 2,
ipGeoSource: 0,
ipHistoryEnabled: true,
- ipHistoryDays: 90,
+ ipHistoryDays: 30,
lang: 'auto',
connectivityLists: null,
connectivityDefaultListId: 'mine',
diff --git a/tests/ip-history.test.js b/tests/ip-history.test.js
index cc5f8fb17..857359f1e 100644
--- a/tests/ip-history.test.js
+++ b/tests/ip-history.test.js
@@ -1,6 +1,6 @@
// Tests for the pure IP-history helpers behind the local (browser-only)
// IP detection history: parsing/sanitizing the localStorage payload, per-day
-// merge with field back-fill, retention pruning, and render-order sorting.
+// merge with field back-fill, retention pruning, aggregation, facets and filtering.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
@@ -14,6 +14,7 @@ import {
mergeIntoHistory,
pruneHistory,
sortedHistoryDays,
+ groupHistoryByIP,
countryFacets,
distinctCountryCount,
ipVersionCounts,
@@ -30,11 +31,11 @@ describe('localDayKey', () => {
});
describe('clampRetentionDays', () => {
- it('clamps to [1, 90] and rounds to whole days', () => {
+ it('clamps to [1, 30] and rounds to whole days', () => {
assert.equal(clampRetentionDays(30), 30);
assert.equal(clampRetentionDays(0), 1);
assert.equal(clampRetentionDays(-5), 1);
- assert.equal(clampRetentionDays(365), 90);
+ assert.equal(clampRetentionDays(365), 30);
assert.equal(clampRetentionDays(7.6), 8);
});
@@ -154,11 +155,11 @@ describe('pruneHistory', () => {
it('keeps days inside the retention window, today included', () => {
let state = createEmptyHistory();
({ history: state } = mergeIntoHistory(state, [{ ip: '1.1.1.1' }], '2026-07-08'));
- ({ history: state } = mergeIntoHistory(state, [{ ip: '8.8.8.8' }], '2026-04-10')); // 89 days before
- ({ history: state } = mergeIntoHistory(state, [{ ip: '9.9.9.9' }], '2026-04-09')); // 90 days before
+ ({ history: state } = mergeIntoHistory(state, [{ ip: '8.8.8.8' }], '2026-06-09')); // 29 days before
+ ({ history: state } = mergeIntoHistory(state, [{ ip: '9.9.9.9' }], '2026-06-08')); // 30 days before
const { history, changed } = pruneHistory(state, '2026-07-08', IP_HISTORY_RETENTION_DAYS);
assert.equal(changed, true);
- assert.deepEqual(Object.keys(history.days).sort(), ['2026-04-10', '2026-07-08']);
+ assert.deepEqual(Object.keys(history.days).sort(), ['2026-06-09', '2026-07-08']);
});
it('reports changed=false when nothing expires', () => {
@@ -187,6 +188,108 @@ describe('sortedHistoryDays', () => {
});
});
+describe('groupHistoryByIP', () => {
+ it('collapses 90 days of one stable IP to one summary without modifying records', () => {
+ const days = Array.from({ length: 90 }, (_, i) => ({
+ day: localDayKey(new Date(2026, 6, 1 + i)),
+ entries: [entry('192.0.2.1')],
+ }));
+ const before = structuredClone(days);
+ const result = groupHistoryByIP(days);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].dayCount, 90);
+ assert.equal(result[0].firstSeen, days[0].day);
+ assert.equal(result[0].lastSeen, days[89].day);
+ assert.deepEqual(result[0].dates, days.map(({ day }) => day).reverse());
+ assert.deepEqual(days, before);
+ });
+
+ it('counts distinct observed dates, not duplicates or unobserved days in the span', () => {
+ const days = [
+ { day: '2026-07-01', entries: [entry('192.0.2.1'), entry('192.0.2.1')] },
+ { day: '2026-07-09', entries: [entry('192.0.2.1')] },
+ ];
+ assert.deepEqual(groupHistoryByIP(days)[0], {
+ ...entry('192.0.2.1'),
+ dates: ['2026-07-09', '2026-07-01'],
+ firstSeen: '2026-07-01', lastSeen: '2026-07-09', dayCount: 2,
+ });
+ });
+
+ it('sorts by last seen descending with a stable IP tie-breaker, regardless of input order', () => {
+ const days = [
+ { day: '2026-07-01', entries: [entry('192.0.2.1')] },
+ { day: '2026-07-09', entries: [entry('2001:db8::1'), entry('192.0.2.2')] },
+ ];
+ assert.deepEqual(groupHistoryByIP(days).map(({ ip }) => ip), [
+ '192.0.2.2', '2001:db8::1', '192.0.2.1',
+ ]);
+ assert.deepEqual(groupHistoryByIP([...days].reverse()), groupHistoryByIP(days));
+ });
+
+ it('uses the latest matching snapshot without borrowing older geographic details', () => {
+ const days = [
+ { day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US', location: 'New York', asn: 'AS1' })] },
+ { day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG', asn: 'AS2' })] },
+ ];
+ const [summary] = groupHistoryByIP(days);
+ assert.equal(summary.country, 'SG');
+ assert.equal(summary.location, '');
+ assert.equal(summary.asn, 'AS2');
+ const [historical] = groupHistoryByIP(filterHistoryDays(days, { countries: ['US'] }));
+ assert.equal(historical.country, 'US');
+ assert.equal(historical.location, 'New York');
+ assert.equal(historical.lastSeen, '2026-07-01');
+ assert.equal(historical.dayCount, 1);
+ });
+
+ it('derives first seen and counts from retained records only', () => {
+ const history = { version: 1, days: {
+ '2026-07-01': [entry('192.0.2.1')],
+ '2026-07-09': [entry('192.0.2.1')],
+ } };
+ const retained = pruneHistory(history, '2026-07-09', 1).history;
+ const [summary] = groupHistoryByIP(sortedHistoryDays(retained));
+ assert.equal(summary.firstSeen, '2026-07-09');
+ assert.equal(summary.lastSeen, '2026-07-09');
+ assert.equal(summary.dayCount, 1);
+ assert.deepEqual(groupHistoryByIP([]), []);
+ });
+});
+
+describe('unique IP facets', () => {
+ const days = [
+ { day: '2026-07-09', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
+ { day: '2026-07-08', entries: [entry('192.0.2.1', { country: 'SG' }), entry('2001:db8::1')] },
+ { day: '2026-07-01', entries: [entry('192.0.2.1', { country: 'US' })] },
+ ];
+
+ it('counts one IP per country while retaining every historical country', () => {
+ const facets = countryFacets(days, { uniqueIPs: true });
+ assert.deepEqual(facets, [
+ { code: '', count: 1 }, { code: 'SG', count: 1 }, { code: 'US', count: 1 },
+ ]);
+ for (const facet of facets) {
+ assert.equal(groupHistoryByIP(filterHistoryDays(days, { countries: [facet.code] })).length, facet.count);
+ }
+ assert.equal(countryFacets(days).find(({ code }) => code === 'SG').count, 2);
+ assert.deepEqual(countryFacets([], { uniqueIPs: true }), []);
+ });
+
+ it('deduplicates IP versions across days, with the original daily counts still available', () => {
+ assert.deepEqual(ipVersionCounts(days, { uniqueIPs: true }), { v4: 1, v6: 1 });
+ assert.deepEqual(ipVersionCounts(days), { v4: 3, v6: 2 });
+ assert.deepEqual(ipVersionCounts([], { uniqueIPs: true }), { v4: 0, v6: 0 });
+ });
+
+ it('combines country selections without counting a shared IP twice', () => {
+ const filtered = filterHistoryDays(days, { countries: ['SG', 'US'], versions: [4] });
+ const summaries = groupHistoryByIP(filtered);
+ assert.equal(summaries.length, 1);
+ assert.equal(summaries[0].dayCount, 3);
+ });
+});
+
// Two-day history spanning US / SG / no-country entries and both IP versions,
// used by the filter suites below. Day 07-08: three v4 + one v6 (US);
// day 07-07: one v4 (US).
diff --git a/tests/ip-score-details.test.js b/tests/ip-score-details.test.js
new file mode 100644
index 000000000..69cbc72a7
--- /dev/null
+++ b/tests/ip-score-details.test.js
@@ -0,0 +1,236 @@
+// Tests for frontend/utils/ip-score-details.js — the score-details panel's
+// explanation sentences (i18n keys + params) and attribute rows, including
+// responses without dimensions and gated ones.
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import fs from 'node:fs';
+
+import {
+ isSuspectedResidentialProxy,
+ SCORE_TAG_KEYS,
+ listScoreTags,
+ scoreRiskTier,
+ buildScoreExplanation,
+} from '../frontend/utils/ip-score-details.js';
+
+const t = (key) => `<${key}>`;
+const P = 'ipInfos.scoreDetails.explain.';
+
+const v3Tags = {
+ isBogon: false, isMobile: false, isResidential: false, isSatellite: false,
+ isCrawler: false, isBenignBot: false, isDatacenter: false, isTor: false,
+ isProxy: false, isVPN: false, isAnyAnonymizer: false, isRelay: false, isResidentialProxy: true,
+ isAbuser: false, isNative: true,
+};
+
+const card = (overrides = {}) => ({
+ ip: '44.27.137.203',
+ isp: 'Amateur Radio Digital Communications',
+ asn: 'AS7377',
+ qualityScore: 90,
+ scoreTags: v3Tags,
+ scoreDimensions: {
+ reputation: { level: 'none', lastSeen: null },
+ anonymity: { class: 'residential_proxy', sources: 'single', firstSeen: null, lastSeen: '2026-09-20' },
+ network: { level: 'none', asn: 7377 },
+ },
+ ...overrides,
+});
+
+const keysOf = (sentences) => sentences.map((s) => s.key.replace(P, ''));
+
+describe('listScoreTags()', () => {
+ it('lists the attribute rows in display order, without isBogon / isAnyAnonymizer / isBenignBot', () => {
+ const rows = listScoreTags(v3Tags);
+ assert.deepEqual(rows.map((r) => r.key), SCORE_TAG_KEYS);
+ assert.deepEqual(SCORE_TAG_KEYS, [
+ 'isDatacenter', 'isResidential', 'isMobile', 'isSatellite',
+ 'isProxy', 'isVPN', 'isResidentialProxy', 'isRelay', 'isTor',
+ 'isAbuser', 'isCrawler', 'isNative',
+ ]);
+ assert.ok(!rows.some((r) => ['isBogon', 'isAnyAnonymizer', 'isBenignBot'].includes(r.key)));
+ });
+
+ it('maps booleans to yes / no, and a sighting-only residential proxy to suspected', () => {
+ const state = (tags, key) => listScoreTags(tags).find((r) => r.key === key).state;
+ assert.equal(state(v3Tags, 'isResidentialProxy'), 'suspected');
+ assert.equal(state({ ...v3Tags, isProxy: true, isAnyAnonymizer: true }, 'isResidentialProxy'), 'yes');
+ assert.equal(state({ ...v3Tags, isResidentialProxy: false }, 'isResidentialProxy'), 'no');
+ assert.equal(state(v3Tags, 'isNative'), 'yes');
+ assert.equal(state(v3Tags, 'isTor'), 'no');
+ // isProxy and isVPN can both be true.
+ const both = listScoreTags({ ...v3Tags, isProxy: true, isVPN: true, isAnyAnonymizer: true });
+ assert.equal(both.find((r) => r.key === 'isProxy').state, 'yes');
+ assert.equal(both.find((r) => r.key === 'isVPN').state, 'yes');
+ });
+
+ it('skips missing tags and returns nothing for gated or absent tags', () => {
+ const { isSatellite, ...partial } = v3Tags;
+ assert.ok(!listScoreTags(partial).some((r) => r.key === 'isSatellite'));
+ assert.deepEqual(listScoreTags('sign_in_required'), []);
+ assert.deepEqual(listScoreTags('quota_exceeded'), []);
+ assert.deepEqual(listScoreTags(undefined), []);
+ });
+
+ it('has a label for every row and every state in the en pack', () => {
+ const en = JSON.parse(fs.readFileSync(new URL('../frontend/locales/en.json', import.meta.url), 'utf8'));
+ const details = en.ipInfos.scoreDetails;
+ assert.equal(typeof details.tagsTitle, 'string');
+ for (const key of SCORE_TAG_KEYS) assert.equal(typeof details.tags[key], 'string', key);
+ for (const state of ['yes', 'no', 'suspected']) assert.equal(typeof details.tagState[state], 'string', state);
+ });
+});
+
+describe('isSuspectedResidentialProxy()', () => {
+ it('is true only for a residential proxy no other source calls a proxy', () => {
+ assert.equal(isSuspectedResidentialProxy(v3Tags), true);
+ assert.equal(isSuspectedResidentialProxy({ ...v3Tags, isProxy: true, isAnyAnonymizer: true }), false);
+ assert.equal(isSuspectedResidentialProxy({ ...v3Tags, isResidentialProxy: false }), false);
+ assert.equal(isSuspectedResidentialProxy('sign_in_required'), false);
+ });
+});
+
+describe('scoreRiskTier()', () => {
+ it('matches the score bar tiers', () => {
+ assert.equal(scoreRiskTier(100), 'lowRisk');
+ assert.equal(scoreRiskTier(80), 'lowRisk');
+ assert.equal(scoreRiskTier(79), 'mediumRisk');
+ assert.equal(scoreRiskTier(50), 'mediumRisk');
+ assert.equal(scoreRiskTier(49), 'highRisk');
+ });
+});
+
+describe('buildScoreExplanation()', () => {
+ it('builds one sentence per dimension for a v3 card', () => {
+ const out = buildScoreExplanation(card(), { t, formatDate: (d) => `[${d}]` });
+ assert.deepEqual(keysOf(out), [
+ 'summary.lowRisk', 'network.isp', 'anonymity.residentialProxySuspected',
+ 'reputation.none', 'asn.none',
+ ]);
+ assert.deepEqual(out[0].params, {
+ ip: '44.27.137.203',
+ operator: 'Amateur Radio Digital Communications (AS7377)',
+ score: '90',
+ });
+ assert.deepEqual(out[2].params, { date: '[2026-09-20]' });
+ });
+
+ it('dates a suspected residential proxy in one sentence, or drops the date', () => {
+ const undated = keysOf(buildScoreExplanation(card({
+ scoreDimensions: { ...card().scoreDimensions, anonymity: { class: 'residential_proxy', sources: 'single', lastSeen: null } },
+ }), { t }));
+ assert.ok(undated.includes('anonymity.residentialProxySuspectedUndated'));
+ assert.ok(!undated.includes('anonymityLastSeen'));
+ });
+
+ it('keeps the confirmed residential proxy sentence plus its last-observed day', () => {
+ const out = buildScoreExplanation(card({
+ qualityScore: 25,
+ scoreTags: { ...v3Tags, isProxy: true, isAnyAnonymizer: true },
+ }), { t, formatDate: (d) => d });
+ assert.deepEqual(keysOf(out).slice(2, 4), ['anonymity.residentialProxy', 'anonymityLastSeen']);
+ assert.deepEqual(out[3].params, { date: '2026-09-20' });
+ });
+
+ it('picks network variants from the tags', () => {
+ const dc = buildScoreExplanation(card({ scoreTags: { ...v3Tags, isDatacenter: true, isMobile: true } }), { t });
+ assert.ok(keysOf(dc).includes('network.datacenter'));
+ const mobile = buildScoreExplanation(card({ scoreTags: { ...v3Tags, isMobile: true } }), { t });
+ assert.ok(keysOf(mobile).includes('network.mobile'));
+ });
+
+ it('picks anonymity variants by class and source agreement', () => {
+ const withAnon = (anonymity) => keysOf(buildScoreExplanation(card({
+ scoreDimensions: { ...card().scoreDimensions, anonymity },
+ }), { t }));
+ assert.ok(withAnon({ class: null, sources: null, lastSeen: '2026-09-01' }).includes('anonymity.none'));
+ assert.ok(!withAnon({ class: null, sources: null, lastSeen: '2026-09-01' }).includes('anonymityLastSeen'));
+ assert.ok(withAnon({ class: 'tor', sources: 'multi', lastSeen: null }).includes('anonymity.tor'));
+ assert.ok(withAnon({ class: 'relay', sources: 'single', lastSeen: null }).includes('anonymity.relay'));
+ assert.ok(withAnon({ class: 'proxy', sources: 'multi', lastSeen: null }).includes('anonymity.proxyMulti'));
+ assert.ok(withAnon({ class: 'proxy', sources: 'single', lastSeen: null }).includes('anonymity.proxySingle'));
+ assert.ok(withAnon({ class: 'proxy', sources: null, lastSeen: null }).includes('anonymity.proxySingle'));
+ assert.ok(withAnon({ class: 'vpn', sources: 'single', lastSeen: null }).includes('anonymity.vpnSingle'));
+ assert.ok(withAnon({ class: 'vpn', sources: 'multi', lastSeen: null }).includes('anonymity.vpnMulti'));
+ // Anything but 'multi' (missing, null, unknown) reads as a single source.
+ assert.ok(withAnon({ class: 'vpn', lastSeen: null }).includes('anonymity.vpnSingle'));
+ // VPN sentences carry no count.
+ const vpn = buildScoreExplanation(card({
+ scoreDimensions: { ...card().scoreDimensions, anonymity: { class: 'vpn', sources: 'multi', lastSeen: null } },
+ }), { t });
+ assert.deepEqual(vpn.find((s) => s.key === `${P}anonymity.vpnMulti`).params, {});
+ // An unknown future class is skipped rather than mislabelled.
+ assert.ok(!withAnon({ class: 'something_new', sources: 'single', lastSeen: null }).some((k) => k.startsWith('anonymity')));
+ });
+
+ it('dates reputation sentences only when there is abuse evidence and a report day', () => {
+ const withRep = (reputation) => buildScoreExplanation(card({
+ scoreDimensions: { ...card().scoreDimensions, reputation },
+ }), { t, formatDate: (d) => d });
+ const high = withRep({ level: 'high', lastSeen: '2026-09-25' });
+ assert.deepEqual(high.find((s) => s.key === `${P}reputation.highDated`).params, { date: '2026-09-25' });
+ assert.ok(keysOf(withRep({ level: 'medium', lastSeen: null })).includes('reputation.medium'));
+ assert.ok(keysOf(withRep({ level: 'none', lastSeen: '2026-01-01' })).includes('reputation.none'));
+ });
+
+ it('maps the ASN level and the score tier', () => {
+ const out = keysOf(buildScoreExplanation(card({
+ qualityScore: 30,
+ scoreDimensions: { ...card().scoreDimensions, network: { level: 'high', asn: 1 } },
+ }), { t }));
+ assert.equal(out[0], 'summary.highRisk');
+ assert.ok(out.includes('asn.high'));
+ });
+
+ it('falls back on the operator name', () => {
+ const asnOnly = buildScoreExplanation(card({ isp: '' }), { t });
+ assert.equal(asnOnly[0].params.operator, 'AS7377');
+ const none = buildScoreExplanation(card({ isp: '', asn: '' }), { t });
+ assert.equal(none[0].params.operator, `<${P}unknownOperator>`);
+ });
+
+ it('is empty without dimensions, for gated data and unknown scores', () => {
+ assert.deepEqual(buildScoreExplanation(card({ scoreDimensions: undefined }), { t }), []);
+ assert.deepEqual(buildScoreExplanation(card({ qualityScore: 'sign_in_required', scoreDimensions: undefined }), { t }), []);
+ assert.deepEqual(buildScoreExplanation(card({ qualityScore: 'unknown' }), { t }), []);
+ assert.deepEqual(buildScoreExplanation(card({ qualityScore: null }), { t }), []);
+ assert.deepEqual(buildScoreExplanation(undefined), []);
+ });
+
+ it('skips malformed dimensions instead of throwing', () => {
+ const out = keysOf(buildScoreExplanation(card({
+ scoreDimensions: { reputation: { level: 'bogus' }, anonymity: 'x', network: null },
+ }), { t }));
+ assert.deepEqual(out, ['summary.lowRisk', 'network.isp']);
+ });
+
+ it('only produces keys that exist in the en pack', () => {
+ const en = JSON.parse(fs.readFileSync(new URL('../frontend/locales/en.json', import.meta.url), 'utf8'));
+ const resolve = (key) => key.split('.').reduce((node, part) => node?.[part], en);
+ const variants = [
+ card(),
+ card({ qualityScore: 60, scoreTags: { ...v3Tags, isDatacenter: true } }),
+ card({ qualityScore: 10, scoreTags: { ...v3Tags, isMobile: true } }),
+ card({ qualityScore: 25, scoreTags: { ...v3Tags, isProxy: true, isAnyAnonymizer: true } }),
+ ];
+ const dims = [
+ { class: null }, { class: 'tor' }, { class: 'relay' }, { class: 'proxy', sources: 'single' }, { class: 'proxy', sources: 'multi' },
+ { class: 'vpn', sources: 'single' }, { class: 'vpn', sources: 'multi', lastSeen: '2026-09-01' },
+ { class: 'residential_proxy', lastSeen: '2026-09-01' }, { class: 'residential_proxy', lastSeen: null },
+ ];
+ for (const level of ['none', 'low', 'medium', 'high']) {
+ for (const anonymity of dims) {
+ for (const base of variants) {
+ for (const lastSeen of [null, '2026-09-02']) {
+ const out = buildScoreExplanation({
+ ...base,
+ scoreDimensions: { reputation: { level, lastSeen }, anonymity, network: { level } },
+ }, { t });
+ for (const { key } of out) assert.equal(typeof resolve(key), 'string', key);
+ }
+ }
+ }
+ }
+ });
+});
diff --git a/tests/report-builders.test.js b/tests/report-builders.test.js
index 56c5005a5..9aac79c9a 100644
--- a/tests/report-builders.test.js
+++ b/tests/report-builders.test.js
@@ -186,36 +186,57 @@ describe('cleaning rules', () => {
const section = build({
cards: [{
source: 'IPCheck.ing IPv4', ip: '1.2.3.4', country_code: 'US',
- proxyCode: 'no', ipTypeCode: 'residential', isNativeIP: true,
- qualityScore: '85', proxyProtocol: 'unknown', proxyProvider: '',
+ anonymityCode: 'vpn', ipTypeCode: 'residential', isNativeIP: true,
+ qualityScore: '85', anonymityProtocol: 'OpenVPN', anonymityProvider: 'ACME VPN',
}, {
// Signed-out run: locale-free codes stay undefined and raw
// 'sign_in_required' strings must never survive the builder.
source: 'IPCheck.ing IPv6', ip: '2001:db8::1', country_code: 'US',
- proxyCode: undefined, ipTypeCode: undefined,
+ anonymityCode: undefined, ipTypeCode: undefined,
isNativeIP: 'sign_in_required', qualityScore: 'sign_in_required',
+ anonymityProvider: null, anonymityProtocol: null,
}, {
- // 'unknown' values carry no signal — dropped like absent.
+ // An unknown line type carries no signal; no service named.
source: 'IPCheck.ing IPv6/4', ip: '9.9.9.9', country_code: 'US',
- proxyCode: 'unknown', ipTypeCode: 'unknown',
- proxyProvider: 'unknown', proxyProtocol: 'unknown',
+ anonymityCode: 'none', ipTypeCode: 'unknown',
+ anonymityProvider: null, anonymityProtocol: '',
}],
});
- const [enriched, gated, unknowns] = section.cards;
- assert.equal('isProxy' in unknowns, false);
- assert.equal('ipType' in unknowns, false);
- assert.equal('proxyProvider' in unknowns, false);
- assert.equal('proxyProtocol' in unknowns, false);
- assert.equal(enriched.isProxy, 'no');
+ const [enriched, gated, plain] = section.cards;
+ assert.equal(enriched.anonymity, 'vpn');
+ assert.equal(enriched.anonymityProvider, 'ACME VPN');
+ assert.equal(enriched.anonymityProtocol, 'OpenVPN');
assert.equal(enriched.ipType, 'residential');
assert.equal(enriched.nativeIP, true);
assert.equal(enriched.qualityScore, 85);
- // 'unknown' protocol and empty provider stay out of the report.
- assert.equal('proxyProtocol' in enriched, false);
- assert.equal('proxyProvider' in enriched, false);
- for (const key of ['isProxy', 'ipType', 'nativeIP', 'qualityScore']) {
+ assert.equal(plain.anonymity, 'none');
+ assert.equal('ipType' in plain, false);
+ assert.equal('anonymityProvider' in plain, false);
+ assert.equal('anonymityProtocol' in plain, false);
+ for (const key of ['anonymity', 'ipType', 'nativeIP', 'qualityScore', 'anonymityProvider', 'anonymityProtocol']) {
assert.equal(key in gated, false, `${key} should be absent on the gated card`);
}
+ // The old field names are gone from the report.
+ for (const card of section.cards) {
+ for (const key of ['isProxy', 'proxyProvider', 'proxyProtocol']) assert.equal(key in card, false, key);
+ }
+ });
+
+ it('ipinfo keeps every anonymity code and nothing else', () => {
+ const { build } = REPORT_EVENT_BUILDERS['ipinfo:finished'];
+ const codes = [
+ 'tor', 'relay', 'residential', 'suspected_residential',
+ 'proxy', 'proxy_suspected', 'vpn', 'vpn_suspected', 'none',
+ ];
+ // One card per build: a section holds at most 8 cards.
+ const kept = codes.map((anonymityCode) => build({
+ cards: [{ source: 'IPCheck.ing', ip: '1.2.3.4', country_code: 'US', anonymityCode }],
+ }).cards[0].anonymity);
+ assert.deepEqual(kept, codes);
+ for (const legacy of ['yes', 'maybe', 'no', 'unknown']) {
+ const card = build({ cards: [{ source: 'IPCheck.ing', ip: '1.2.3.4', country_code: 'US', anonymityCode: legacy }] }).cards[0];
+ assert.equal('anonymity' in card, false, legacy);
+ }
});
it('connectivity keeps only settled targets and maps the enum', () => {
diff --git a/tests/report-schema.test.js b/tests/report-schema.test.js
index 0b9b2aa99..da26cefdb 100644
--- a/tests/report-schema.test.js
+++ b/tests/report-schema.test.js
@@ -29,7 +29,7 @@ const makeValidReport = () => ({
ipinfo: {
testedAt: '2026-07-14T08:00:00.000Z',
cards: [
- { source: 'IPCheck.ing IPv4', ip: '1.2.3.4', countryCode: 'US', region: 'California', city: 'Mountain View', timezone: 'America/Los_Angeles', asn: 'AS15169', isp: 'Google LLC', isProxy: 'no', ipType: 'residential', nativeIP: true, qualityScore: 85, proxyProtocol: 'SOCKS5', proxyProvider: 'ACME' },
+ { source: 'IPCheck.ing IPv4', ip: '1.2.3.4', countryCode: 'US', region: 'California', city: 'Mountain View', timezone: 'America/Los_Angeles', asn: 'AS15169', isp: 'Google LLC', anonymity: 'none', ipType: 'residential', nativeIP: true, qualityScore: 85, anonymityProtocol: 'SOCKS5', anonymityProvider: 'ACME' },
{ source: 'Cloudflare IPv6', ip: '2001:db8::1', countryCode: '', city: '' },
],
},
@@ -161,6 +161,13 @@ describe('validateReport', () => {
assert.match(result.errors[0], /report\.v/);
});
+ it('is on version 2 and rejects version-1 reports', () => {
+ assert.equal(REPORT_VERSION, 2);
+ const report = makeValidReport();
+ report.v = 1;
+ assert.equal(validateReport(report).ok, false);
+ });
+
it('rejects unknown envelope keys, unknown sections and unknown fields', () => {
const withEnvelopeKey = { ...makeValidReport(), smuggled: 'data' };
assert.match(validateReport(withEnvelopeKey).errors[0], /smuggled: unknown key/);
@@ -193,10 +200,24 @@ describe('validateReport', () => {
badEnum.sections.connectivity.targets[0].status = 'flaky';
assert.equal(validateReport(badEnum).ok, false);
+ for (const code of ['tor', 'relay', 'residential', 'suspected_residential', 'proxy', 'proxy_suspected', 'vpn', 'vpn_suspected', 'none']) {
+ const okProxyEnum = makeValidReport();
+ okProxyEnum.sections.ipinfo.cards[0].anonymity = code;
+ assert.equal(validateReport(okProxyEnum).ok, true, code);
+ }
+
const badProxyEnum = makeValidReport();
- badProxyEnum.sections.ipinfo.cards[0].isProxy = 'sign_in_required';
+ badProxyEnum.sections.ipinfo.cards[0].anonymity = 'sign_in_required';
assert.equal(validateReport(badProxyEnum).ok, false);
+ // v1 field names are unknown keys now.
+ const legacyField = makeValidReport();
+ legacyField.sections.ipinfo.cards[0].isProxy = 'no';
+ assert.equal(validateReport(legacyField).ok, false);
+ const legacyCode = makeValidReport();
+ legacyCode.sections.ipinfo.cards[0].anonymity = 'no';
+ assert.equal(validateReport(legacyCode).ok, false);
+
const badRange = makeValidReport();
badRange.sections.invisibility.scores.proxy = 101;
assert.equal(validateReport(badRange).ok, false);
diff --git a/tests/store.test.js b/tests/store.test.js
index 9faf04011..ac0090fe9 100644
--- a/tests/store.test.js
+++ b/tests/store.test.js
@@ -321,3 +321,37 @@ describe('store — handleSignInError', () => {
assert.equal(s.alert.alertStyle, 'text-danger');
});
});
+
+describe('store — quota snapshot', () => {
+ // The backend's /userinfo quota carries percentages only.
+ const snapshot = (features) => ({ quota: { isSponsor: false, features } });
+
+ it('quotaExceeded reads the exhausted flag, not the percentage', () => {
+ const s = useMainStore();
+ s.remoteUserInfo = snapshot({
+ ipinfo: { percent: 100, exhausted: true },
+ dns_leak_test: { percent: 99, exhausted: false },
+ });
+ assert.equal(s.quotaExceeded.ipinfo, true);
+ assert.equal(s.quotaExceeded.dns_leak_test, false);
+ // Absent features and a missing snapshot read as not exceeded.
+ assert.equal(s.quotaExceeded.persona_check, false);
+ s.remoteUserInfo = null;
+ assert.equal(s.quotaExceeded.ipinfo, false);
+ });
+
+ it('markQuotaExhausted pins a feature to exhausted after a 429', () => {
+ const s = useMainStore();
+ s.remoteUserInfo = snapshot({ invisibility_test: { percent: 42, exhausted: false } });
+ s.markQuotaExhausted('invisibility_test');
+ assert.deepEqual(s.remoteUserInfo.quota.features.invisibility_test, { percent: 100, exhausted: true });
+ assert.equal(s.quotaExceeded.invisibility_test, true);
+ });
+
+ it('markQuotaExhausted is a no-op without a snapshot for the feature', () => {
+ const s = useMainStore();
+ s.remoteUserInfo = snapshot({});
+ s.markQuotaExhausted('dns_leak_test');
+ assert.deepEqual(s.remoteUserInfo.quota.features, {});
+ });
+});
diff --git a/tests/time-utils.test.js b/tests/time-utils.test.js
index 1f7d96c21..af0f7fcf0 100644
--- a/tests/time-utils.test.js
+++ b/tests/time-utils.test.js
@@ -333,4 +333,16 @@ describe('formatIsoDate', () => {
assert.equal(formatIsoDate('', 'en'), '');
assert.equal(formatIsoDate(undefined, 'en'), '');
});
+
+ it('keeps locales apart across repeated (cached) calls', () => {
+ for (let i = 0; i < 3; i += 1) {
+ assert.equal(formatIsoDate('2020-11-06', 'en'), 'Nov 6, 2020');
+ assert.equal(formatIsoDate('2020-11-06', 'zh'), '2020年11月6日');
+ }
+ });
+
+ it('falls back to the raw string for a malformed locale', () => {
+ assert.equal(formatIsoDate('2020-11-06', 'not a locale!'), '2020-11-06');
+ assert.equal(formatIsoDate('2020-11-06', 'not a locale!'), '2020-11-06');
+ });
});
diff --git a/tests/transform-ip-data.test.js b/tests/transform-ip-data.test.js
index b58e8b32a..bcfc7248c 100644
--- a/tests/transform-ip-data.test.js
+++ b/tests/transform-ip-data.test.js
@@ -9,6 +9,14 @@ import {
// simple i18n translation stub: return key with prefix for assertions, to verify template references rather than hardcoded translation text
const t = (key) => `<${key}>`;
+// v3 tags with nothing flagged.
+const cleanTags = {
+ isBogon: false, isMobile: false, isResidential: false, isSatellite: false,
+ isCrawler: false, isBenignBot: false, isDatacenter: false, isTor: false,
+ isProxy: false, isVPN: false, isAnyAnonymizer: false, isRelay: false,
+ isResidentialProxy: false, isAbuser: false, isNative: true,
+};
+
const basicRaw = {
country_name: 'Japan',
country: 'JP',
@@ -94,35 +102,41 @@ describe('transformDataFromIPapi()', () => {
const withAdvanced = {
...basicRaw,
advancedData: {
- tags: { isProxyOrVPN: false, isNative: true },
+ tags: { ...cleanTags },
operatorType: 'Business',
score: 92,
- proxyProtocol: 'http',
- proxyProvider: 'Acme',
+ anonymityProtocol: 'http',
+ anonymityProvider: 'Acme',
+ dimensions: { anonymity: { class: null, sources: null, firstSeen: null, lastSeen: null } },
},
};
const out = transformDataFromIPapi(withAdvanced, 0, t, 'en');
- assert.equal(out.isProxy, '');
+ assert.equal(out.anonymity, '');
+ assert.equal(out.anonymityCode, 'none');
assert.equal(out.type, '');
assert.equal(out.qualityScore, 92);
- assert.equal(out.proxyProtocol, 'http');
- assert.equal(out.proxyProvider, 'Acme');
+ assert.equal(out.anonymityProtocol, 'http');
+ assert.equal(out.anonymityProvider, 'Acme');
assert.equal(out.isNativeIP, true);
});
});
describe('extractAdvancedData()', () => {
- it('propagates sign_in_required sentinel verbatim for type / qualityScore / isNativeIP / isProxy', () => {
+ it('propagates sign_in_required sentinel verbatim for type / qualityScore / isNativeIP / anonymity', () => {
const out = extractAdvancedData({
tags: 'sign_in_required',
score: 'sign_in_required',
operatorType: 'sign_in_required',
- proxyProtocol: 'http',
+ anonymityProvider: 'sign_in_required',
+ anonymityProtocol: 'sign_in_required',
}, t);
- assert.equal(out.isProxy, 'sign_in_required');
+ assert.equal(out.anonymity, 'sign_in_required');
assert.equal(out.type, 'sign_in_required');
assert.equal(out.qualityScore, 'sign_in_required');
assert.equal(out.isNativeIP, 'sign_in_required');
+ // Sentinels never pose as a service name.
+ assert.equal(out.anonymityProvider, null);
+ assert.equal(out.anonymityProtocol, null);
});
it('propagates quota_exceeded sentinel verbatim, and drops the report codes', () => {
@@ -130,42 +144,105 @@ describe('extractAdvancedData()', () => {
tags: 'quota_exceeded',
score: 'quota_exceeded',
operatorType: 'quota_exceeded',
- proxyProtocol: 'quota_exceeded',
+ anonymityProtocol: 'quota_exceeded',
}, t);
- assert.equal(out.isProxy, 'quota_exceeded');
+ assert.equal(out.anonymity, 'quota_exceeded');
assert.equal(out.type, 'quota_exceeded');
assert.equal(out.qualityScore, 'quota_exceeded');
assert.equal(out.isNativeIP, 'quota_exceeded');
// Locale-free report codes must be absent for gated data.
- assert.equal(out.proxyCode, undefined);
+ assert.equal(out.anonymityCode, undefined);
assert.equal(out.ipTypeCode, undefined);
});
- it("classifies isProxy='proxyYes' for VPN + known protocol", () => {
+ it('keeps provider / protocol null when the API named no service', () => {
const out = extractAdvancedData({
- tags: { isProxyOrVPN: true, isNative: false },
- operatorType: 'VPN',
- score: 10,
- proxyProtocol: 'http',
+ tags: { ...cleanTags }, operatorType: 'Residential', score: 80,
+ anonymityProvider: null, anonymityProtocol: null,
}, t);
- assert.equal(out.isProxy, '');
+ assert.equal(out.anonymityProvider, null);
+ assert.equal(out.anonymityProtocol, null);
+ const missing = extractAdvancedData({ tags: { ...cleanTags }, operatorType: 'Residential', score: 80 }, t);
+ assert.equal(missing.anonymityProvider, null);
});
- it("classifies isProxy='proxyMaybe' for VPN with unknown protocol", () => {
- const out = extractAdvancedData({
- tags: { isProxyOrVPN: true, isNative: false },
- operatorType: 'Hosting',
- score: 10,
- proxyProtocol: 'unknown',
- }, t);
- assert.equal(out.isProxy, ''); });
+ describe('anonymity verdict (display text + report code agree)', () => {
+ const verdict = (tags, anonymity) => {
+ const out = extractAdvancedData({
+ tags, operatorType: 'Hosting', score: 50, anonymityProtocol: 'socks5',
+ ...(anonymity !== undefined ? { dimensions: { anonymity } } : {}),
+ }, t);
+ return [out.anonymity, out.anonymityCode];
+ };
+ const key = (name) => ``;
+ const anon = (cls, sources = 'single') => ({ class: cls, sources, firstSeen: null, lastSeen: null });
+
+ it('Tor first, then iCloud Private Relay', () => {
+ assert.deepEqual(verdict({ ...cleanTags, isAnyAnonymizer: true, isTor: true, isProxy: true }, anon('tor', 'multi')),
+ [key('tor'), 'tor']);
+ assert.deepEqual(verdict({ ...cleanTags, isAnyAnonymizer: true, isRelay: true }, anon('relay')),
+ [key('relay'), 'relay']);
+ });
+
+ it('confirmed residential proxy', () => {
+ const tags = { ...cleanTags, isProxy: true, isAnyAnonymizer: true, isResidentialProxy: true };
+ assert.deepEqual(verdict(tags, anon('residential_proxy', 'multi')), [key('residential'), 'residential']);
+ });
+
+ it('proxy class: multi → proxy, otherwise possible proxy', () => {
+ const tags = { ...cleanTags, isProxy: true, isAnyAnonymizer: true };
+ assert.deepEqual(verdict(tags, anon('proxy', 'multi')), [key('proxy'), 'proxy']);
+ assert.deepEqual(verdict(tags, anon('proxy', 'single')), [key('proxySuspected'), 'proxy_suspected']);
+ assert.deepEqual(verdict(tags, anon('proxy', null)), [key('proxySuspected'), 'proxy_suspected']);
+ });
+
+ it('vpn class: multi → VPN, otherwise possible VPN (isProxy may be true too)', () => {
+ const tags = { ...cleanTags, isVPN: true, isProxy: true, isAnyAnonymizer: true };
+ assert.deepEqual(verdict(tags, anon('vpn', 'multi')), [key('vpn'), 'vpn']);
+ assert.deepEqual(verdict(tags, anon('vpn', 'single')), [key('vpnSuspected'), 'vpn_suspected']);
+ });
+
+ it('residential proxy only our sighting history reports → suspected', () => {
+ assert.deepEqual(verdict({ ...cleanTags, isResidentialProxy: true }, anon('residential_proxy')),
+ [key('suspectedResidential'), 'suspected_residential']);
+ });
+
+ it('nothing detected → none (also without dimensions)', () => {
+ assert.deepEqual(verdict(cleanTags, { class: null, sources: null }), [key('none'), 'none']);
+ assert.deepEqual(verdict(cleanTags), [key('none'), 'none']);
+ });
+
+ it('every code has a label in the en pack and is a report code', async () => {
+ const { ANONYMITY_I18N_KEYS } = await import('../frontend/utils/transform-ip-data.js');
+ const { ANONYMITY_CODES } = await import('../common/report-schema.js');
+ const fs = await import('node:fs');
+ const en = JSON.parse(fs.readFileSync(new URL('../frontend/locales/en.json', import.meta.url), 'utf8'));
+ const resolve = (path) => path.split('.').reduce((node, part) => node?.[part], en);
+ assert.deepEqual(Object.keys(ANONYMITY_I18N_KEYS).sort(), [...ANONYMITY_CODES].sort());
+ for (const code of ANONYMITY_CODES) {
+ assert.equal(typeof resolve(ANONYMITY_I18N_KEYS[code]), 'string', code);
+ }
+ assert.equal(typeof resolve('ipInfos.anonymity.label'), 'string');
+ });
+ });
- it('falls back proxyProtocol to empty string when missing (template decides display)', () => {
- const out = extractAdvancedData({
- tags: { isProxyOrVPN: false, isNative: true },
- operatorType: 'Residential',
- score: 80,
- }, t);
- assert.equal(out.proxyProtocol, '');
+ it('passes tags / dimensions / scoreVersion through for the score-details panel', () => {
+ const dimensions = {
+ reputation: { level: 'none', lastSeen: null },
+ anonymity: { class: null, sources: null, firstSeen: null, lastSeen: null },
+ network: { level: 'low', asn: 18144 },
+ };
+ const tags = { ...cleanTags };
+ const out = extractAdvancedData({ tags, operatorType: 'Business', score: 90, scoreVersion: 3, dimensions }, t);
+ assert.equal(out.scoreTags, tags);
+ assert.equal(out.scoreDimensions, dimensions);
+ assert.equal(out.scoreVersion, 3);
+ });
+
+ it('leaves the score-details fields undefined when gated', () => {
+ const gated = extractAdvancedData({ tags: 'quota_exceeded', score: 'quota_exceeded', operatorType: 'quota_exceeded' }, t);
+ assert.equal(gated.scoreTags, undefined);
+ assert.equal(gated.scoreDimensions, undefined);
+ assert.equal(gated.scoreVersion, undefined);
});
});
diff --git a/tests/world-map-chart.test.js b/tests/world-map-chart.test.js
new file mode 100644
index 000000000..3d89ed1e6
--- /dev/null
+++ b/tests/world-map-chart.test.js
@@ -0,0 +1,120 @@
+// Exercise the map's country joins, interaction and updates without a browser.
+import assert from 'node:assert/strict';
+import { after, test } from 'node:test';
+import { registerHooks } from 'node:module';
+
+const created = [];
+globalThis.__mapTestCharts = created;
+globalThis.document = { documentElement: {} };
+let theme = { '--info': '#38bdf8', '--border': '#eeeeee', '--foreground': '#111111', '--muted': '#f4f4f5' };
+globalThis.getComputedStyle = () => ({ getPropertyValue: (key) => theme[key] || '' });
+const stubs = {
+ 'chart.js/auto': `
+ export const registerables = [];
+ export class Chart {
+ static register() {}
+ constructor(canvas, config) {
+ this.canvas = canvas; this.data = config.data; this.options = config.options;
+ this.updates = 0; this.destroyed = false;
+ globalThis.__mapTestCharts.push(this);
+ }
+ update() { this.updates += 1; }
+ destroy() { this.destroyed = true; }
+ }
+ `,
+ 'chartjs-chart-geo': `
+ export const ChoroplethController = {}, GeoFeature = {}, ColorScale = {}, ProjectionScale = {};
+ export const topojson = { feature: () => ({ features: [
+ { id: '840', properties: { name: 'United States' } },
+ { id: '276', properties: { name: 'Germany' } },
+ { id: '010', properties: { name: 'Antarctica' } },
+ ] }) };
+ `,
+ 'world-atlas/countries-110m.json': 'export default { objects: { countries: {} } };',
+};
+const hook = registerHooks({
+ resolve(specifier, context, next) {
+ if (stubs[specifier]) return { url: `map-test:${specifier}`, shortCircuit: true };
+ return next(specifier, context);
+ },
+ load(url, context, next) {
+ if (url.startsWith('map-test:')) return { format: 'module', source: stubs[url.slice(9)], shortCircuit: true };
+ return next(url, context);
+ },
+});
+after(() => {
+ hook.deregister();
+ delete globalThis.__mapTestCharts;
+ delete globalThis.document;
+ delete globalThis.getComputedStyle;
+});
+
+const { renderWorldMapChart } = await import('../frontend/utils/world-map-chart.js');
+const options = () => ({
+ canvas: { style: {} }, values: { US: 1 }, lang: 'en',
+ colorFrom: '#000000', colorTo: '#ffffff', formatValue: (value) => `value:${value}`,
+});
+
+test('uniform countries ignore IP count, omit Antarctica and keep missing countries neutral', async () => {
+ const chart = await renderWorldMapChart({ ...options(), uniformColor: true, values: { US: 1, DE: 99 } });
+ assert.deepEqual(chart.data.datasets[0].backgroundColor, ['#38bdf8', '#38bdf8']);
+ assert.equal(chart.data.datasets[0].data.length, 2);
+ await renderWorldMapChart({ ...options(), canvas: chart.canvas, chart, uniformColor: true });
+ assert.deepEqual(chart.data.datasets[0].backgroundColor, ['#38bdf8', '#f4f4f5']);
+});
+
+test('click and pointer feedback apply only to recorded countries', async () => {
+ const selected = [];
+ const chart = await renderWorldMapChart({ ...options(), onCountryClick: (code) => selected.push(code), tooltipOnMissing: false });
+ const hit = (index) => [{ datasetIndex: 0, index }];
+ chart.options.onClick({}, hit(0), chart);
+ chart.options.onClick({}, hit(1), chart);
+ chart.options.onClick({}, [], chart);
+ assert.deepEqual(selected, ['US']);
+ chart.options.onHover({}, hit(0), chart);
+ assert.equal(chart.canvas.style.cursor, 'pointer');
+ chart.options.onHover({}, hit(1), chart);
+ assert.equal(chart.canvas.style.cursor, 'default');
+ assert.equal(chart.options.plugins.tooltip.filter({ raw: { value: 1 } }), true);
+ assert.equal(chart.options.plugins.tooltip.filter({ raw: {} }), false);
+});
+
+test('an update refreshes selection, theme, language and click callbacks on the same chart', async () => {
+ const chart = await renderWorldMapChart({ ...options(), uniformColor: true, selectedCountries: ['US'] });
+ assert.deepEqual(chart.data.datasets[0].borderWidth, [2, 0.5]);
+ theme = { ...theme, '--info': '#7dd3fc', '--foreground': '#ffffff' };
+ const clicks = [];
+ const updated = await renderWorldMapChart({
+ ...options(), canvas: chart.canvas, chart, uniformColor: true, values: { DE: 1 },
+ selectedCountries: ['DE'], lang: 'de', formatValue: () => 'updated',
+ onCountryClick: (code) => clicks.push(code),
+ });
+ assert.equal(updated, chart);
+ assert.deepEqual(chart.data.datasets[0].borderWidth, [0.5, 2]);
+ assert.equal(chart.data.datasets[0].borderColor[1], '#ffffff');
+ assert.equal(chart.data.datasets[0].backgroundColor[1], '#7dd3fc');
+ const tooltip = chart.options.plugins.tooltip.callbacks;
+ assert.equal(tooltip.title([{ raw: { feature: { id: '276' } } }]), 'Deutschland');
+ assert.equal(tooltip.label({ raw: { value: 1, feature: { id: '276' } } }), 'updated');
+ chart.options.onClick({}, [{ datasetIndex: 0, index: 1 }], chart);
+ assert.deepEqual(clicks, ['DE']);
+});
+
+test('heatmap callers retain the gradient and no click behavior', async () => {
+ const chart = await renderWorldMapChart(options());
+ assert.equal(chart.data.datasets[0].backgroundColor, undefined);
+ assert.equal(chart.options.scales.color.interpolate(0), 'rgb(0, 0, 0)');
+ assert.equal(chart.options.scales.color.interpolate(1), 'rgb(255, 255, 255)');
+ assert.equal(chart.options.onClick, undefined);
+ assert.equal(chart.options.onHover, undefined);
+});
+
+test('a stale lazy render does not construct a chart, and canvas replacement destroys the old one', async () => {
+ const count = created.length;
+ assert.equal(await renderWorldMapChart({ ...options(), isActive: () => false }), null);
+ assert.equal(created.length, count);
+ const chart = await renderWorldMapChart(options());
+ const replacement = await renderWorldMapChart({ ...options(), chart });
+ assert.notEqual(replacement, chart);
+ assert.equal(chart.destroyed, true);
+});
| |