feat: add usage IP geolocation lookup
This commit is contained in:
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user