mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3615 from DaydreamCoding/feat/usage-ip-geolocation
feat(usage): add IP geolocation lookup to usage tables
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'],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import en from '../locales/en'
|
||||
import zh from '../locales/zh'
|
||||
|
||||
describe('usage ipGeo locale keys', () => {
|
||||
it('contains zh labels for IP geolocation UI', () => {
|
||||
expect(zh.usage.ipGeo.fetch).toBe('获取地区')
|
||||
expect(zh.usage.ipGeo.fetching).toBe('获取中...')
|
||||
expect(zh.usage.ipGeo.failed).toBe('获取失败')
|
||||
expect(zh.usage.ipGeo.private).toBe('内网地址')
|
||||
expect(zh.usage.ipGeo.batchFetch).toBe('批量获取地区')
|
||||
expect(zh.usage.ipGeo.pending).toBe('{count} 个 IP 待获取地区')
|
||||
})
|
||||
|
||||
it('contains en labels for IP geolocation UI', () => {
|
||||
expect(en.usage.ipGeo.fetch).toBe('Fetch region')
|
||||
expect(en.usage.ipGeo.fetching).toBe('Fetching...')
|
||||
expect(en.usage.ipGeo.failed).toBe('Failed')
|
||||
expect(en.usage.ipGeo.private).toBe('Private address')
|
||||
expect(en.usage.ipGeo.batchFetch).toBe('Batch fetch regions')
|
||||
expect(en.usage.ipGeo.pending).toBe('{count} IPs pending')
|
||||
})
|
||||
})
|
||||
@@ -997,6 +997,21 @@ export default {
|
||||
exportExcelFailed: 'Failed to export usage data',
|
||||
imageUnit: ' images',
|
||||
userAgent: 'User-Agent',
|
||||
ipGeo: {
|
||||
fetch: 'Fetch region',
|
||||
fetching: 'Fetching...',
|
||||
failed: 'Failed',
|
||||
private: 'Private address',
|
||||
refreshTitle: 'Refresh region info',
|
||||
batchFetch: 'Batch fetch regions',
|
||||
batchFetching: 'Fetching...',
|
||||
pending: '{count} IPs pending',
|
||||
batchFailed: 'Failed to batch fetch IP regions',
|
||||
detailOrg: 'ISP',
|
||||
detailTimezone: 'Timezone',
|
||||
detailAccuracy: 'Accuracy',
|
||||
detailCoordinates: 'Coordinates',
|
||||
},
|
||||
tabs: { usage: 'Usage', errors: 'Error Requests' },
|
||||
errors: {
|
||||
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
|
||||
|
||||
@@ -1001,6 +1001,21 @@ export default {
|
||||
exportExcelFailed: '使用数据导出失败',
|
||||
imageUnit: '张',
|
||||
userAgent: 'User-Agent',
|
||||
ipGeo: {
|
||||
fetch: '获取地区',
|
||||
fetching: '获取中...',
|
||||
failed: '获取失败',
|
||||
private: '内网地址',
|
||||
refreshTitle: '刷新地区信息',
|
||||
batchFetch: '批量获取地区',
|
||||
batchFetching: '获取中...',
|
||||
pending: '{count} 个 IP 待获取地区',
|
||||
batchFailed: '批量获取地区信息失败',
|
||||
detailOrg: '运营商',
|
||||
detailTimezone: '时区',
|
||||
detailAccuracy: '定位精度',
|
||||
detailCoordinates: '坐标',
|
||||
},
|
||||
tabs: { usage: '用量明细', errors: '错误请求' },
|
||||
errors: {
|
||||
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { isPrivateIp, getEntry, formatGeoLabel, fetchOne, fetchBatch } from '../ipGeoLookup'
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
it('identifies private/reserved IPv4 ranges', () => {
|
||||
expect(isPrivateIp('10.0.0.1')).toBe(true)
|
||||
expect(isPrivateIp('127.0.0.1')).toBe(true)
|
||||
expect(isPrivateIp('192.168.1.1')).toBe(true)
|
||||
expect(isPrivateIp('172.16.0.1')).toBe(true)
|
||||
expect(isPrivateIp('172.31.255.255')).toBe(true)
|
||||
expect(isPrivateIp('169.254.1.1')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag public IPv4 addresses', () => {
|
||||
expect(isPrivateIp('8.8.8.8')).toBe(false)
|
||||
expect(isPrivateIp('172.32.0.1')).toBe(false)
|
||||
expect(isPrivateIp('121.35.47.43')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies private/reserved IPv6 addresses', () => {
|
||||
expect(isPrivateIp('::1')).toBe(true)
|
||||
expect(isPrivateIp('fe80::1')).toBe(true)
|
||||
expect(isPrivateIp('fe90::1')).toBe(true)
|
||||
expect(isPrivateIp('febf::1')).toBe(true)
|
||||
expect(isPrivateIp('fc00::1')).toBe(true)
|
||||
expect(isPrivateIp('fd00::1')).toBe(true)
|
||||
expect(isPrivateIp('fdff::1')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not overmatch public IPv6 addresses near private ranges', () => {
|
||||
expect(isPrivateIp('fec0::1')).toBe(false)
|
||||
expect(isPrivateIp('fbff::1')).toBe(false)
|
||||
expect(isPrivateIp('fe7f::1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntry', () => {
|
||||
it('returns an idle entry for an IP that has never been fetched', () => {
|
||||
expect(getEntry('203.0.113.9')).toEqual({ status: 'idle' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGeoLabel', () => {
|
||||
it('joins country/region/city with a separator', () => {
|
||||
expect(formatGeoLabel({ countryCode: 'CN', region: 'Guangdong', city: 'Shenzhen' })).toBe('CN · Guangdong · Shenzhen')
|
||||
})
|
||||
|
||||
it('skips missing fields', () => {
|
||||
expect(formatGeoLabel({ countryCode: 'CN' })).toBe('CN')
|
||||
expect(formatGeoLabel({ countryCode: 'US', region: 'Massachusetts' })).toBe('US · Massachusetts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchOne', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it('marks a private IP without making a network request', async () => {
|
||||
await fetchOne('192.168.50.1')
|
||||
expect(getEntry('192.168.50.1')).toEqual({ status: 'private' })
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches and stores a successful geolocation result', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ip: '121.35.47.43',
|
||||
country_code: 'CN',
|
||||
region: 'Guangdong',
|
||||
city: 'Shenzhen',
|
||||
organization: 'AS4134 Chinanet',
|
||||
timezone: 'Asia/Shanghai',
|
||||
accuracy: 10,
|
||||
latitude: '22.5455',
|
||||
longitude: '114.0683',
|
||||
}),
|
||||
})
|
||||
|
||||
await fetchOne('121.35.47.43')
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://get.geojs.io/v1/ip/geo/121.35.47.43.json')
|
||||
const entry = getEntry('121.35.47.43')
|
||||
expect(entry.status).toBe('success')
|
||||
expect(entry.label).toBe('CN · Guangdong · Shenzhen')
|
||||
expect(entry.detail?.organization).toBe('AS4134 Chinanet')
|
||||
})
|
||||
|
||||
it('marks the entry as error when the response has no country_code', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ip: '192.0.2.55', organization: 'AS64512 Unknown' }),
|
||||
})
|
||||
|
||||
await fetchOne('192.0.2.55')
|
||||
|
||||
expect(getEntry('192.0.2.55').status).toBe('error')
|
||||
})
|
||||
|
||||
it('marks the entry as error when the request rejects', async () => {
|
||||
(global.fetch as any).mockRejectedValue(new Error('network down'))
|
||||
|
||||
await fetchOne('198.51.100.7')
|
||||
|
||||
expect(getEntry('198.51.100.7').status).toBe('error')
|
||||
})
|
||||
|
||||
it('does not re-fetch a cached successful IP unless forced', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ip: '8.8.8.8', country_code: 'US', region: 'California', city: 'Mountain View' }),
|
||||
})
|
||||
|
||||
await fetchOne('8.8.8.8')
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await fetchOne('8.8.8.8')
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await fetchOne('8.8.8.8', true)
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchBatch', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it('deduplicates IPs and skips private addresses without a network call', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ ip: '203.0.113.10', country_code: 'US', region: 'Texas', city: 'Dallas' }],
|
||||
})
|
||||
|
||||
await fetchBatch(['203.0.113.10', '203.0.113.10', '10.0.0.5'])
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
const calledUrl = (global.fetch as any).mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain('ip=203.0.113.10')
|
||||
expect(calledUrl).not.toContain('203.0.113.10,203.0.113.10')
|
||||
expect(getEntry('10.0.0.5').status).toBe('private')
|
||||
expect(getEntry('203.0.113.10').status).toBe('success')
|
||||
})
|
||||
|
||||
it('splits more than 50 IPs into multiple chunk requests', async () => {
|
||||
const ips = Array.from({ length: 61 }, (_, i) => `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`)
|
||||
;(global.fetch as any).mockImplementation(async (url: string) => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
const queried = new URL(url).searchParams.get('ip')!.split(',')
|
||||
return queried.map((ip) => ({ ip, country_code: 'US' }))
|
||||
},
|
||||
}))
|
||||
|
||||
await fetchBatch(ips)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
const firstChunkIps = new URL((global.fetch as any).mock.calls[0][0]).searchParams.get('ip')!.split(',')
|
||||
const secondChunkIps = new URL((global.fetch as any).mock.calls[1][0]).searchParams.get('ip')!.split(',')
|
||||
expect(firstChunkIps.length).toBe(50)
|
||||
expect(secondChunkIps.length).toBe(11)
|
||||
})
|
||||
|
||||
it('marks individual IPs as error when they are missing from the batch response', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ ip: '203.0.113.20', country_code: 'US' }],
|
||||
})
|
||||
|
||||
const ok = await fetchBatch(['203.0.113.20', '203.0.113.21'])
|
||||
|
||||
expect(getEntry('203.0.113.20').status).toBe('success')
|
||||
expect(getEntry('203.0.113.21').status).toBe('error')
|
||||
// 响应本身是 200,只是个别 IP 缺失/无法定位,属于业务级失败而非网络级失败
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when a chunk request fails at the network level', async () => {
|
||||
(global.fetch as any).mockRejectedValue(new Error('network down'))
|
||||
|
||||
const ok = await fetchBatch(['203.0.113.50', '203.0.113.51'])
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(getEntry('203.0.113.50').status).toBe('error')
|
||||
expect(getEntry('203.0.113.51').status).toBe('error')
|
||||
})
|
||||
|
||||
it('skips IPs that already have a cached success entry', async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [{ ip: '203.0.113.40', country_code: 'CN' }],
|
||||
})
|
||||
await fetchBatch(['203.0.113.40'])
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [{ ip: '203.0.113.41', country_code: 'CN' }],
|
||||
})
|
||||
await fetchBatch(['203.0.113.40', '203.0.113.41'])
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
const secondCallUrl = (global.fetch as any).mock.calls[1][0] as string
|
||||
expect(secondCallUrl).toContain('203.0.113.41')
|
||||
expect(secondCallUrl).not.toContain('203.0.113.40')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ipGeoLookup localStorage persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('hydrates the in-memory cache from a non-expired localStorage entry on module load', async () => {
|
||||
localStorage.setItem(
|
||||
'sub2api:ip-geo-cache:v1',
|
||||
JSON.stringify({
|
||||
'121.35.47.43': { label: 'CN · Guangdong · Shenzhen', fetchedAt: Date.now() },
|
||||
})
|
||||
)
|
||||
|
||||
const mod = await import('../ipGeoLookup')
|
||||
|
||||
expect(mod.getEntry('121.35.47.43')).toEqual(
|
||||
expect.objectContaining({ status: 'success', label: 'CN · Guangdong · Shenzhen' })
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores expired localStorage entries on module load', async () => {
|
||||
const twentyFiveHoursAgo = Date.now() - 25 * 60 * 60 * 1000
|
||||
localStorage.setItem(
|
||||
'sub2api:ip-geo-cache:v1',
|
||||
JSON.stringify({
|
||||
'8.8.8.8': { label: 'US · California', fetchedAt: twentyFiveHoursAgo },
|
||||
})
|
||||
)
|
||||
|
||||
const mod = await import('../ipGeoLookup')
|
||||
|
||||
expect(mod.getEntry('8.8.8.8')).toEqual({ status: 'idle' })
|
||||
})
|
||||
|
||||
it('persists a successful fetch result to localStorage', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ip: '1.2.4.8', country_code: 'CN' }),
|
||||
})
|
||||
const mod = await import('../ipGeoLookup')
|
||||
|
||||
await mod.fetchOne('1.2.4.8')
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('sub2api:ip-geo-cache:v1') || '{}')
|
||||
expect(stored['1.2.4.8']).toEqual(expect.objectContaining({ label: 'CN' }))
|
||||
})
|
||||
|
||||
it('expires a hydrated in-memory entry after the TTL elapses', async () => {
|
||||
const now = new Date('2026-07-01T00:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
localStorage.setItem(
|
||||
'sub2api:ip-geo-cache:v1',
|
||||
JSON.stringify({
|
||||
'8.8.4.4': { label: 'US · California', fetchedAt: now.getTime() },
|
||||
})
|
||||
)
|
||||
|
||||
const mod = await import('../ipGeoLookup')
|
||||
expect(mod.getEntry('8.8.4.4')).toEqual(expect.objectContaining({ status: 'success' }))
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 25 * 60 * 60 * 1000))
|
||||
|
||||
expect(mod.getEntry('8.8.4.4')).toEqual({ status: 'idle' })
|
||||
})
|
||||
|
||||
it('re-fetches a successful in-memory cache entry after the TTL elapses', async () => {
|
||||
const now = new Date('2026-07-01T00:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ip: '8.8.8.8', country_code: 'US' }),
|
||||
})
|
||||
const mod = await import('../ipGeoLookup')
|
||||
|
||||
await mod.fetchOne('8.8.8.8')
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 25 * 60 * 60 * 1000))
|
||||
await mod.fetchOne('8.8.8.8')
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type IpGeoStatus = 'idle' | 'loading' | 'success' | 'error' | 'private'
|
||||
|
||||
export interface IpGeoDetail {
|
||||
countryCode?: string
|
||||
region?: string
|
||||
city?: string
|
||||
organization?: string
|
||||
timezone?: string
|
||||
accuracy?: number
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
}
|
||||
|
||||
export interface IpGeoEntry {
|
||||
status: IpGeoStatus
|
||||
label?: string
|
||||
detail?: IpGeoDetail
|
||||
fetchedAt?: number
|
||||
}
|
||||
|
||||
const IDLE_ENTRY: IpGeoEntry = { status: 'idle' }
|
||||
const CACHE_STORAGE_KEY = 'sub2api:ip-geo-cache:v1'
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const BATCH_CHUNK_SIZE = 50
|
||||
const GEO_SINGLE_URL = 'https://get.geojs.io/v1/ip/geo'
|
||||
const GEO_BATCH_URL = 'https://get.geojs.io/v1/ip/geo.json'
|
||||
|
||||
interface StoredEntry {
|
||||
label: string
|
||||
detail?: IpGeoDetail
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
const cache = reactive(new Map<string, IpGeoEntry>())
|
||||
|
||||
function isFreshSuccess(entry: IpGeoEntry | undefined): entry is IpGeoEntry & { status: 'success'; fetchedAt: number } {
|
||||
return entry?.status === 'success' && typeof entry.fetchedAt === 'number' && Date.now() - entry.fetchedAt <= CACHE_TTL_MS
|
||||
}
|
||||
|
||||
function getFreshEntry(ip: string): IpGeoEntry | undefined {
|
||||
const entry = cache.get(ip)
|
||||
if (entry?.status === 'success' && !isFreshSuccess(entry)) {
|
||||
cache.delete(ip)
|
||||
persistToStorage()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export function isPrivateIp(ip: string): boolean {
|
||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
||||
if (v4) {
|
||||
const a = Number(v4[1])
|
||||
const b = Number(v4[2])
|
||||
if (a === 10) return true
|
||||
if (a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
return false
|
||||
}
|
||||
const lower = ip.toLowerCase()
|
||||
if (lower === '::1') return true
|
||||
const firstSegment = lower.split(':', 1)[0]
|
||||
if (/^fe[89ab][0-9a-f]$/.test(firstSegment)) return true
|
||||
if (/^f[cd][0-9a-f]{2}$/.test(firstSegment)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function loadFromStorage(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as Record<string, StoredEntry>
|
||||
const now = Date.now()
|
||||
for (const [ip, stored] of Object.entries(parsed)) {
|
||||
if (!stored || typeof stored.fetchedAt !== 'number') continue
|
||||
if (now - stored.fetchedAt > CACHE_TTL_MS) continue
|
||||
cache.set(ip, { status: 'success', label: stored.label, detail: stored.detail, fetchedAt: stored.fetchedAt })
|
||||
}
|
||||
} catch {
|
||||
// 忽略损坏的本地缓存
|
||||
}
|
||||
}
|
||||
|
||||
function persistToStorage(): void {
|
||||
try {
|
||||
const toStore: Record<string, StoredEntry> = {}
|
||||
for (const [ip, entry] of cache.entries()) {
|
||||
if (entry.status === 'success' && entry.label && entry.fetchedAt) {
|
||||
toStore[ip] = { label: entry.label, detail: entry.detail, fetchedAt: entry.fetchedAt }
|
||||
}
|
||||
}
|
||||
localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(toStore))
|
||||
} catch {
|
||||
// 存储写入失败(如隐私模式禁用 localStorage)不影响功能
|
||||
}
|
||||
}
|
||||
|
||||
loadFromStorage()
|
||||
|
||||
export function getEntry(ip: string): IpGeoEntry {
|
||||
return getFreshEntry(ip) ?? IDLE_ENTRY
|
||||
}
|
||||
|
||||
export function formatGeoLabel(detail: IpGeoDetail): string {
|
||||
const parts = [detail.countryCode, detail.region, detail.city].filter(
|
||||
(part): part is string => Boolean(part && part.trim())
|
||||
)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
interface RawGeoResponse {
|
||||
ip: string
|
||||
country_code?: string
|
||||
region?: string
|
||||
city?: string
|
||||
organization?: string
|
||||
timezone?: string
|
||||
accuracy?: number
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
}
|
||||
|
||||
function toDetail(raw: RawGeoResponse): IpGeoDetail {
|
||||
return {
|
||||
countryCode: raw.country_code,
|
||||
region: raw.region,
|
||||
city: raw.city,
|
||||
organization: raw.organization,
|
||||
timezone: raw.timezone,
|
||||
accuracy: raw.accuracy,
|
||||
latitude: raw.latitude,
|
||||
longitude: raw.longitude,
|
||||
}
|
||||
}
|
||||
|
||||
function applyResult(ip: string, raw: RawGeoResponse | undefined): void {
|
||||
if (!raw || !raw.country_code) {
|
||||
cache.set(ip, { status: 'error' })
|
||||
return
|
||||
}
|
||||
const detail = toDetail(raw)
|
||||
cache.set(ip, {
|
||||
status: 'success',
|
||||
label: formatGeoLabel(detail),
|
||||
detail,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchOne(ip: string, force = false): Promise<void> {
|
||||
if (isPrivateIp(ip)) {
|
||||
cache.set(ip, { status: 'private' })
|
||||
return
|
||||
}
|
||||
const existing = getFreshEntry(ip)
|
||||
if (!force && (isFreshSuccess(existing) || existing?.status === 'loading')) {
|
||||
return
|
||||
}
|
||||
cache.set(ip, { status: 'loading' })
|
||||
try {
|
||||
const response = await fetch(`${GEO_SINGLE_URL}/${encodeURIComponent(ip)}.json`)
|
||||
if (!response.ok) {
|
||||
cache.set(ip, { status: 'error' })
|
||||
return
|
||||
}
|
||||
const raw = (await response.json()) as RawGeoResponse
|
||||
applyResult(ip, raw)
|
||||
persistToStorage()
|
||||
} catch {
|
||||
cache.set(ip, { status: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBatch(ips: string[]): Promise<boolean> {
|
||||
const unique = Array.from(new Set(ips))
|
||||
const targets: string[] = []
|
||||
for (const ip of unique) {
|
||||
if (isPrivateIp(ip)) {
|
||||
cache.set(ip, { status: 'private' })
|
||||
continue
|
||||
}
|
||||
const existing = cache.get(ip)
|
||||
if (isFreshSuccess(existing) || existing?.status === 'loading') continue
|
||||
targets.push(ip)
|
||||
}
|
||||
if (targets.length === 0) return true
|
||||
|
||||
targets.forEach((ip) => cache.set(ip, { status: 'loading' }))
|
||||
|
||||
let allChunksOk = true
|
||||
for (let i = 0; i < targets.length; i += BATCH_CHUNK_SIZE) {
|
||||
const chunk = targets.slice(i, i + BATCH_CHUNK_SIZE)
|
||||
try {
|
||||
const response = await fetch(`${GEO_BATCH_URL}?ip=${chunk.map(encodeURIComponent).join(',')}`)
|
||||
if (!response.ok) {
|
||||
chunk.forEach((ip) => cache.set(ip, { status: 'error' }))
|
||||
allChunksOk = false
|
||||
continue
|
||||
}
|
||||
const results = (await response.json()) as RawGeoResponse[]
|
||||
const byIp = new Map(results.map((r) => [r.ip, r]))
|
||||
chunk.forEach((ip) => applyResult(ip, byIp.get(ip)))
|
||||
persistToStorage()
|
||||
} catch {
|
||||
chunk.forEach((ip) => cache.set(ip, { status: 'error' }))
|
||||
allChunksOk = false
|
||||
}
|
||||
}
|
||||
return allChunksOk
|
||||
}
|
||||
@@ -118,6 +118,7 @@
|
||||
:default-sort-order="'desc'"
|
||||
@sort="handleSort"
|
||||
@userClick="handleUserClick"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
||||
/>
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
@@ -486,6 +487,10 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleIpGeoBatchFailed = () => {
|
||||
appStore.showError(t('usage.ipGeo.batchFailed'))
|
||||
}
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
const getRequestTypeLabel = (log: AdminUsageLog): string => {
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
@sort="handleSort"
|
||||
@ipGeoBatchFailed="handleIpGeoBatchFailed"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
@@ -514,6 +515,10 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
const handleIpGeoBatchFailed = () => {
|
||||
appStore.showError(t('usage.ipGeo.batchFailed'))
|
||||
}
|
||||
|
||||
const getRequestTypeExportText = (log: UsageLog): string => {
|
||||
const requestType = resolveUsageRequestType(log)
|
||||
if (requestType === 'cyber') return 'Cyber'
|
||||
|
||||
Reference in New Issue
Block a user