mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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>
92 lines
4.6 KiB
Vue
92 lines
4.6 KiB
Vue
<template>
|
|
<!-- 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">
|
|
<!-- Top note -->
|
|
<div class="px-3 pt-3 pb-2 flex items-start gap-2 text-xs text-muted-foreground">
|
|
<span>{{ t('ipInfos.ASNHistory.note') }}</span>
|
|
</div>
|
|
|
|
<!-- Loaded with rows -->
|
|
<ul v-if="rows && rows.length" class="px-3 pb-3 divide-y divide-border">
|
|
<li v-for="row in rows" :key="row.asn + ':' + row.firstSeen" class="py-2.5 flex flex-col gap-1">
|
|
<div class="flex items-center justify-between gap-2 min-w-0">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<Building2 class="size-3.5 text-muted-foreground shrink-0" />
|
|
<span class="font-mono font-medium shrink-0">AS{{ row.asn }}</span>
|
|
<span v-if="row.org" class="truncate text-muted-foreground" :title="row.org">· {{ row.org }}</span>
|
|
</div>
|
|
<!-- Visibility relative to the peak in this history. Raw peer count lives in the tooltip
|
|
so it's still recoverable — the badge avoids exposing an absolute number that users
|
|
might mistake for "global BGP reach" (RIPE RIS is a sample, not the whole internet). -->
|
|
<Badge v-if="row.peersPct != null" variant="outline"
|
|
class="shrink-0 gap-1 font-normal min-w-[74px] flex items-center justify-center"
|
|
:title="t('ipInfos.ASNHistory.seenBy', { peers: row.peers, pct: row.peersPct })">
|
|
<Eye class="size-3" />
|
|
<span class="tabular-nums">{{ row.peersPct }}%</span>
|
|
</Badge>
|
|
</div>
|
|
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Clock4 class="size-3" />
|
|
<span class="tabular-nums">{{ fmtDate(row.firstSeen) }} → {{ fmtDate(row.lastSeen) }}</span>
|
|
</div>
|
|
<div v-if="row.prefixes && row.prefixes.length" class="flex flex-wrap gap-1">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ t('ipInfos.ASNHistory.announcedPrefixes') }}
|
|
</span>
|
|
<Badge v-for="p in row.prefixes.slice(0, 4)" :key="p" variant="outline"
|
|
class="font-mono text-[10px] py-0 px-1.5 font-normal">{{ p }}</Badge>
|
|
<span v-if="row.prefixes.length > 4" class="text-[10px] text-muted-foreground self-center">
|
|
+{{ row.prefixes.length - 4 }}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Empty -->
|
|
<div v-else-if="rows && rows.length === 0" class="px-3 pb-3 text-xs text-muted-foreground">
|
|
{{ t('ipInfos.ASNHistory.empty') }}
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="entry && entry.error" class="px-3 pb-3 text-xs text-destructive">
|
|
{{ t('ipInfos.ASNHistory.error') }}
|
|
</div>
|
|
|
|
<!-- Loading skeleton -->
|
|
<div v-else class="px-3 pb-3 space-y-2">
|
|
<div v-for="(w, i) in placeholderSizes" :key="i" class="h-3.5 bg-muted rounded animate-pulse"
|
|
:style="`width: ${(w / 12) * 100}%`"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Building2, Clock4, Eye } from '@lucide/vue';
|
|
|
|
const { t } = useI18n();
|
|
|
|
const placeholderSizes = [12, 8, 6, 8, 4];
|
|
|
|
const props = defineProps({
|
|
// BGP-floor prefix the parent quantized the IP to (/24 v4, /48 v6).
|
|
// Used both as cache key and as the URL param sent to the backend.
|
|
prefix: { type: String, required: true },
|
|
// Shared session cache, keyed by prefix. Owned by IpInfos.vue (or local in QueryIP).
|
|
asnHistoryInfos: { type: Object, required: true },
|
|
});
|
|
|
|
const entry = computed(() => props.asnHistoryInfos[props.prefix]);
|
|
const rows = computed(() => (entry.value && !entry.value.error) ? entry.value.history : null);
|
|
|
|
// Compact YYYY-MM-DD — RIPEstat returns "2013-12-05T00:00:00", so slice is
|
|
// enough. Deliberately NOT localized (unlike the rest of the app's dates):
|
|
// these are dense two-per-row ranges aligned via tabular-nums, and only
|
|
// fixed-width ISO keeps the columns lined up.
|
|
const fmtDate = (iso) => (typeof iso === 'string' ? iso.slice(0, 10) : '—');
|
|
</script>
|