Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/components/common/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ interface Props {
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
(e: 'search', query: string): void
}

const props = withDefaults(defineProps<Props>(), {
Expand Down Expand Up @@ -329,6 +330,7 @@ watch(isOpen, (open) => {
}

if (props.searchable) {
emit('search', searchQuery.value)
nextTick(() => searchInputRef.value?.focus())
}
// Add scroll listener to update position
Expand All @@ -342,6 +344,11 @@ watch(isOpen, (open) => {
}
})

watch(searchQuery, (query) => {
if (!props.searchable || !isOpen.value) return
emit('search', query)
})

const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/components/common/__tests__/Select.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { ref } from 'vue'

import Select from '../Select.vue'

const messages: Record<string, string> = {
'common.selectOption': 'Select option',
'common.searchPlaceholder': 'Search...',
'common.noOptionsFound': 'No options found'
}

vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
locale: ref('en')
})
}))

describe('Select', () => {
afterEach(() => {
document.body.innerHTML = ''
})

it('emits search when opened and when the query changes', async () => {
const wrapper = mount(Select, {
attachTo: document.body,
props: {
modelValue: null,
searchable: true,
options: [
{ value: 1, label: 'Alpha' },
{ value: 2, label: 'Beta' }
]
},
global: {
stubs: {
Icon: true,
Teleport: true
}
}
})

await wrapper.get('button[aria-haspopup="true"]').trigger('click')
await flushPromises()

expect(wrapper.emitted('search')).toEqual([['']])

const searchInput = wrapper.find('.select-search-input')
expect(searchInput.exists()).toBe(true)

await searchInput.setValue('abc')
await flushPromises()

expect(wrapper.emitted('search')).toEqual([[''], ['abc']])
})

it('does not emit search when searchable is disabled', async () => {
const wrapper = mount(Select, {
attachTo: document.body,
props: {
modelValue: null,
options: [
{ value: 1, label: 'Alpha' },
{ value: 2, label: 'Beta' }
]
},
global: {
stubs: {
Icon: true,
Teleport: true
}
}
})

await wrapper.get('button[aria-haspopup="true"]').trigger('click')
await flushPromises()

expect(wrapper.emitted('search')).toBeUndefined()
})

it('keeps change and update:modelValue behavior unchanged', async () => {
const wrapper = mount(Select, {
attachTo: document.body,
props: {
modelValue: null,
options: [
{ value: 1, label: 'Alpha' },
{ value: 2, label: 'Beta' }
]
},
global: {
stubs: {
Icon: true,
Teleport: true
}
}
})

await wrapper.get('button[aria-haspopup="true"]').trigger('click')
await flushPromises()

const options = wrapper.findAll('.select-option')
expect(options).toHaveLength(2)

await options[1].trigger('click')
await flushPromises()

expect(wrapper.emitted('update:modelValue')).toEqual([[2]])
expect(wrapper.emitted('change')).toEqual([[2, { value: 2, label: 'Beta' }]])
})
})
135 changes: 120 additions & 15 deletions frontend/src/views/user/UsageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@
v-model="filters.api_key_id"
:options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')"
@change="applyFilters"
:empty-text="apiKeyOptionsLoading ? t('common.loading') : t('common.noOptionsFound')"
searchable
:search-placeholder="t('keys.searchPlaceholder')"
@change="onApiKeyChange"
@search="searchApiKeys"
/>
</div>

Expand Down Expand Up @@ -504,7 +508,7 @@
</template>

<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api'
Expand All @@ -516,7 +520,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { UsageLog, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
Expand Down Expand Up @@ -559,19 +563,41 @@ const columns = computed<Column[]>(() => [
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false }
])

type ApiKeyOption = {
value: number | null
label: string
}

const getDefaultApiKeyOption = (): ApiKeyOption => ({
value: null,
label: t('usage.allApiKeys')
})

const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([])
const apiKeyOptionsState = ref<ApiKeyOption[]>([])
const selectedApiKeyOption = ref<ApiKeyOption | null>(null)
const loading = ref(false)
const exporting = ref(false)
const apiKeyOptionsLoading = ref(false)
let apiKeySearchAbortController: AbortController | null = null
let apiKeySearchTimer: ReturnType<typeof setTimeout> | null = null

const apiKeyOptions = computed(() => {
return [
{ value: null, label: t('usage.allApiKeys') },
...apiKeys.value.map((key) => ({
value: key.id,
label: key.name
}))
const defaultOption = getDefaultApiKeyOption()
const options = [defaultOption]
const seenValues = new Set<number | null>([defaultOption.value])
const candidateOptions = [
...(selectedApiKeyOption.value ? [selectedApiKeyOption.value] : []),
...apiKeyOptionsState.value
]

for (const option of candidateOptions) {
if (seenValues.has(option.value)) continue
options.push(option)
seenValues.add(option.value)
}

return options
})

// Helper function to format date in local timezone
Expand Down Expand Up @@ -718,15 +744,79 @@ const loadUsageLogs = async () => {
}
}

const loadApiKeys = async () => {
const buildApiKeySearchFilters = (query: string) => {
const trimmedQuery = query.trim()
if (trimmedQuery) {
return {
search: trimmedQuery,
sort_by: 'name',
sort_order: 'asc' as const
}
}

return {
sort_by: 'created_at',
sort_order: 'desc' as const
}
}

const fetchApiKeyOptions = async (query: string) => {
apiKeySearchAbortController?.abort()
const controller = new AbortController()
apiKeySearchAbortController = controller
const { signal } = controller
apiKeyOptionsLoading.value = true

try {
const response = await keysAPI.list(1, 100)
apiKeys.value = response.items
const response = await keysAPI.list(1, 20, buildApiKeySearchFilters(query), { signal })
if (signal.aborted) {
return
}

apiKeyOptionsState.value = response.items.map((key) => ({
value: key.id,
label: key.name
}))
} catch (error) {
console.error('Failed to load API keys:', error)
if (signal.aborted) {
return
}
apiKeyOptionsState.value = []
console.error('Failed to search API keys:', error)
} finally {
if (apiKeySearchAbortController === controller) {
apiKeyOptionsLoading.value = false
}
}
}

const searchApiKeys = (query: string) => {
if (apiKeySearchTimer) {
clearTimeout(apiKeySearchTimer)
}

apiKeySearchTimer = setTimeout(() => {
apiKeySearchTimer = null
fetchApiKeyOptions(query)
}, 300)
}

const onApiKeyChange = (
value: string | number | boolean | null,
option: { value?: string | number | boolean | null; label?: string } | null
) => {
if (value == null) {
selectedApiKeyOption.value = null
} else if (typeof value === 'number' && option && typeof option.label === 'string') {
selectedApiKeyOption.value = {
value,
label: option.label
}
}

applyFilters()
}

const loadUsageStats = async () => {
try {
const apiKeyId = filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined
Expand All @@ -748,6 +838,11 @@ const applyFilters = () => {
}

const resetFilters = () => {
apiKeySearchAbortController?.abort()
if (apiKeySearchTimer) {
clearTimeout(apiKeySearchTimer)
apiKeySearchTimer = null
}
filters.value = {
api_key_id: undefined,
start_date: undefined,
Expand All @@ -761,6 +856,8 @@ const resetFilters = () => {
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
selectedApiKeyOption.value = null
apiKeyOptionsState.value = []
pagination.page = 1
loadUsageLogs()
loadUsageStats()
Expand Down Expand Up @@ -925,8 +1022,16 @@ const hideTokenTooltip = () => {
}

onMounted(() => {
loadApiKeys()
loadUsageLogs()
loadUsageStats()
})

onUnmounted(() => {
abortController?.abort()
apiKeySearchAbortController?.abort()
if (apiKeySearchTimer) {
clearTimeout(apiKeySearchTimer)
apiKeySearchTimer = null
}
})
</script>
Loading