mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
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,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>
|
||||
@@ -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>
|
||||
@@ -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,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,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.
|
||||
//
|
||||
|
||||
@@ -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,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.
|
||||
//
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user