Feat(ip-infos): point score details to a live blocklist check

Score details is one of the most-opened panels, but the score is not a
live reading. A line under it links to Blocklist Check with the IP
carried as `?q=`, which the tool now prefills without running: the
visitor may be signed out or out of quota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 5.5 committed 2026-10-07 15:26:02 +08:00
1 parent 117ebaa65a
commit 017e5c9af3
9 files changed
+77 -4

No files matched your search

+3 -1
View File
@@ -160,7 +160,9 @@ Every "business state → color" mapping goes through `use-status-tone.js`
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
pathOf: () => route.path })` (immediate, so mount, later changes and a `q`
changed while cached all run; another page's `q` never does), `router.replace`
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`.
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`. A `q` runs
the tool only when a run needs no sign-in or quota (AsnProfile,
IpCalculator); otherwise it just prefills (IpBlocklist).
- **Fixed option sets** — a closed list wider than one line is a `Select`.
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
@@ -201,8 +201,10 @@
// IP Blocklist Check: which blocklists an IP is on — live DNSBLs and static
// threat lists — via /api/ipblocklist (the private API's /iphitlist; signed-in
// users, original site only). Verdict / row split live in
// utils/features/ip-blocklist.js.
import { ref, computed } from 'vue';
// utils/features/ip-blocklist.js. A `?q=` IP is prefilled, never run: the
// visitor may be signed out or out of quota.
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
@@ -210,6 +212,7 @@ import { authenticatedFetch } from '@/utils/authenticated-fetch';
import { isoToDateTime, formatIsoDate } from '@/utils/time-utils.js';
import { blocklistVerdict, listedCounts, splitPart, itemTone, reasonMessage } from '@/utils/features/ip-blocklist.js';
import { selectableIPs, classifyTarget } from '@/composables/use-globalping-measurement';
import { useActiveValue } from '@/composables/use-route-active.js';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
@@ -226,6 +229,8 @@ import {
const { t, te } = useI18n();
const store = useMainStore();
const route = useRoute();
const router = useRouter();
const lang = computed(() => store.lang);
// The backend waits up to 30s on the upstream; give it room to answer first.
@@ -254,6 +259,29 @@ const manualIP = ref('');
const inputInvalid = ref(false);
const targetIP = computed(() => (manualMode.value ? manualIP.value.trim() : selectedIP.value));
// Shareable `?q=` (use-route-active.js): a stored IP is picked in the
// dropdown, any other goes to manual entry.
const prefill = (ip) => {
inputInvalid.value = false;
if (allIPs.value.some((item) => item.ip === ip)) {
useStored.value = true;
selectedIP.value = ip;
} else {
useStored.value = false;
manualIP.value = ip;
}
};
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q.trim() !== targetIP.value) prefill(q.trim());
}, { immediate: true });
// Keep `?q=` in step with the last run without growing history.
const syncQuery = (q) => {
if (route.query.q === q) return;
router.replace({ query: { ...route.query, q } });
};
const status = ref('idle'); // 'idle' | 'running'
const isRunning = computed(() => status.value === 'running');
const canRun = computed(() => !isRunning.value && !!store.user && !!targetIP.value);
@@ -316,6 +344,7 @@ const runCheck = async () => {
quotaExceeded.value = true;
return;
}
syncQuery(targetIP.value);
status.value = 'running';
errorMsg.value = '';
quotaExceeded.value = false;
+31 -1
View File
@@ -234,6 +234,16 @@
</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>
</div>
</div>
</CollapsibleContent>
@@ -311,6 +321,7 @@
import { ref, computed, watch } from 'vue';
import { useMainStore } from '@/store';
import { useI18n, I18nT } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { trackEvent } from '@/utils/analytics';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { loadAsnInfoInto } from '@/composables/use-asn-info.js';
@@ -318,6 +329,9 @@ 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 ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import CountryTraffic from './CountryTraffic.vue';
@@ -335,6 +349,7 @@ import { Button } from '@/components/ui/button';
import { Icon } from '@iconify/vue';
import {
Activity,
ArrowRight,
Building2,
Check,
ChevronDown,
@@ -384,7 +399,7 @@ const props = defineProps({
});
// A host dialog listens to close itself first: before the Benefits & Usage
// dialog stacks on it (`view-usage`) or ASN Info's profile link changes page (`open-tool`).
// dialog stacks on it (`view-usage`) or a tool link (ASN Profile, Blocklist Check) changes page (`open-tool`).
const emit = defineEmits(['view-usage', 'open-tool']);
const openUsageDialog = () => {
@@ -495,6 +510,21 @@ const toggleScoreDetails = () => {
// A different IP starts collapsed.
watch(() => props.data.ip, () => { isScoreDetailsOpen.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 } });
};
// Consumer opt-in + a Cloudflare key on the deployment + a country to query.
const canShowCountryTraffic = computed(() =>
props.countryTrafficMode !== 'off' && !props.collapsed
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "All attributes",
"blocklistHint": "The score is an overall profile of this IP. Want to know if it's blocklisted right now?",
"blocklistLink": "Check blocklists live",
"tagState": {
"yes": "Yes",
"no": "No",
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "Tous les attributs",
"blocklistHint": "Le score reflète le profil global de cette IP. Vous voulez savoir si elle figure sur une liste noire en ce moment ?",
"blocklistLink": "Vérifier les listes noires en direct",
"tagState": {
"yes": "Oui",
"no": "Non",
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "Todos os atributos",
"blocklistHint": "A pontuação reflete o perfil geral deste IP. Quer saber se ele está em alguma lista negra agora?",
"blocklistLink": "Verificar listas negras em tempo real",
"tagState": {
"yes": "Sim",
"no": "Não",
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "Все атрибуты",
"blocklistHint": "Оценка отражает общий профиль этого IP. Хотите узнать, есть ли он в чёрных списках прямо сейчас?",
"blocklistLink": "Проверить по чёрным спискам сейчас",
"tagState": {
"yes": "Да",
"no": "Нет",
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "全部屬性",
"blocklistHint": "評分反映的是這個 IP 的綜合輪廓。想知道它此刻有沒有上黑名單?",
"blocklistLink": "即時檢查黑名單",
"tagState": {
"yes": "是",
"no": "否",
+2
View File
@@ -1643,6 +1643,8 @@
}
},
"tagsTitle": "全部属性",
"blocklistHint": "评分反映的是这个 IP 的综合画像。想知道它此刻有没有上黑名单?",
"blocklistLink": "实时检查黑名单",
"tagState": {
"yes": "是",
"no": "否",