Files
MyIP/frontend/components/widgets/QueryIP.vue
T
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00

184 lines
7.9 KiB
Vue

<template>
<!-- Floating query button (bottom right fixed) -->
<JnTooltip :text="t('Tooltips.QueryIP')" side="left">
<Button size="icon" variant="action" type="button" aria-label="IP Check"
class="rounded-full shadow-lg cursor-pointer" @click="openQueryIP">
<Search class="size-4" />
</Button>
</JnTooltip>
<!-- Query Dialog -->
<Dialog :open="isOpen" @update:open="onOpenChange">
<DialogContent :title="t('ipcheck.Title')" class="max-w-2xl min-h-50">
<DialogHeader :icon="Search" :title="t('ipcheck.Title')" />
<div class="space-y-4">
<!-- Input Group -->
<div class="flex items-center gap-2">
<Input type="text" id="inputIP" name="inputIP" :placeholder="t('ipcheck.Placeholder')"
v-model="inputIP" @keyup.enter="submitQuery" :aria-invalid="modalQueryError !== ''"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore
data-lpignore="true" class="font-mono" />
<Button id="sumitQueryButton" type="button" variant="action"
:disabled="!isValidIP(inputIP) || isChecking === 'running'" @click="submitQuery"
class="cursor-pointer">
<Spinner v-if="isChecking === 'running'" />
<template v-else>
<Search class="size-4 shrink-0" />
</template>
</Button>
</div>
<!-- Error message -->
<p v-if="modalQueryError" class="text-sm text-destructive">{{ modalQueryError }}</p>
<!-- Query result: Hero IP (no Copy / Map) + shared IpDetailPanel -->
<div v-if="modalQueryResult" class="rounded-lg border bg-card overflow-hidden">
<div class="px-4 py-3 flex items-start gap-2 min-w-0 border-b mb-4">
<FitText :text="inputIP" :tiers="HERO_TIERS" :title="inputIP" :max-lines="2"
class="font-mono font-semibold min-w-0">
<template #prefix>
<Monitor class="inline size-5 align-middle text-muted-foreground mr-2 mb-1" />
</template>
</FitText>
</div>
<IpDetailPanel :data="modalQueryResult" :ip-geo-source="ipGeoSource" :asn-infos="asnInfos"
:asn-history-infos="asnHistoryInfos" :asn-connectivity-infos="asnConnectivityInfos"
:configs="configs" :is-dark-mode="isDarkMode" map-mode="inline"
country-traffic-mode="inline" @view-usage="onOpenChange(false)"
@open-tool="onOpenChange(false)" />
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup>
// QueryIP — manual IP lookup. Shares IpDetailPanel with IPCard so the info display stays in sync.
// Differences from IPCard:
// - No Copy button (the IP was typed by the user — copying it is pointless).
// - Map & country-traffic expand inline instead of opening dialogs (this IS a Dialog already).
// - The panel's "view usage" link closes this dialog first, for the same reason.
// - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard);
// asnInfos comes from useAsnInfo().
import { ref, computed, watch, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { isValidIP, isUsablePublicIP } from '@/utils/ip/valid-ip.js';
import { toApiTag } from '@/utils/locale-registry.js';
import FitText from '@/components/widgets/FitText.vue';
import { HERO_TIERS } from '@/composables/use-fit-text.js';
import { transformDataFromIPapi } from '@/utils/ip/transform-ip-data.js';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { authenticatedFetch, fetchErrorLabel, logSourceFetchFailure } from '@/utils/authenticated-fetch';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import IpDetailPanel from '../ip-infos/IpDetailPanel.vue';
import { useAsnInfo } from '@/composables/use-asn-info.js';
import { Monitor, Search } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const userPreferences = computed(() => store.userPreferences);
const configs = computed(() => store.configs);
const isDarkMode = computed(() => store.isDarkMode);
const lang = computed(() => store.lang);
const inputIP = ref('');
const modalQueryResult = ref(null);
const modalQueryError = ref('');
const isChecking = ref('idle');
const ipGeoSource = ref(userPreferences.value.ipGeoSource);
const { asnInfos } = useAsnInfo();
const asnHistoryInfos = ref({});
const asnConnectivityInfos = ref({});
watch(() => userPreferences.value.ipGeoSource, (newVal) => {
ipGeoSource.value = newVal;
}, { deep: true });
// Emptying the box retracts whatever the last attempt complained about —
// the message no longer describes anything on screen.
watch(inputIP, (value) => {
if (value.trim() === '') modalQueryError.value = '';
});
const rejectQuery = (message) => {
modalQueryError.value = message;
modalQueryResult.value = null;
isChecking.value = 'idle';
};
const submitQuery = async () => {
if (!isValidIP(inputIP.value)) return rejectQuery(t('ipcheck.Error'));
// Only publicly routable space has geolocation, so answer here rather
// than walking every geo source to collect the same "no data" from each.
if (!isUsablePublicIP(inputIP.value)) return rejectQuery(t('ipcheck.reservedIP'));
modalQueryError.value = '';
modalQueryResult.value = null;
isChecking.value = 'running';
await fetchIPForModal(inputIP.value);
};
const isOpen = ref(false);
const onOpenChange = (val) => {
isOpen.value = val;
if (val) {
nextTick(() => {
const inputElement = document.getElementById('inputIP');
if (inputElement) inputElement.focus();
});
}
};
const openQueryIP = () => {
trackEvent('SideButtons', 'ToggleClick', 'QueryIP');
openModal();
};
const openModal = () => onOpenChange(true);
const fetchIPForModal = async (ip) => {
const selectedLang = toApiTag(lang.value);
const sources = store.ipDBs.filter(s => s.enabled);
// Cyclic walk from the user's preferred source
// Fallback fires only on real failures (network error, 4xx/5xx,
// transform throwing). A 200 response that contains "N/A" fields
// means the upstream is healthy but doesn't know the IP — display
// those as-is rather than walking to the next source.
const startIdx = Math.max(0, sources.findIndex(s => s.id === ipGeoSource.value));
let currentIdx = startIdx;
let attempts = 0;
while (attempts < sources.length) {
const source = sources[currentIdx];
try {
const url = store.getDbUrl(source.id, ip, selectedLang);
const response = await authenticatedFetch(url);
modalQueryResult.value = { ...transformDataFromIPapi(response, source.id, t, lang.value), ip };
isChecking.value = 'idle';
return;
} catch (error) {
logSourceFetchFailure(`Error fetching IP details from source ${source.id} (${fetchErrorLabel(error)}):`, error);
currentIdx = (currentIdx + 1) % sources.length;
attempts++;
}
}
// Every source exhausted with real failures — surface a user-facing
// error instead of leaving the spinner running forever.
isChecking.value = 'idle';
modalQueryError.value = t('ipcheck.NoData');
};
defineExpose({ openModal });
</script>