Merge pull request #3615 from DaydreamCoding/feat/usage-ip-geolocation

feat(usage): add IP geolocation lookup to usage tables
This commit is contained in:
Wesley Liddick
2026-07-02 16:42:06 +08:00
committed by GitHub
11 changed files with 938 additions and 3 deletions
@@ -1,5 +1,21 @@
<template>
<div class="card overflow-hidden">
<div
v-if="showIpGeoToolbar"
class="flex items-center justify-end gap-2 border-b border-gray-200 px-4 py-2 dark:border-dark-700"
>
<span v-if="pendingIpCount > 0" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('usage.ipGeo.pending', { count: pendingIpCount }) }}
</span>
<button
type="button"
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:bg-primary-900/30"
:disabled="ipGeoBatchLoading || pendingIpCount === 0"
@click="handleBatchFetchIpGeo"
>
{{ ipGeoBatchLoading ? t('usage.ipGeo.batchFetching') : t('usage.ipGeo.batchFetch') }}
</button>
</div>
<div class="overflow-auto">
<DataTable
:columns="columns"
@@ -188,7 +204,10 @@
</template>
<template #cell-ip_address="{ row }">
<span v-if="row.ip_address" class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
<div v-if="row.ip_address">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ row.ip_address }}</span>
<IpGeoCell :ip="row.ip_address" />
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
@@ -404,7 +423,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
@@ -440,7 +459,9 @@ function accountBilled(row: { total_cost?: number | null; account_stats_cost?: n
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import IpGeoCell from '@/components/common/IpGeoCell.vue'
import Icon from '@/components/icons/Icon.vue'
import { fetchBatch, getEntry } from '@/utils/ipGeoLookup'
import type { AdminUsageLog } from '@/types'
import type { Column } from '@/components/common/types'
@@ -463,13 +484,39 @@ const props = withDefaults(defineProps<Props>(), {
showAccountBilling: true,
showUpstreamEndpoint: true
})
defineEmits<{
const emit = defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
ipGeoBatchFailed: []
}>()
const { t } = useI18n()
const showAccountBilling = props.showAccountBilling
const showUpstreamEndpoint = props.showUpstreamEndpoint
const ipGeoBatchLoading = ref(false)
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
const currentPageIps = computed(() =>
Array.from(new Set(props.data.map((row) => row.ip_address).filter((ip): ip is string => Boolean(ip))))
)
const pendingIpCount = computed(() => {
if (!showIpGeoToolbar.value) return 0
return currentPageIps.value.filter((ip) => {
const status = getEntry(ip).status
return status === 'idle' || status === 'error'
}).length
})
const handleBatchFetchIpGeo = async () => {
ipGeoBatchLoading.value = true
try {
const ok = await fetchBatch(currentPageIps.value)
if (!ok) emit('ipGeoBatchFailed')
} finally {
ipGeoBatchLoading.value = false
}
}
// Tooltip state - cost
const tooltipVisible = ref(false)
@@ -1,3 +1,11 @@
const ipGeoMocks = vi.hoisted(() => ({
getEntry: vi.fn(() => ({ status: 'idle' as const })),
fetchOne: vi.fn(),
fetchBatch: vi.fn(),
}))
vi.mock('@/utils/ipGeoLookup', () => ipGeoMocks)
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
@@ -323,6 +331,104 @@ describe('admin UsageTable tooltip', () => {
})
})
describe('admin UsageTable IP geolocation batch toolbar', () => {
const DataTableStubWithIp = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-ip_address" :row="row" />
</div>
</div>
`,
}
beforeEach(() => {
ipGeoMocks.getEntry.mockReset()
ipGeoMocks.fetchOne.mockReset()
ipGeoMocks.fetchBatch.mockReset()
ipGeoMocks.getEntry.mockReturnValue({ status: 'idle' })
})
it('does not render the batch toolbar when the ip_address column is not visible', () => {
const wrapper = mount(UsageTable, {
props: {
data: [{ request_id: 'r1', ip_address: '8.8.8.8' }],
loading: false,
columns: [],
},
global: { stubs: { DataTable: DataTableStubWithIp, EmptyState: true, Teleport: true } },
})
expect(wrapper.text()).not.toContain('usage.ipGeo.batchFetch')
})
it('renders the batch toolbar with a pending count when the ip_address column is visible', () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{ request_id: 'r1', ip_address: '8.8.8.8' },
{ request_id: 'r2', ip_address: '8.8.8.8' },
{ request_id: 'r3', ip_address: '1.1.1.1' },
],
loading: false,
columns: [{ key: 'ip_address', label: 'IP' }],
},
global: { stubs: { DataTable: DataTableStubWithIp, EmptyState: true, Teleport: true } },
})
expect(wrapper.text()).toContain('usage.ipGeo.pending')
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect((button.element as HTMLButtonElement).disabled).toBe(false)
})
it('fetches deduplicated IPs from the current page when the batch button is clicked', async () => {
ipGeoMocks.fetchBatch.mockResolvedValue(true)
const wrapper = mount(UsageTable, {
props: {
data: [
{ request_id: 'r1', ip_address: '8.8.8.8' },
{ request_id: 'r2', ip_address: '8.8.8.8' },
{ request_id: 'r3', ip_address: '1.1.1.1' },
],
loading: false,
columns: [{ key: 'ip_address', label: 'IP' }],
},
global: { stubs: { DataTable: DataTableStubWithIp, EmptyState: true, Teleport: true } },
})
await wrapper.find('button').trigger('click')
expect(ipGeoMocks.fetchBatch).toHaveBeenCalledWith(['8.8.8.8', '1.1.1.1'])
expect(wrapper.emitted('ipGeoBatchFailed')).toBeUndefined()
})
it('emits ipGeoBatchFailed when the batch request reports a network-level failure', async () => {
ipGeoMocks.fetchBatch.mockResolvedValue(false)
const wrapper = mount(UsageTable, {
props: {
data: [{ request_id: 'r1', ip_address: '8.8.8.8' }],
loading: false,
columns: [{ key: 'ip_address', label: 'IP' }],
},
global: { stubs: { DataTable: DataTableStubWithIp, EmptyState: true, Teleport: true } },
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('ipGeoBatchFailed')).toHaveLength(1)
})
it('renders IpGeoCell content for ip_address cells', () => {
ipGeoMocks.getEntry.mockReturnValue({ status: 'success', label: 'CN · Guangdong · Shenzhen', detail: {} })
const wrapper = mount(UsageTable, {
props: {
data: [{ request_id: 'r1', ip_address: '121.35.47.43' }],
loading: false,
columns: [{ key: 'ip_address', label: 'IP' }],
},
global: { stubs: { DataTable: DataTableStubWithIp, EmptyState: true, Teleport: true } },
})
expect(wrapper.text()).toContain('121.35.47.43')
expect(wrapper.text()).toContain('CN · Guangdong · Shenzhen')
})
})
// A DataTable stub that also renders cell-user, so the deleted badge can be asserted.
const DataTableStubWithUser = {
props: ['data'],
@@ -0,0 +1,101 @@
<template>
<div v-if="entry.status === 'idle'" class="mt-0.5 text-xs">
<button
type="button"
class="text-primary-600 underline decoration-dashed underline-offset-2 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleFetch"
>
{{ t('usage.ipGeo.fetch') }}
</button>
</div>
<div
v-else-if="entry.status === 'loading'"
class="mt-0.5 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500"
>
<svg class="h-3 w-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{{ t('usage.ipGeo.fetching') }}
</div>
<div v-else-if="entry.status === 'success'" class="mt-0.5 flex items-center gap-1 text-xs">
<button
type="button"
class="truncate text-gray-500 underline decoration-dotted underline-offset-2 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400"
:title="tooltipText"
@click="handleOpenDetail"
>
{{ entry.label }}
</button>
<button
type="button"
class="text-gray-400 hover:text-primary-600 dark:hover:text-primary-400"
:title="t('usage.ipGeo.refreshTitle')"
@click="handleRefresh"
>
<Icon name="refresh" size="xs" />
</button>
</div>
<div v-else-if="entry.status === 'error'" class="mt-0.5 text-xs">
<button
type="button"
class="text-red-600 underline decoration-dashed underline-offset-2 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
@click="handleFetch"
>
{{ t('usage.ipGeo.failed') }}
</button>
</div>
<div v-else class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{{ t('usage.ipGeo.private') }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { fetchOne, getEntry } from '@/utils/ipGeoLookup'
const props = defineProps<{ ip: string }>()
const { t } = useI18n()
const entry = computed(() => getEntry(props.ip))
const tooltipText = computed(() => {
const detail = entry.value.detail
if (!detail) return ''
const lines = [
detail.organization ? `${t('usage.ipGeo.detailOrg')}: ${detail.organization}` : '',
detail.timezone ? `${t('usage.ipGeo.detailTimezone')}: ${detail.timezone}` : '',
detail.accuracy != null ? `${t('usage.ipGeo.detailAccuracy')}: ${detail.accuracy}km` : '',
detail.latitude && detail.longitude
? `${t('usage.ipGeo.detailCoordinates')}: ${detail.latitude}, ${detail.longitude}`
: '',
].filter(Boolean)
return lines.join('\n')
})
const handleFetch = () => {
void fetchOne(props.ip)
}
const handleRefresh = () => {
void fetchOne(props.ip, true)
}
const handleOpenDetail = () => {
window.open(
`https://www.iplocation.net/ip-lookup?query=${encodeURIComponent(props.ip)}`,
'_blank',
'noopener,noreferrer'
)
}
</script>
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
getEntry: vi.fn(),
fetchOne: vi.fn(),
}))
vi.mock('@/utils/ipGeoLookup', () => ({
getEntry: mocks.getEntry,
fetchOne: mocks.fetchOne,
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => {
const table: Record<string, string> = {
'usage.ipGeo.fetch': 'Fetch region',
'usage.ipGeo.fetching': 'Fetching...',
'usage.ipGeo.failed': 'Failed',
'usage.ipGeo.private': 'Private address',
'usage.ipGeo.refreshTitle': 'Refresh',
'usage.ipGeo.detailOrg': 'ISP',
'usage.ipGeo.detailTimezone': 'Timezone',
'usage.ipGeo.detailAccuracy': 'Accuracy',
'usage.ipGeo.detailCoordinates': 'Coordinates',
}
return table[key] ?? key
},
}),
}
})
import IpGeoCell from '../IpGeoCell.vue'
describe('IpGeoCell', () => {
beforeEach(() => {
mocks.getEntry.mockReset()
mocks.fetchOne.mockReset()
})
it('renders a clickable fetch link in idle state and triggers fetchOne on click', async () => {
mocks.getEntry.mockReturnValue({ status: 'idle' })
const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } })
expect(wrapper.text()).toContain('Fetch region')
await wrapper.find('button').trigger('click')
expect(mocks.fetchOne).toHaveBeenCalledWith('8.8.8.8')
})
it('renders loading state', () => {
mocks.getEntry.mockReturnValue({ status: 'loading' })
const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } })
expect(wrapper.text()).toContain('Fetching...')
})
it('renders success state with label, tooltip detail, and a refresh button', async () => {
mocks.getEntry.mockReturnValue({
status: 'success',
label: 'CN · Guangdong · Shenzhen',
detail: {
organization: 'AS4134 Chinanet',
timezone: 'Asia/Shanghai',
accuracy: 10,
latitude: '22.5',
longitude: '114.0',
},
})
const wrapper = mount(IpGeoCell, { props: { ip: '121.35.47.43' } })
expect(wrapper.text()).toContain('CN · Guangdong · Shenzhen')
const buttons = wrapper.findAll('button')
expect(buttons.length).toBe(2)
expect(buttons[0].attributes('title')).toContain('AS4134 Chinanet')
expect(buttons[0].attributes('title')).toContain('Asia/Shanghai')
await buttons[1].trigger('click')
expect(mocks.fetchOne).toHaveBeenCalledWith('121.35.47.43', true)
})
it('opens the external lookup page when the label is clicked', async () => {
mocks.getEntry.mockReturnValue({ status: 'success', label: 'US · California', detail: {} })
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mount(IpGeoCell, { props: { ip: '8.8.4.4' } })
await wrapper.findAll('button')[0].trigger('click')
expect(openSpy).toHaveBeenCalledWith(
'https://www.iplocation.net/ip-lookup?query=8.8.4.4',
'_blank',
'noopener,noreferrer'
)
openSpy.mockRestore()
})
it('renders failed state as a clickable retry', async () => {
mocks.getEntry.mockReturnValue({ status: 'error' })
const wrapper = mount(IpGeoCell, { props: { ip: '8.8.8.8' } })
expect(wrapper.text()).toContain('Failed')
await wrapper.find('button').trigger('click')
expect(mocks.fetchOne).toHaveBeenCalledWith('8.8.8.8')
})
it('renders private state as non-clickable text', () => {
mocks.getEntry.mockReturnValue({ status: 'private' })
const wrapper = mount(IpGeoCell, { props: { ip: '192.168.1.1' } })
expect(wrapper.text()).toContain('Private address')
expect(wrapper.find('button').exists()).toBe(false)
})
})