Refactor(ip-infos): split IpDetailPanel into advanced and ASN blocks

IpDetailPanel had grown to ~750 lines holding three independent regions.
It is now the shell (metadata grid, inline Map / Country-traffic panels,
dialogs) with unchanged props, emits and multi-root template, rendering
IpAdvancedBlock (with ScoreDetailsPanel beside ScenarioFrictionPanel) and
IpAsnBlock, which relay view-usage / open-tool through it.

The inline /api/asn-history fetch becomes loadAsnHistoryInto in
utils/ip/asn-history.js, matching the other ASN loaders, now with tests.
Markup, copy, analytics and per-IP reset behaviour are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 5.5 committed 2026-10-25 11:19:36 +08:00
1 parent 825db1638f
commit 896494d895
12 files changed
+707 -549

No files matched your search

@@ -454,7 +454,7 @@ import { Separator } from '@/components/ui/separator';
import { Spinner } from '@/components/ui/spinner';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
// Heavy (dagre + SVG) — async, as IpDetailPanel loads it.
// Heavy (dagre + SVG) — async, as IpAsnBlock loads it.
const ASNConnectivity = defineAsyncComponent(() => import('@/components/ip-infos/ASNConnectivity.vue'));
const { t, locale } = useI18n();
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<!-- ASN History Panel: embedded in IpDetailPanel's Collapsible.
<!-- ASN History Panel: embedded in IpAsnBlock's Collapsible.
Lists every ASN that has announced (a prefix covering) this IP over
time, per RIPEstat routing-history. -->
<div class="rounded-md border bg-muted/40 text-sm">
@@ -0,0 +1,375 @@
<!-- Advanced block (IPCheck.ing source only): locked CTA + `***` preview when gated, otherwise the
label-value grid with the single-select score / friction details frame under it. One of
IpDetailPanel's root siblings; its own root carries the collapsed / source gates. -->
<template>
<div v-if="!collapsed" v-show="showAdvancedBlock" class="px-4 pb-3 border-t pt-3 space-y-2.5">
<!-- Gated: single CTA banner + 4-field preview grid with *** values.
Signed-out shows the sign-in copy; over-quota shows the monthly
quota copy + sponsor link instead. -->
<template v-if="allAdvancedLocked || allAdvancedQuotaExceeded">
<span
class="w-full flex items-center justify-between gap-2 text-xs px-2.5 py-1.5 rounded-md bg-muted/60 text-muted-foreground transition-colors group">
<span class="flex items-center gap-1.5 min-w-0">
<component :is="allAdvancedQuotaExceeded ? Hourglass : Lock" class="size-3.5 shrink-0" />
<span class="truncate">
{{ allAdvancedQuotaExceeded ? t('ipInfos.advancedQuotaCta') : t('ipInfos.advancedUnlockCta') }}
</span>
</span>
<!-- Opens the Benefits & Usage dialog (usage tab for signed-in
users) — the sponsor path lives there, not a direct jump. -->
<button v-if="allAdvancedQuotaExceeded" type="button"
class="shrink-0 underline underline-offset-2 hover:text-foreground cursor-pointer"
@click="openUsageDialog">
{{ t('user.ViewUsage') }}
</button>
</span>
<dl class="grid grid-cols-2 md:grid-cols-4 gap-x-3 gap-y-3 text-sm items-start">
<div v-for="f in lockedFieldList" :key="f.key">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<component :is="f.icon" class="size-3.5" />
<span>{{ f.label }}</span>
</dt>
<dd class="font-normal text-muted-foreground/60">***</dd>
</div>
</dl>
</template>
<!-- Signed-in: same vertical "label on top, value below" rhythm as the metadata grid,
plus the collapsible score / friction details panel under it (one child for space-y). -->
<div v-else>
<dl class="grid grid-cols-2 md:grid-cols-2 gap-x-3 gap-y-3 text-sm items-start">
<div v-if="showTypeBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<SignalHigh class="size-3.5" />
<span>{{ t('ipInfos.type') }}</span>
</dt>
<dd class="font-normal wrap-break-word">{{ data.type }}</dd>
</div>
<div v-if="showAnonymityBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ShieldCheck class="size-3.5" />
<span>{{ t('ipInfos.anonymity.label') }}</span>
</dt>
<dd class="font-normal wrap-break-word">
{{ data.anonymity }}
</dd>
<!-- Anonymizing service, when the API named one. -->
<dd v-if="anonymityService" class="font-normal wrap-break-word">
<span class="text-muted-foreground font-normal text-xs">{{ anonymityService }}</span>
</dd>
</div>
<div v-if="showNativeBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<House class="size-3.5" />
<span>{{ t('ipInfos.advancedData.Nativeness') }}</span>
<JnTooltip :text="t('ipInfos.advancedData.nativenessTooltip')" side="top" class="hidden md:block">
<CircleQuestionMark class="size-3 cursor-help opacity-70" />
</JnTooltip>
</dt>
<dd class="font-normal flex items-center gap-1 wrap-break-word">
<component :is="data.isNativeIP === true ? Equal : EqualNot" class="size-3.5 shrink-0" />
<span>{{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') :
t('ipInfos.advancedData.NativeIPNo') }}</span>
</dd>
</div>
<div v-if="showQualityScore">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Gauge class="size-3.5" />
<span>{{ t('ipInfos.qualityScore') }}</span>
</dt>
<dd class="flex items-center gap-2">
<span v-if="data.qualityScore === 'unknown'" class="font-normal text-muted-foreground">
{{ t('ipInfos.qualityScoreUnknown') }}
</span>
<template v-else>
<Progress :model-value="Number(data.qualityScore) || 0" class="h-2 flex-1 min-w-12"
:indicator-class="qualityTone === 'ok-fast' ? 'bg-success' : qualityTone === 'ok-slow' ? 'bg-warning' : 'bg-destructive'" />
<span class="text-sm font-normal tabular-nums shrink-0">{{ data.qualityScore }}/100</span>
</template>
<!-- Score-details toggle -->
<JnTooltip v-if="hasScoreDetails" :text="t('ipInfos.scoreDetails.toggle')" side="top">
<Button variant="ghost" size="icon"
:class="['size-6 -my-1 shrink-0 cursor-pointer', isDetailActive('score') && 'bg-muted text-foreground hover:bg-muted']"
@click="toggleDetail('score')" :aria-expanded="isDetailActive('score')"
:aria-label="t('ipInfos.scoreDetails.toggle')">
<ChevronDown class="size-3.5 transition-transform duration-200"
:class="isDetailActive('score') && 'rotate-180'" />
</Button>
</JnTooltip>
</dd>
</div>
<div v-if="abuseLevel">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Flag class="size-3.5" />
<span>{{ t('ipInfos.abuseLevel.label') }}</span>
</dt>
<dd class="font-normal wrap-break-word">{{ t('ipInfos.abuseLevel.' + abuseLevel) }}</dd>
</div>
<!-- Scenario friction: one tone dot per scenario, low → high (deliberately not a score
bar); hidden when the answer carries no scenarios. -->
<div v-if="hasFriction">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Compass class="size-3.5" />
<span>{{ t('ipInfos.scenarioFriction.label') }}</span>
</dt>
<!-- min-h-5 matches the quality-score row's text line. Dots + chevron form one
left-aligned group: 8px dots at a 4px gap (10 → 116px) that close up rather
than overflow a narrow cell; free space trails, like the text cells. -->
<dd class="flex min-h-5 items-center gap-2">
<div role="img" :aria-label="frictionBreakdown" :title="frictionBreakdown"
class="flex min-w-0 flex-nowrap items-center justify-between"
:style="{ width: `${frictionDotList.length * 12 - 4}px` }">
<span v-for="dot in frictionDotList" :key="dot.id"
class="size-2 shrink-0 rounded-full" :class="dotClass(dot.tone)" />
</div>
<JnTooltip :text="t('ipInfos.scenarioFriction.toggle')" side="top">
<Button variant="ghost" size="icon"
:class="['size-6 -my-1 shrink-0 cursor-pointer', isDetailActive('friction') && 'bg-muted text-foreground hover:bg-muted']"
@click="toggleDetail('friction')" :aria-expanded="isDetailActive('friction')"
:aria-label="t('ipInfos.scenarioFriction.toggle')">
<ChevronDown class="size-3.5 transition-transform duration-200"
:class="isDetailActive('friction') && 'rotate-180'" />
</Button>
</JnTooltip>
</dd>
</div>
</dl>
<!-- Details frame, one at a time. Score: plain-language explanation (needs the
`dimensions` block), then every attribute tag. Friction: the scenario list. -->
<Collapsible :open="isDetailOpen" @update:open="isDetailOpen = $event">
<CollapsibleContent>
<div class="pt-3">
<div class="rounded-md border bg-muted/40 p-3 space-y-3">
<template v-if="openDetail === 'score'">
<ScoreDetailsPanel :explanation="scoreExplanation" :tags="scoreTagRows" />
<!-- Point to a blocklist check of this IP. -->
<p v-if="blocklistHref"
class="border-t pt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-relaxed text-muted-foreground">
<span>{{ t('ipInfos.scoreDetails.blocklistHint') }}</span>
<a :href="blocklistHref" @click="openBlocklist"
class="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline">
{{ t('ipInfos.scoreDetails.blocklistLink') }}<ArrowRight class="size-3" />
</a>
</p>
</template>
<template v-else-if="openDetail === 'friction' && hasFriction">
<ScenarioFrictionPanel :scenarios="data.frictionScenarios"
:country-name="data.country_name || ''" />
<!-- Point to a Persona Check of this browser. -->
<p v-if="personacheckHref"
class="border-t pt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-relaxed text-muted-foreground">
<span>{{ t('ipInfos.scenarioFriction.personaHint') }}</span>
<a :href="personacheckHref" @click="openPersonacheck"
class="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline">
{{ t('ipInfos.scenarioFriction.personaLink') }}<ArrowRight class="size-3" />
</a>
</p>
</template>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { trackEvent } from '@/utils/analytics';
import { formatIsoDate } from '@/utils/time-utils.js';
import { buildScoreExplanation, listScoreTags } from '@/utils/ip/ip-score-details.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { frictionDots, frictionShares } from '@/utils/features/scenario-friction.js';
import ScenarioFrictionPanel from './ScenarioFrictionPanel.vue';
import ScoreDetailsPanel from './ScoreDetailsPanel.vue';
import { JnTooltip } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import {
ArrowRight,
ChevronDown,
CircleQuestionMark,
Compass,
Equal,
EqualNot,
Flag,
Gauge,
Hourglass,
House,
Lock,
ShieldCheck,
SignalHigh,
} from '@lucide/vue';
const { t, locale } = useI18n();
const store = useMainStore();
const props = defineProps({
data: { type: Object, required: true },
ipGeoSource: { type: Number, required: true },
configs: { type: Object, required: true },
collapsed: { type: Boolean, default: false },
});
// Re-emitted by IpDetailPanel to its consumer (see there).
const emit = defineEmits(['view-usage', 'open-tool']);
const openUsageDialog = () => {
emit('view-usage');
store.setTriggerUserBenefits(true);
};
// Advanced block only surfaces for the IPCheck.ing source (ipGeoSource === 0).
const showAdvancedBlock = computed(() => props.ipGeoSource === 0 && Boolean(props.data));
// Backend gating sentinels for the advanced fields (see transform-ip-data.js).
const isGatedValue = (value) => value === 'sign_in_required' || value === 'quota_exceeded';
// If every advanced field is masked → show the single CTA + preview grid instead
// of rendering four individual gated rows. The two sentinels pick different copy.
const allAdvancedLocked = computed(() =>
props.data.type === '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.anonymity === 'quota_exceeded' &&
props.data.isNativeIP === 'quota_exceeded' &&
props.data.qualityScore === 'quota_exceeded'
);
const showTypeBadge = computed(() =>
props.data.type && !isGatedValue(props.data.type)
&& props.data.type !== t('ipInfos.advancedData.type.unknownType')
);
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)
);
const showQualityScore = computed(() =>
props.data.qualityScore !== undefined && !isGatedValue(props.data.qualityScore)
);
// 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: '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') },
]);
// Quality Score color tiers — reuse the same 4-tone semantic as use-status-tone.
const qualityTone = computed(() => {
const n = Number(props.data.qualityScore);
if (isNaN(n)) return 'wait';
if (n >= 80) return 'ok-fast';
if (n >= 50) return 'ok-slow';
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));
const hasScoreDetails = computed(() =>
showAdvancedBlock.value && (scoreExplanation.value.length > 0 || scoreTagRows.value.length > 0)
);
// Abuse level from the v3 reputation dimension (`ipInfos.abuseLevel.*`); anything else hides the cell.
const ABUSE_LEVELS = ['none', 'low', 'medium', 'high'];
const abuseLevel = computed(() => {
const level = props.data.scoreDimensions?.reputation?.level;
return ABUSE_LEVELS.includes(level) ? level : '';
});
// Scenario friction: normalized scenarios (transform-ip-data.js), one dot each.
const { dotClass } = useStatusTone();
const hasFriction = computed(() =>
Array.isArray(props.data.frictionScenarios) && props.data.frictionScenarios.length > 0
);
const frictionList = computed(() => (hasFriction.value ? props.data.frictionScenarios : []));
const frictionCounts = computed(() => frictionShares(frictionList.value));
const frictionDotList = computed(() => frictionDots(frictionList.value));
const frictionBreakdown = computed(() => t('ipInfos.scenarioFriction.breakdown', {
smooth: frictionCounts.value.smooth,
checks: frictionCounts.value.checks,
restricted: frictionCounts.value.restricted,
}));
// Single-select details frame ('score' | 'friction'). Content is kept separate
// from the open state, so the close animation keeps its content.
const openDetail = ref(null);
const isDetailOpen = ref(false);
const isDetailActive = (name) => isDetailOpen.value && openDetail.value === name;
const toggleDetail = (name) => {
if (isDetailActive(name)) {
isDetailOpen.value = false;
return;
}
if (name === 'score') trackEvent('IPCheck', 'ScoreDetailsClick', 'Show Score Details');
else trackEvent('IPCheck', 'ScenarioFrictionClick', 'Show Scenario Friction');
openDetail.value = name;
isDetailOpen.value = true;
};
// A different IP starts collapsed.
watch(() => props.data.ip, () => { isDetailOpen.value = false; });
// A refresh without scenarios closes their frame instead of leaving it empty.
watch(hasFriction, (has) => { if (!has && openDetail.value === 'friction') isDetailOpen.value = false; });
// Blocklist Check entry: hidden when the tool is gated off; `q` only prefills.
const router = useRouter();
const blocklistHref = computed(() => (
isToolAvailable(TOOL_BY_SLUG.get('blocklist'), props.configs) && props.data.ip
? `/tools/blocklist?q=${encodeURIComponent(props.data.ip)}`
: ''
));
const openBlocklist = (e) => {
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'BlocklistCTAClick', 'Open Blocklist Check');
emit('open-tool');
router.push({ path: '/tools/blocklist', query: { q: props.data.ip } });
};
// Persona Check invitation closing the friction details: dropped when the tool is gated off.
const personacheckHref = computed(() => (
isToolAvailable(TOOL_BY_SLUG.get('personacheck'), props.configs) ? '/tools/personacheck' : ''
));
const openPersonacheck = (e) => {
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'PersonaCheckCTAClick', 'Open Persona Check');
emit('open-tool');
router.push({ path: '/tools/personacheck' });
};
</script>
+156
View File
@@ -0,0 +1,156 @@
<!-- ASN block: the ASN row with its Info / History / Connectivity toggles over one single-select
Collapsible. One of IpDetailPanel's root siblings; the caches are the consumer's, passed down. -->
<template>
<div v-if="data.asn && !collapsed" class="px-4 py-3 border-t">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 text-sm">
<Building2 class="size-4 text-muted-foreground shrink-0" />
<span class="text-xs text-muted-foreground shrink-0">{{ t('ipInfos.ASN') }}</span>
<span class="font-mono font-normal truncate">{{ data.asn }}</span>
</div>
<div class="shrink-0 flex items-center gap-1">
<!-- ASN Info -->
<JnTooltip v-if="data.asnlink && configs.cloudFlare" :text="t('Tooltips.ShowASNInfo')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('info') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('info')" :aria-expanded="isPanelActive('info')"
:aria-label="'Display AS Info of ' + data.asn">
<Info />
</Button>
</JnTooltip>
<!-- ASN History -->
<JnTooltip v-if="ipPrefix" :text="t('Tooltips.ShowASNHistory')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('history') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('history')" :aria-expanded="isPanelActive('history')"
:aria-label="'Display ASN History of ' + data.ip">
<FolderClock />
</Button>
</JnTooltip>
<!-- ASN Connectivity -->
<JnTooltip v-if="asnNumeric" :text="t('Tooltips.ShowASNConnectivity')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('connectivity') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('connectivity')" :aria-expanded="isPanelActive('connectivity')"
:aria-label="'Display ASN Connectivity of ' + data.asn">
<Network />
</Button>
</JnTooltip>
</div>
</div>
<Collapsible :open="isPanelOpen" @update:open="onPanelOpenChange">
<CollapsibleContent>
<div class="pt-3">
<ASNInfo v-if="activePanel === 'info'" :index="index" :isDarkMode="isDarkMode" :asn="data.asn"
:asnInfos="asnInfos" @open-profile="emit('open-tool')" />
<ASNHistory v-else-if="activePanel === 'history'" :prefix="ipPrefix"
:asnHistoryInfos="asnHistoryInfos" />
<ASNConnectivity v-else-if="activePanel === 'connectivity'" :asn="asnNumeric"
:asnConnectivityInfos="asnConnectivityInfos" />
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { loadAsnInfoInto } from '@/composables/use-asn-info.js';
import { loadAsnHistoryInto } from '@/utils/ip/asn-history.js';
import { loadAsnConnectivityInto } from '@/utils/ip/asn-connectivity.js';
import { toBgpPrefix } from '@/utils/ip/bgp-prefix.js';
import ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import { JnTooltip } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { Building2, FolderClock, Info, Network } from '@lucide/vue';
// ASNConnectivity is heavy (dagre + SVG render); async-import so it
// only enters the bundle when a user opens the Connectivity panel.
const ASNConnectivity = defineAsyncComponent(() => import('./ASNConnectivity.vue'));
const { t } = useI18n();
const props = defineProps({
data: { type: Object, required: true },
asnInfos: { type: Object, required: true },
asnHistoryInfos: { type: Object, default: () => ({}) },
asnConnectivityInfos: { type: Object, default: () => ({}) },
configs: { type: Object, required: true },
isDarkMode: { type: Boolean, required: true },
index: { type: Number, default: 0 },
collapsed: { type: Boolean, default: false },
});
// ASNInfo's ASN Profile link; re-emitted by IpDetailPanel to its consumer.
const emit = defineEmits(['open-tool']);
// Single-select panel content: 'info' | 'history' | 'connectivity' | null.
// Kept separate from the open state so close animations retain their content
// until Collapsible finishes measuring and animating the closing height.
const activePanel = ref(null);
const isPanelOpen = ref(false);
// BGP DFZ-floor prefix for the IP — /24 v4, /48 v6. Used both as the query
// param sent to /api/asn-history (so CF dedupes across every IP in the same
// prefix) and as the local session-cache key.
const ipPrefix = computed(() => toBgpPrefix(props.data.ip));
// Numeric ASN (no 'AS' prefix), used as the connectivity cache key and the
// /api/asn-connectivity query param. Null when the geo source didn't return
// an ASN.
const asnNumeric = computed(() => {
const raw = props.data.asn;
if (!raw) return null;
const m = String(raw).match(/^AS?(\d+)$/i);
return m ? m[1] : null;
});
// Toggle the panel for `name`. Clicking the already-active button collapses
// the panel entirely; clicking the inactive one switches view and lazily
// triggers its data fetch. Session caches (asnInfos / asnHistoryInfos) make
// the switch instant on the second visit.
const togglePanel = async (name) => {
if (isPanelOpen.value && activePanel.value === name) {
isPanelOpen.value = false;
return;
}
activePanel.value = name;
isPanelOpen.value = true;
if (name === 'info') {
await getASNInfo(props.data.asn);
} else if (name === 'history' && ipPrefix.value) {
await getASNHistory(ipPrefix.value);
} else if (name === 'connectivity' && asnNumeric.value) {
await getASNConnectivity(asnNumeric.value);
}
};
const isPanelActive = (name) => isPanelOpen.value && activePanel.value === name;
// Collapsible's controlled mode can emit open-state updates from its internals;
// mirror those without clearing the selected content before the close animation.
const onPanelOpenChange = (open) => {
isPanelOpen.value = open;
};
// Fetch + caching live in the loaders; each cache is the owner's (IpInfos /
// QueryIP), handed down as a prop.
const getASNInfo = async (asn) => {
trackEvent('IPCheck', 'ASNInfoClick', 'Show ASN Info');
await loadAsnInfoInto(props.asnInfos, asn);
};
const getASNHistory = async (prefix) => {
trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History');
await loadAsnHistoryInto(props.asnHistoryInfos, prefix);
};
const getASNConnectivity = async (asn) => {
trackEvent('IPCheck', 'ASNConnectivityClick', 'Show ASN Connectivity');
await loadAsnConnectivityInto(props.asnConnectivityInfos, asn);
};
</script>
+19 -543
View File
@@ -1,6 +1,6 @@
<template>
<!-- Multi-root template on purpose: Metadata / Advanced / ASN render as direct siblings of
the consumer's Hero IP inside a flex-col, so ASN's `mt-auto` pushes to the card bottom.
the consumer's Hero IP (IPCard: inside its body's flex-col), with no wrapper of their own.
Side-effect: `class` / `style` don't auto-fall through — don't write
`<IpDetailPanel class="..."/>`, the class goes nowhere. Style via the children's own classes. -->
@@ -92,270 +92,14 @@
</CollapsibleContent>
</Collapsible>
<!-- Advanced block (IPCheck.ing source only): locked CTA for signed-out, label-value grid for signed-in. -->
<div v-if="!collapsed" v-show="showAdvancedBlock" class="px-4 pb-3 border-t pt-3 space-y-2.5">
<!-- Advanced block (IPCheck.ing source only): gated CTA or the advanced grid + details. -->
<IpAdvancedBlock :data="data" :ip-geo-source="ipGeoSource" :configs="configs" :collapsed="collapsed"
@view-usage="emit('view-usage')" @open-tool="emit('open-tool')" />
<!-- Gated: single CTA banner + 4-field preview grid with *** values.
Signed-out shows the sign-in copy; over-quota shows the monthly
quota copy + sponsor link instead. -->
<template v-if="allAdvancedLocked || allAdvancedQuotaExceeded">
<span
class="w-full flex items-center justify-between gap-2 text-xs px-2.5 py-1.5 rounded-md bg-muted/60 text-muted-foreground transition-colors group">
<span class="flex items-center gap-1.5 min-w-0">
<component :is="allAdvancedQuotaExceeded ? Hourglass : Lock" class="size-3.5 shrink-0" />
<span class="truncate">
{{ allAdvancedQuotaExceeded ? t('ipInfos.advancedQuotaCta') : t('ipInfos.advancedUnlockCta') }}
</span>
</span>
<!-- Opens the Benefits & Usage dialog (usage tab for signed-in
users) — the sponsor path lives there, not a direct jump. -->
<button v-if="allAdvancedQuotaExceeded" type="button"
class="shrink-0 underline underline-offset-2 hover:text-foreground cursor-pointer"
@click="openUsageDialog">
{{ t('user.ViewUsage') }}
</button>
</span>
<dl class="grid grid-cols-2 md:grid-cols-4 gap-x-3 gap-y-3 text-sm items-start">
<div v-for="f in lockedFieldList" :key="f.key">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<component :is="f.icon" class="size-3.5" />
<span>{{ f.label }}</span>
</dt>
<dd class="font-normal text-muted-foreground/60">***</dd>
</div>
</dl>
</template>
<!-- Signed-in: same vertical "label on top, value below" rhythm as the metadata grid,
plus the collapsible score / friction details panel under it (one child for space-y). -->
<div v-else>
<dl class="grid grid-cols-2 md:grid-cols-2 gap-x-3 gap-y-3 text-sm items-start">
<div v-if="showTypeBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<SignalHigh class="size-3.5" />
<span>{{ t('ipInfos.type') }}</span>
</dt>
<dd class="font-normal wrap-break-word">{{ data.type }}</dd>
</div>
<div v-if="showAnonymityBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ShieldCheck class="size-3.5" />
<span>{{ t('ipInfos.anonymity.label') }}</span>
</dt>
<dd class="font-normal wrap-break-word">
{{ data.anonymity }}
</dd>
<!-- Anonymizing service, when the API named one. -->
<dd v-if="anonymityService" class="font-normal wrap-break-word">
<span class="text-muted-foreground font-normal text-xs">{{ anonymityService }}</span>
</dd>
</div>
<div v-if="showNativeBadge">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<House class="size-3.5" />
<span>{{ t('ipInfos.advancedData.Nativeness') }}</span>
<JnTooltip :text="t('ipInfos.advancedData.nativenessTooltip')" side="top" class="hidden md:block">
<CircleQuestionMark class="size-3 cursor-help opacity-70" />
</JnTooltip>
</dt>
<dd class="font-normal flex items-center gap-1 wrap-break-word">
<component :is="data.isNativeIP === true ? Equal : EqualNot" class="size-3.5 shrink-0" />
<span>{{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') :
t('ipInfos.advancedData.NativeIPNo') }}</span>
</dd>
</div>
<div v-if="showQualityScore">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Gauge class="size-3.5" />
<span>{{ t('ipInfos.qualityScore') }}</span>
</dt>
<dd class="flex items-center gap-2">
<span v-if="data.qualityScore === 'unknown'" class="font-normal text-muted-foreground">
{{ t('ipInfos.qualityScoreUnknown') }}
</span>
<template v-else>
<Progress :model-value="Number(data.qualityScore) || 0" class="h-2 flex-1 min-w-12"
:indicator-class="qualityTone === 'ok-fast' ? 'bg-success' : qualityTone === 'ok-slow' ? 'bg-warning' : 'bg-destructive'" />
<span class="text-sm font-normal tabular-nums shrink-0">{{ data.qualityScore }}/100</span>
</template>
<!-- Score-details toggle -->
<JnTooltip v-if="hasScoreDetails" :text="t('ipInfos.scoreDetails.toggle')" side="top">
<Button variant="ghost" size="icon"
:class="['size-6 -my-1 shrink-0 cursor-pointer', isDetailActive('score') && 'bg-muted text-foreground hover:bg-muted']"
@click="toggleDetail('score')" :aria-expanded="isDetailActive('score')"
:aria-label="t('ipInfos.scoreDetails.toggle')">
<ChevronDown class="size-3.5 transition-transform duration-200"
:class="isDetailActive('score') && 'rotate-180'" />
</Button>
</JnTooltip>
</dd>
</div>
<div v-if="abuseLevel">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Flag class="size-3.5" />
<span>{{ t('ipInfos.abuseLevel.label') }}</span>
</dt>
<dd class="font-normal wrap-break-word">{{ t('ipInfos.abuseLevel.' + abuseLevel) }}</dd>
</div>
<!-- Scenario friction: one tone dot per scenario, low → high (deliberately not a score
bar); hidden when the answer carries no scenarios. -->
<div v-if="hasFriction">
<dt class="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Compass class="size-3.5" />
<span>{{ t('ipInfos.scenarioFriction.label') }}</span>
</dt>
<!-- min-h-5 matches the quality-score row's text line. Dots + chevron form one
left-aligned group: 8px dots at a 4px gap (10 → 116px) that close up rather
than overflow a narrow cell; free space trails, like the text cells. -->
<dd class="flex min-h-5 items-center gap-2">
<div role="img" :aria-label="frictionBreakdown" :title="frictionBreakdown"
class="flex min-w-0 flex-nowrap items-center justify-between"
:style="{ width: `${frictionDotList.length * 12 - 4}px` }">
<span v-for="dot in frictionDotList" :key="dot.id"
class="size-2 shrink-0 rounded-full" :class="dotClass(dot.tone)" />
</div>
<JnTooltip :text="t('ipInfos.scenarioFriction.toggle')" side="top">
<Button variant="ghost" size="icon"
:class="['size-6 -my-1 shrink-0 cursor-pointer', isDetailActive('friction') && 'bg-muted text-foreground hover:bg-muted']"
@click="toggleDetail('friction')" :aria-expanded="isDetailActive('friction')"
:aria-label="t('ipInfos.scenarioFriction.toggle')">
<ChevronDown class="size-3.5 transition-transform duration-200"
:class="isDetailActive('friction') && 'rotate-180'" />
</Button>
</JnTooltip>
</dd>
</div>
</dl>
<!-- Details frame, one at a time. Score: plain-language explanation (needs the
`dimensions` block), then every attribute tag. Friction: the scenario list. -->
<Collapsible :open="isDetailOpen" @update:open="isDetailOpen = $event">
<CollapsibleContent>
<div class="pt-3">
<div class="rounded-md border bg-muted/40 p-3 space-y-3">
<template v-if="openDetail === 'score'">
<div v-if="scoreExplanation.length">
<p class="text-xs text-muted-foreground mb-1.5">{{ t('ipInfos.scoreDetails.explainTitle') }}</p>
<!-- One i18n template per sentence; slots keep the IP under the info-mask blur. -->
<p class="text-xs leading-relaxed text-muted-foreground">
<template v-for="(sentence, i) in scoreExplanation" :key="sentence.key">
<I18nT :keypath="sentence.key" tag="span" scope="global">
<template #ip><span data-mask="ip" class="font-medium text-foreground">{{ sentence.params.ip }}</span></template>
<template #operator><span class="text-foreground">{{ sentence.params.operator }}</span></template>
<template #score><span class="font-medium text-foreground tabular-nums">{{ sentence.params.score }}</span></template>
<template #date>{{ sentence.params.date }}</template>
</I18nT>{{ i === scoreExplanation.length - 1 ? '' : sentenceGap }}
</template>
</p>
</div>
<!-- All attributes: label + check / cross / question mark. -->
<div v-if="scoreTagRows.length" :class="scoreExplanation.length && 'border-t pt-3'">
<p class="text-xs text-muted-foreground mb-1.5">{{ t('ipInfos.scoreDetails.tagsTitle') }}</p>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div v-for="tag in scoreTagRows" :key="tag.key"
class="flex items-center justify-between gap-2 min-w-0">
<dt class="text-muted-foreground min-w-0 wrap-break-word">
{{ t('ipInfos.scoreDetails.tags.' + tag.key) }}
</dt>
<!-- One neutral tone for every state: a tag is a fact, not a verdict. -->
<dd class="shrink-0 text-foreground">
<component :is="TAG_STATE_ICONS[tag.state]" class="size-3.5" aria-hidden="true" />
<span class="sr-only">{{ t('ipInfos.scoreDetails.tagState.' + tag.state) }}</span>
</dd>
</div>
</dl>
</div>
<!-- Point to a blocklist check of this IP. -->
<p v-if="blocklistHref"
class="border-t pt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-relaxed text-muted-foreground">
<span>{{ t('ipInfos.scoreDetails.blocklistHint') }}</span>
<a :href="blocklistHref" @click="openBlocklist"
class="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline">
{{ t('ipInfos.scoreDetails.blocklistLink') }}<ArrowRight class="size-3" />
</a>
</p>
</template>
<template v-else-if="openDetail === 'friction' && hasFriction">
<ScenarioFrictionPanel :scenarios="data.frictionScenarios"
:country-name="data.country_name || ''" />
<!-- Point to a Persona Check of this browser. -->
<p v-if="personacheckHref"
class="border-t pt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-relaxed text-muted-foreground">
<span>{{ t('ipInfos.scenarioFriction.personaHint') }}</span>
<a :href="personacheckHref" @click="openPersonacheck"
class="inline-flex items-center gap-0.5 font-medium text-foreground underline-offset-2 hover:underline">
{{ t('ipInfos.scenarioFriction.personaLink') }}<ArrowRight class="size-3" />
</a>
</p>
</template>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<!-- ASN block -->
<div v-if="data.asn && !collapsed" class="px-4 py-3 border-t">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 text-sm">
<Building2 class="size-4 text-muted-foreground shrink-0" />
<span class="text-xs text-muted-foreground shrink-0">{{ t('ipInfos.ASN') }}</span>
<span class="font-mono font-normal truncate">{{ data.asn }}</span>
</div>
<div class="shrink-0 flex items-center gap-1">
<!-- ASN Info -->
<JnTooltip v-if="data.asnlink && configs.cloudFlare" :text="t('Tooltips.ShowASNInfo')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('info') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('info')" :aria-expanded="isPanelActive('info')"
:aria-label="'Display AS Info of ' + data.asn">
<Info />
</Button>
</JnTooltip>
<!-- ASN History -->
<JnTooltip v-if="ipPrefix" :text="t('Tooltips.ShowASNHistory')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('history') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('history')" :aria-expanded="isPanelActive('history')"
:aria-label="'Display ASN History of ' + data.ip">
<FolderClock />
</Button>
</JnTooltip>
<!-- ASN Connectivity -->
<JnTooltip v-if="asnNumeric" :text="t('Tooltips.ShowASNConnectivity')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', isPanelActive('connectivity') && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('connectivity')" :aria-expanded="isPanelActive('connectivity')"
:aria-label="'Display ASN Connectivity of ' + data.asn">
<Network />
</Button>
</JnTooltip>
</div>
</div>
<Collapsible :open="isPanelOpen" @update:open="onPanelOpenChange">
<CollapsibleContent>
<div class="pt-3">
<ASNInfo v-if="activePanel === 'info'" :index="index" :isDarkMode="isDarkMode" :asn="data.asn"
:asnInfos="asnInfos" @open-profile="emit('open-tool')" />
<ASNHistory v-else-if="activePanel === 'history'" :prefix="ipPrefix"
:asnHistoryInfos="asnHistoryInfos" />
<ASNConnectivity v-else-if="activePanel === 'connectivity'" :asn="asnNumeric"
:asnConnectivityInfos="asnConnectivityInfos" />
</div>
</CollapsibleContent>
</Collapsible>
</div>
<!-- ASN block: ASN row + Info / History / Connectivity panels. -->
<IpAsnBlock :data="data" :index="index" :asn-infos="asnInfos" :asn-history-infos="asnHistoryInfos"
:asn-connectivity-infos="asnConnectivityInfos" :configs="configs" :is-dark-mode="isDarkMode"
:collapsed="collapsed" @open-tool="emit('open-tool')" />
<!-- Dialog-mode shells for the same two panels (IPCard). -->
<CountryTraffic v-if="countryTrafficMode === 'dialog'" :open="isTrafficDialogOpen"
@@ -367,73 +111,35 @@
</template>
<script setup>
// Shared display panel for IP info: Metadata grid + Advanced block + ASN row, plus the Map and
// Country-traffic panels (per-consumer 'dialog' / 'inline' / 'off' via mapMode / countryTrafficMode).
// 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, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n, I18nT } from 'vue-i18n';
import { useRouter } from 'vue-router';
// Shared display panel for IP info: Metadata grid + Advanced block (IpAdvancedBlock) + ASN row
// (IpAsnBlock), plus the Map and Country-traffic panels (per-consumer 'dialog' / 'inline' / 'off'
// via mapMode / countryTrafficMode). 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 { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { loadAsnInfoInto } from '@/composables/use-asn-info.js';
import { loadAsnConnectivityInto } from '@/utils/ip/asn-connectivity.js';
import { toBgpPrefix } from '@/utils/ip/bgp-prefix.js';
import { getZoneUtcOffset, getZoneLocalTime, formatIsoDate } from '@/utils/time-utils.js';
import { buildScoreExplanation, listScoreTags } from '@/utils/ip/ip-score-details.js';
import { isToolAvailable } from '@/utils/tool-availability.js';
import { TOOL_BY_SLUG } from '@/data/tools.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { frictionDots, frictionShares } from '@/utils/features/scenario-friction.js';
import ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import { getZoneUtcOffset, getZoneLocalTime } from '@/utils/time-utils.js';
import CountryTraffic from './CountryTraffic.vue';
import CountryTrafficPanel from './CountryTrafficPanel.vue';
import LocationMap from './LocationMap.vue';
import LocationMapPanel from './LocationMapPanel.vue';
import ScenarioFrictionPanel from './ScenarioFrictionPanel.vue';
// ASNConnectivity is heavy (dagre + SVG render); async-import so it
// only enters the bundle when a user opens the Connectivity panel.
import { defineAsyncComponent } from 'vue';
const ASNConnectivity = defineAsyncComponent(() => import('./ASNConnectivity.vue'));
import IpAdvancedBlock from './IpAdvancedBlock.vue';
import IpAsnBlock from './IpAsnBlock.vue';
import { JnTooltip } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Icon } from '@iconify/vue';
import {
Activity,
ArrowRight,
Building2,
Check,
ChevronDown,
CornerUpRight,
Equal,
EqualNot,
EthernetPort,
Gauge,
Hourglass,
House,
Info,
Network,
Lock,
Map,
MapPin,
Compass,
ShieldCheck,
SignalHigh,
Flag,
X,
CircleQuestionMark,
Clock,
FolderClock,
} from '@lucide/vue';
const { t, locale } = useI18n();
const store = useMainStore();
const props = defineProps({
data: { type: Object, required: true },
@@ -461,16 +167,6 @@ const props = defineProps({
// dialog stacks on it (`view-usage`) or a tool link (ASN Profile, Blocklist Check, Persona Check) changes page (`open-tool`).
const emit = defineEmits(['view-usage', 'open-tool']);
const openUsageDialog = () => {
emit('view-usage');
store.setTriggerUserBenefits(true);
};
// Single-select panel content for the ASN block: 'info' | 'history' | null.
// Kept separate from the open state so close animations retain their content
// until Collapsible finishes measuring and animating the closing height.
const activePanel = ref(null);
const isPanelOpen = ref(false);
const isMapDialogOpen = ref(false);
const isTrafficDialogOpen = ref(false);
@@ -484,151 +180,11 @@ const refreshZoneLocalTime = () => {
zoneLocalTime.value = getZoneLocalTime(props.data.timezone, locale.value);
};
// Advanced block only surfaces for the IPCheck.ing source (ipGeoSource === 0).
const showAdvancedBlock = computed(() => props.ipGeoSource === 0 && Boolean(props.data));
// Map button is gated on the deployment having a Google Maps key (configs.map) + location data.
const canShowMap = computed(() =>
props.mapMode !== 'off' && Boolean(props.configs.map) && Boolean(props.data.country_name)
);
// Backend gating sentinels for the advanced fields (see transform-ip-data.js).
const isGatedValue = (value) => value === 'sign_in_required' || value === 'quota_exceeded';
// If every advanced field is masked → show the single CTA + preview grid instead
// of rendering four individual gated rows. The two sentinels pick different copy.
const allAdvancedLocked = computed(() =>
props.data.type === '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.anonymity === 'quota_exceeded' &&
props.data.isNativeIP === 'quota_exceeded' &&
props.data.qualityScore === 'quota_exceeded'
);
const showTypeBadge = computed(() =>
props.data.type && !isGatedValue(props.data.type)
&& props.data.type !== t('ipInfos.advancedData.type.unknownType')
);
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)
);
const showQualityScore = computed(() =>
props.data.qualityScore !== undefined && !isGatedValue(props.data.qualityScore)
);
// 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: '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') },
]);
// Quality Score color tiers — reuse the same 4-tone semantic as use-status-tone.
const qualityTone = computed(() => {
const n = Number(props.data.qualityScore);
if (isNaN(n)) return 'wait';
if (n >= 80) return 'ok-fast';
if (n >= 50) return 'ok-slow';
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) ? '' : ' '));
// Abuse level from the v3 reputation dimension (`ipInfos.abuseLevel.*`); anything else hides the cell.
const ABUSE_LEVELS = ['none', 'low', 'medium', 'high'];
const abuseLevel = computed(() => {
const level = props.data.scoreDimensions?.reputation?.level;
return ABUSE_LEVELS.includes(level) ? level : '';
});
// Scenario friction: normalized scenarios (transform-ip-data.js), one dot each.
const { dotClass } = useStatusTone();
const hasFriction = computed(() =>
Array.isArray(props.data.frictionScenarios) && props.data.frictionScenarios.length > 0
);
const frictionList = computed(() => (hasFriction.value ? props.data.frictionScenarios : []));
const frictionCounts = computed(() => frictionShares(frictionList.value));
const frictionDotList = computed(() => frictionDots(frictionList.value));
const frictionBreakdown = computed(() => t('ipInfos.scenarioFriction.breakdown', {
smooth: frictionCounts.value.smooth,
checks: frictionCounts.value.checks,
restricted: frictionCounts.value.restricted,
}));
// Single-select details frame ('score' | 'friction') — same open/content split as
// the ASN block, so the close animation keeps its content.
const openDetail = ref(null);
const isDetailOpen = ref(false);
const isDetailActive = (name) => isDetailOpen.value && openDetail.value === name;
const toggleDetail = (name) => {
if (isDetailActive(name)) {
isDetailOpen.value = false;
return;
}
if (name === 'score') trackEvent('IPCheck', 'ScoreDetailsClick', 'Show Score Details');
else trackEvent('IPCheck', 'ScenarioFrictionClick', 'Show Scenario Friction');
openDetail.value = name;
isDetailOpen.value = true;
};
// A different IP starts collapsed.
watch(() => props.data.ip, () => { isDetailOpen.value = false; });
// A refresh without scenarios closes their frame instead of leaving it empty.
watch(hasFriction, (has) => { if (!has && openDetail.value === 'friction') isDetailOpen.value = false; });
// Blocklist Check entry: hidden when the tool is gated off; `q` only prefills.
const router = useRouter();
const blocklistHref = computed(() => (
isToolAvailable(TOOL_BY_SLUG.get('blocklist'), props.configs) && props.data.ip
? `/tools/blocklist?q=${encodeURIComponent(props.data.ip)}`
: ''
));
const openBlocklist = (e) => {
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'BlocklistCTAClick', 'Open Blocklist Check');
emit('open-tool');
router.push({ path: '/tools/blocklist', query: { q: props.data.ip } });
};
// Persona Check invitation closing the friction details: dropped when the tool is gated off.
const personacheckHref = computed(() => (
isToolAvailable(TOOL_BY_SLUG.get('personacheck'), props.configs) ? '/tools/personacheck' : ''
));
const openPersonacheck = (e) => {
if (!isPlainClick(e)) return;
e.preventDefault();
trackEvent('IPCheck', 'PersonaCheckCTAClick', 'Open Persona Check');
emit('open-tool');
router.push({ path: '/tools/personacheck' });
};
// Consumer opt-in + a Cloudflare key on the deployment + a country to query.
const canShowCountryTraffic = computed(() =>
props.countryTrafficMode !== 'off' && !props.collapsed
@@ -636,7 +192,7 @@ const canShowCountryTraffic = computed(() =>
);
// Single-select inline expansion under the metadata grid ('inline' mode) —
// same open/content split as the ASN block below, so close animations keep
// same open/content split as IpAsnBlock's panels, so close animations keep
// their content.
const activeMetaPanel = ref(null);
const isMetaPanelOpen = ref(false);
@@ -664,84 +220,4 @@ const onTrafficClick = () => {
if (props.countryTrafficMode === 'inline') toggleMetaPanel('traffic');
else isTrafficDialogOpen.value = true;
};
// BGP DFZ-floor prefix for the IP — /24 v4, /48 v6. Used both as the query
// param sent to /api/asn-history (so CF dedupes across every IP in the same
// prefix) and as the local session-cache key.
const ipPrefix = computed(() => toBgpPrefix(props.data.ip));
// Numeric ASN (no 'AS' prefix), used as the connectivity cache key and the
// /api/asn-connectivity query param. Null when the geo source didn't return
// an ASN.
const asnNumeric = computed(() => {
const raw = props.data.asn;
if (!raw) return null;
const m = String(raw).match(/^AS?(\d+)$/i);
return m ? m[1] : null;
});
// Toggle the panel for `name`. Clicking the already-active button collapses
// the panel entirely; clicking the inactive one switches view and lazily
// triggers its data fetch. Session caches (asnInfos / asnHistoryInfos) make
// the switch instant on the second visit.
const togglePanel = async (name) => {
if (isPanelOpen.value && activePanel.value === name) {
isPanelOpen.value = false;
return;
}
activePanel.value = name;
isPanelOpen.value = true;
if (name === 'info') {
await getASNInfo(props.data.asn);
} else if (name === 'history' && ipPrefix.value) {
await getASNHistory(ipPrefix.value);
} else if (name === 'connectivity' && asnNumeric.value) {
await getASNConnectivity(asnNumeric.value);
}
};
const isPanelActive = (name) => isPanelOpen.value && activePanel.value === name;
// Collapsible's controlled mode can emit open-state updates from its internals;
// mirror those without clearing the selected content before the close animation.
const onPanelOpenChange = (open) => {
isPanelOpen.value = open;
};
// Fetch + versioning live in composables/use-asn-info.js; the cache is the
// owner's (IpInfos / QueryIP), handed down as the asnInfos prop.
const getASNInfo = async (asn) => {
trackEvent('IPCheck', 'ASNInfoClick', 'Show ASN Info');
await loadAsnInfoInto(props.asnInfos, asn);
};
const getASNHistory = async (prefix) => {
trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History');
try {
if (props.asnHistoryInfos[prefix]) return;
// RIPEstat routing-history is a slow analytical endpoint (10–20s on
// cold prefixes). Backend caps it at 25s; browser waits 26s so the
// server's 504 surfaces instead of the browser aborting first.
const response = await fetchWithTimeout(
`/api/asn-history?prefix=${encodeURIComponent(prefix)}`,
{ timeoutMs: 26000 }
);
if (!response.ok) {
props.asnHistoryInfos[prefix] = { error: true };
return;
}
const data = await response.json();
props.asnHistoryInfos[prefix] = data;
} catch (error) {
console.error('Error fetching ASN history:', error);
props.asnHistoryInfos[prefix] = { error: true };
}
};
// Fetch + versioning live in utils/ip/asn-connectivity.js; the cache is the
// owner's, handed down as asnConnectivityInfos.
const getASNConnectivity = async (asn) => {
trackEvent('IPCheck', 'ASNConnectivityClick', 'Show ASN Connectivity');
await loadAsnConnectivityInto(props.asnConnectivityInfos, asn);
};
</script>
@@ -1,6 +1,6 @@
<!-- Scenario friction details: how it was estimated, then one row per scenario in the card
dots' order, smoothest first (name + tone dot + verdict). Root blocks sit directly in the
IpDetailPanel details frame, which spaces them like the score details and draws the border
IpAdvancedBlock details frame, which spaces them like the score details and draws the border
and the closing Persona Check line. -->
<template>
<div>
@@ -0,0 +1,56 @@
<!-- Quality-score details: the plain-language explanation (needs the `dimensions` block), then
every attribute tag. Root blocks sit directly in the IpAdvancedBlock details frame, which
spaces them like the friction details and draws the closing Blocklist Check line. -->
<template>
<div v-if="explanation.length">
<p class="text-xs text-muted-foreground mb-1.5">{{ t('ipInfos.scoreDetails.explainTitle') }}</p>
<!-- One i18n template per sentence; slots keep the IP under the info-mask blur. -->
<p class="text-xs leading-relaxed text-muted-foreground">
<template v-for="(sentence, i) in explanation" :key="sentence.key">
<I18nT :keypath="sentence.key" tag="span" scope="global">
<template #ip><span data-mask="ip" class="font-medium text-foreground">{{ sentence.params.ip }}</span></template>
<template #operator><span class="text-foreground">{{ sentence.params.operator }}</span></template>
<template #score><span class="font-medium text-foreground tabular-nums">{{ sentence.params.score }}</span></template>
<template #date>{{ sentence.params.date }}</template>
</I18nT>{{ i === explanation.length - 1 ? '' : sentenceGap }}
</template>
</p>
</div>
<!-- All attributes: label + check / cross / question mark. -->
<div v-if="tags.length" :class="explanation.length && 'border-t pt-3'">
<p class="text-xs text-muted-foreground mb-1.5">{{ t('ipInfos.scoreDetails.tagsTitle') }}</p>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div v-for="tag in tags" :key="tag.key"
class="flex items-center justify-between gap-2 min-w-0">
<dt class="text-muted-foreground min-w-0 wrap-break-word">
{{ t('ipInfos.scoreDetails.tags.' + tag.key) }}
</dt>
<!-- One neutral tone for every state: a tag is a fact, not a verdict. -->
<dd class="shrink-0 text-foreground">
<component :is="TAG_STATE_ICONS[tag.state]" class="size-3.5" aria-hidden="true" />
<span class="sr-only">{{ t('ipInfos.scoreDetails.tagState.' + tag.state) }}</span>
</dd>
</div>
</dl>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { Check, CircleQuestionMark, X } from '@lucide/vue';
defineProps({
// buildScoreExplanation() sentences and listScoreTags() rows (utils/ip/ip-score-details.js).
explanation: { type: Array, required: true },
tags: { type: Array, required: true },
});
const { t, locale } = useI18n();
// Row state → icon; the question mark marks a suspected residential proxy.
const TAG_STATE_ICONS = { yes: Check, no: X, suspected: CircleQuestionMark };
// CJK sentences run together; others take a space.
const sentenceGap = computed(() => (/^(zh|ja|ko)/.test(locale.value) ? '' : ' '));
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
// ASN summary from Cloudflare Radar (`/api/cfradar?view=asn`) with a
// per-owner session cache keyed 'AS<n>'. Owners (IpInfos, QueryIP) each
// create their own cache with useAsnInfo(); IpDetailPanel fills whichever
// create their own cache with useAsnInfo(); IpAsnBlock fills whichever
// cache it is handed through loadAsnInfoInto().
//
// Relative imports keep this file importable from the Node test runner.
+1 -1
View File
@@ -1,5 +1,5 @@
// Client for `/api/asn-connectivity` (CAIDA topology + neighbour lists),
// used by IpDetailPanel. Fills an owner's session cache keyed by the numeric ASN
// used by IpAsnBlock. Fills an owner's session cache keyed by the numeric ASN
// string: `{ graph }` on success, `{ error: true }` on failure — the shape
// ASNConnectivity.vue reads.
//
+27
View File
@@ -0,0 +1,27 @@
// Client for `/api/asn-history` (RIPEstat routing history per BGP prefix),
// used by IpAsnBlock. Fills an owner's session cache keyed by the prefix:
// the response body on success, `{ error: true }` on failure — the shape
// ASNHistory.vue reads.
//
// Relative imports keep this file importable from the Node test runner.
import { fetchWithTimeout } from '../fetch-with-timeout.js';
export const asnHistoryUrl = (prefix) => `/api/asn-history?prefix=${encodeURIComponent(prefix)}`;
// Fetch `prefix` ('1.1.1.0/24') into `cache` unless already there; resolves
// to the cache entry. Non-2xx answers and network failures are cached as
// `{ error: true }`.
export const loadAsnHistoryInto = async (cache, prefix) => {
try {
if (cache[prefix]) return cache[prefix];
// RIPEstat routing-history is a slow analytical endpoint (10–20s on
// cold prefixes). Backend caps it at 25s; browser waits 26s so the
// server's 504 surfaces instead of the browser aborting first.
const response = await fetchWithTimeout(asnHistoryUrl(prefix), { timeoutMs: 26000 });
cache[prefix] = response.ok ? await response.json() : { error: true };
} catch (error) {
console.error('Error fetching ASN history:', error);
cache[prefix] = { error: true };
}
return cache[prefix];
};
+1 -1
View File
@@ -1,4 +1,4 @@
// Score-details panel for IPCheck.ing results (IpDetailPanel): a plain-
// Score-details panel for IPCheck.ing results (ScoreDetailsPanel): 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.
//
+68
View File
@@ -0,0 +1,68 @@
// Tests for frontend/utils/ip/asn-history.js: the request URL, cache hits
// skipping the network, and the body / `{ error: true }` entry shapes
// ASNHistory.vue reads. globalThis.fetch is stubbed per test.
import assert from 'node:assert/strict';
import { describe, it, beforeEach, afterEach } from 'node:test';
import { asnHistoryUrl, loadAsnHistoryInto } from '../frontend/utils/ip/asn-history.js';
const realFetch = globalThis.fetch;
const realConsoleError = console.error;
let calls;
const stubFetch = (impl) => {
globalThis.fetch = async (url, init) => {
calls.push(url);
return impl(url, init);
};
};
const jsonResponse = (body, status = 200) => ({ ok: status < 400, status, json: async () => body });
beforeEach(() => {
calls = [];
console.error = () => {};
});
afterEach(() => {
globalThis.fetch = realFetch;
console.error = realConsoleError;
});
describe('asnHistoryUrl', () => {
it('encodes the prefix', () => {
assert.equal(asnHistoryUrl('1.1.1.0/24'), '/api/asn-history?prefix=1.1.1.0%2F24');
});
});
describe('loadAsnHistoryInto', () => {
it('caches the body under the prefix', async () => {
const body = { prefix: '1.1.1.0/24', asns: [] };
stubFetch(() => jsonResponse(body));
const cache = {};
const entry = await loadAsnHistoryInto(cache, '1.1.1.0/24');
assert.deepEqual(calls, [asnHistoryUrl('1.1.1.0/24')]);
assert.deepEqual(cache, { '1.1.1.0/24': body });
assert.equal(entry, cache['1.1.1.0/24']);
});
it('a cached prefix resolves without a request', async () => {
stubFetch(() => jsonResponse({}));
const cache = { '2001:db8::/48': { asns: [{ asn: 64500 }] } };
const entry = await loadAsnHistoryInto(cache, '2001:db8::/48');
assert.equal(calls.length, 0);
assert.deepEqual(entry, { asns: [{ asn: 64500 }] });
});
it('a non-2xx answer is cached as { error: true }', async () => {
stubFetch(() => jsonResponse({ error: 'Gateway Timeout' }, 504));
const cache = {};
assert.deepEqual(await loadAsnHistoryInto(cache, '192.0.2.0/24'), { error: true });
assert.deepEqual(cache, { '192.0.2.0/24': { error: true } });
});
it('a network failure is cached as { error: true }', async () => {
stubFetch(() => { throw new TypeError('offline'); });
const cache = {};
assert.deepEqual(await loadAsnHistoryInto(cache, '198.51.100.0/24'), { error: true });
assert.deepEqual(cache, { '198.51.100.0/24': { error: true } });
});
});