feat(web): 统一用量筛选草稿与应用状态
This commit is contained in:
7 files changed
+177
-51
No files matched your search
@@ -142,7 +142,7 @@ describe('UsageTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('updates range immediately while Group/model remain an explicit Apply/Reset draft', async () => {
|
||||
it('keeps every filter as one draft and applies range, Group, and model atomically', async () => {
|
||||
const api = new UsageApi([usageReport(), usageReport(), usageReport(), usageReport()])
|
||||
const { router, wrapper } = await mountUsage(
|
||||
api,
|
||||
@@ -150,27 +150,44 @@ describe('UsageTab', () => {
|
||||
)
|
||||
|
||||
await wrapper.get('[data-test="usage-range"]').setValue('30d')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/monitor?tab=usage&range=30d&group_id=7&model=gpt-upstream',
|
||||
)
|
||||
|
||||
await wrapper.get('[data-test="usage-group"]').setValue('')
|
||||
await wrapper.get('[data-test="usage-model"]').setValue('claude-upstream')
|
||||
expect(router.currentRoute.value.fullPath).toContain('model=gpt-upstream')
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
|
||||
)
|
||||
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(1)
|
||||
expect(wrapper.get('[data-test="usage-filter-dirty"]').text()).toContain('not applied')
|
||||
const appliedBefore = wrapper.get('[data-test="usage-applied-filters"]').text()
|
||||
expect(appliedBefore).toContain('Last 24 hours')
|
||||
expect(appliedBefore).toContain('gpt-upstream')
|
||||
expect(appliedBefore).not.toContain('claude-upstream')
|
||||
|
||||
await wrapper.get('[data-test="usage-filter-form"]').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/monitor?tab=usage&range=30d&model=claude-upstream',
|
||||
)
|
||||
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
|
||||
expect(wrapper.find('[data-test="usage-filter-dirty"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('[data-test="usage-reset"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=usage&range=30d')
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/monitor?tab=usage&range=30d&model=claude-upstream',
|
||||
)
|
||||
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
|
||||
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-range"]').element.value).toBe('24h')
|
||||
expect(wrapper.get<HTMLInputElement>('[data-test="usage-model"]').element.value).toBe('')
|
||||
expect(wrapper.get('[data-test="usage-applied-filters"]').text()).toContain('claude-upstream')
|
||||
|
||||
await wrapper.get('[data-test="usage-filter-form"]').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=usage&range=24h')
|
||||
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('preserves an unapplied Group/model draft when only range changes', async () => {
|
||||
const api = new UsageApi([usageReport(), usageReport()])
|
||||
it('preserves an unapplied complete draft on Refresh and separates observed/refreshed time', async () => {
|
||||
const api = new UsageApi([usageReport(), usageReport({ observed_at: '2026-07-27T04:30:00Z' })])
|
||||
const { router, wrapper } = await mountUsage(
|
||||
api,
|
||||
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
|
||||
@@ -179,15 +196,24 @@ describe('UsageTab', () => {
|
||||
await wrapper.get('[data-test="usage-group"]').setValue('')
|
||||
await wrapper.get('[data-test="usage-model"]').setValue('draft-upstream')
|
||||
await wrapper.get('[data-test="usage-range"]').setValue('30d')
|
||||
await wrapper.get('[data-test="usage-refresh"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/monitor?tab=usage&range=30d&group_id=7&model=gpt-upstream',
|
||||
'/monitor?tab=usage&range=24h&group_id=7&model=gpt-upstream',
|
||||
)
|
||||
expect(api.requests.filter(({ path }) => path.startsWith('/api/usage?'))).toHaveLength(2)
|
||||
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-range"]').element.value).toBe('30d')
|
||||
expect(wrapper.get<HTMLSelectElement>('[data-test="usage-group"]').element.value).toBe('')
|
||||
expect(wrapper.get<HTMLInputElement>('[data-test="usage-model"]').element.value).toBe(
|
||||
'draft-upstream',
|
||||
)
|
||||
expect(wrapper.get('[data-test="usage-observed-at"]').attributes('datetime')).toBe(
|
||||
'2026-07-27T04:30:00Z',
|
||||
)
|
||||
expect(wrapper.get('[data-test="usage-refreshed-at"]').attributes('datetime')).not.toBe(
|
||||
'2026-07-27T04:30:00Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades a failed Group option query to a usable positive-ID input', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useApiClient } from '@/api/client-context'
|
||||
import { listGroups } from '@/api/control/groups'
|
||||
import { getUsageReport, type UsageAggregateDto, type UsageFilters } from '@/api/control/usage'
|
||||
import { controlQueryKeys } from '@/app/query-keys'
|
||||
import { useUnsavedChanges } from '@/app/unsaved-changes'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import DataTable from '@/components/ui/DataTable.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
@@ -56,11 +57,30 @@ const usageQuery = useQuery({
|
||||
})
|
||||
const report = computed(() => usageQuery.data.value)
|
||||
const hasData = computed(() => (report.value?.summary.request_count ?? 0) > 0)
|
||||
|
||||
watch([() => appliedFilters.value.group_id, () => appliedFilters.value.model], () => {
|
||||
draft.value = createUsageFilterDraft(appliedFilters.value)
|
||||
filterErrors.value = {}
|
||||
const draftDirty = computed(() => {
|
||||
const appliedDraft = createUsageFilterDraft(appliedFilters.value)
|
||||
return (
|
||||
draft.value.range !== appliedDraft.range ||
|
||||
draft.value.group_id !== appliedDraft.group_id ||
|
||||
draft.value.model !== appliedDraft.model
|
||||
)
|
||||
})
|
||||
const lastRefreshedAt = computed(() =>
|
||||
usageQuery.dataUpdatedAt.value > 0 ? new Date(usageQuery.dataUpdatedAt.value) : null,
|
||||
)
|
||||
const unsavedChanges = useUnsavedChanges(draftDirty)
|
||||
|
||||
watch(
|
||||
[
|
||||
() => appliedFilters.value.range,
|
||||
() => appliedFilters.value.group_id,
|
||||
() => appliedFilters.value.model,
|
||||
],
|
||||
() => {
|
||||
draft.value = createUsageFilterDraft(appliedFilters.value)
|
||||
filterErrors.value = {}
|
||||
},
|
||||
)
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat(locale.value).format(value)
|
||||
@@ -81,27 +101,21 @@ function groupLabel(groupID: number): string {
|
||||
return t('monitor.usage.filters.deletedOrUnknown', { id: groupID })
|
||||
}
|
||||
|
||||
function filterError(field: keyof UsageFilterDraft): string | undefined {
|
||||
function filterError(field: keyof UsageFilterErrors): string | undefined {
|
||||
const key = filterErrors.value[field]
|
||||
return key ? t(key) : undefined
|
||||
}
|
||||
|
||||
async function changeRange(event: Event): Promise<void> {
|
||||
const range = (event.target as HTMLSelectElement).value === '30d' ? '30d' : '24h'
|
||||
await navigate({ ...appliedFilters.value, range })
|
||||
}
|
||||
|
||||
async function applyFilters(): Promise<void> {
|
||||
const errors = validateUsageFilterDraft(draft.value)
|
||||
filterErrors.value = errors
|
||||
if (Object.keys(errors).length > 0) return
|
||||
await navigate(applyUsageFilterDraft(appliedFilters.value.range, draft.value))
|
||||
await unsavedChanges.runWithoutPrompt(() => navigate(applyUsageFilterDraft(draft.value)))
|
||||
}
|
||||
|
||||
async function resetFilters(): Promise<void> {
|
||||
function resetFilters(): void {
|
||||
draft.value = resetUsageFilterDraft()
|
||||
filterErrors.value = {}
|
||||
await navigate({ range: appliedFilters.value.range })
|
||||
}
|
||||
|
||||
async function navigate(filters: UsageFilters): Promise<void> {
|
||||
@@ -119,12 +133,7 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
>
|
||||
<div class="usage-filter-grid">
|
||||
<FormField id="usage-range" :label="t('monitor.usage.filters.range')">
|
||||
<select
|
||||
id="usage-range"
|
||||
data-test="usage-range"
|
||||
:value="appliedFilters.range"
|
||||
@change="changeRange"
|
||||
>
|
||||
<select id="usage-range" v-model="draft.range" data-test="usage-range">
|
||||
<option value="24h">{{ t('monitor.usage.filters.range24h') }}</option>
|
||||
<option value="30d">{{ t('monitor.usage.filters.range30d') }}</option>
|
||||
</select>
|
||||
@@ -207,6 +216,7 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
data-test="usage-refresh"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:busy="usageQuery.isFetching.value"
|
||||
@click="usageQuery.refetch()"
|
||||
>
|
||||
<RefreshCw :size="16" aria-hidden="true" />{{ t('monitor.usage.filters.refresh') }}
|
||||
@@ -218,6 +228,30 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="usage-applied" data-test="usage-applied-filters">
|
||||
<strong>{{ t('monitor.usage.filters.applied') }}</strong>
|
||||
<span class="usage-filter-chip">
|
||||
{{
|
||||
appliedFilters.range === '30d'
|
||||
? t('monitor.usage.filters.range30d')
|
||||
: t('monitor.usage.filters.range24h')
|
||||
}}
|
||||
</span>
|
||||
<span class="usage-filter-chip">
|
||||
{{
|
||||
appliedFilters.group_id === undefined
|
||||
? t('monitor.usage.filters.anyGroup')
|
||||
: groupLabel(appliedFilters.group_id)
|
||||
}}
|
||||
</span>
|
||||
<span class="usage-filter-chip">
|
||||
{{ appliedFilters.model ?? t('monitor.usage.filters.anyModel') }}
|
||||
</span>
|
||||
</section>
|
||||
<InlineFeedback v-if="draftDirty" data-test="usage-filter-dirty" tone="info">
|
||||
{{ t('monitor.usage.filters.dirty') }}
|
||||
</InlineFeedback>
|
||||
|
||||
<InlineFeedback
|
||||
v-if="groupsQuery.isError.value"
|
||||
data-test="usage-group-options-failed"
|
||||
@@ -256,6 +290,21 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
@retry="usageQuery.refetch()"
|
||||
/>
|
||||
|
||||
<section class="usage-freshness" data-test="usage-freshness">
|
||||
<p>
|
||||
<strong>{{ t('monitor.usage.filters.observedAt') }}</strong>
|
||||
<time data-test="usage-observed-at" :datetime="report.observed_at">
|
||||
{{ report.observed_at }}
|
||||
</time>
|
||||
</p>
|
||||
<p v-if="lastRefreshedAt">
|
||||
<strong>{{ t('monitor.usage.filters.refreshedAt') }}</strong>
|
||||
<time data-test="usage-refreshed-at" :datetime="lastRefreshedAt.toISOString()">
|
||||
{{ lastRefreshedAt.toISOString() }}
|
||||
</time>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<EmptyState
|
||||
v-if="!hasData"
|
||||
data-test="usage-empty"
|
||||
@@ -606,7 +655,9 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
}
|
||||
|
||||
.usage-filter-form,
|
||||
.usage-scope {
|
||||
.usage-scope,
|
||||
.usage-applied,
|
||||
.usage-freshness {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--color-surface);
|
||||
@@ -639,7 +690,9 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
|
||||
.usage-filter-actions,
|
||||
.usage-scope,
|
||||
.usage-heading {
|
||||
.usage-heading,
|
||||
.usage-applied,
|
||||
.usage-freshness {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
@@ -651,6 +704,30 @@ async function navigate(filters: UsageFilters): Promise<void> {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.usage-applied {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.usage-filter-chip {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-secondary);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.usage-freshness {
|
||||
justify-content: space-between;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.usage-freshness p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-scope > svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-primary);
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('usage filters', () => {
|
||||
const applied = { range: '30d' as const, group_id: 7, model: 'provider/model:Exact' }
|
||||
|
||||
expect(createUsageFilterDraft(applied)).toEqual({
|
||||
range: '30d',
|
||||
group_id: '7',
|
||||
model: 'provider/model:Exact',
|
||||
})
|
||||
@@ -21,28 +22,36 @@ describe('usage filters', () => {
|
||||
expect(
|
||||
parseAppliedUsageFilters({ tab: 'usage', range: '30d', group_id: '7', model: 'gpt-5' }),
|
||||
).toEqual({ range: '30d', group_id: 7, model: 'gpt-5' })
|
||||
expect(applyUsageFilterDraft('24h', { group_id: '8', model: 'provider/model:Exact' })).toEqual({
|
||||
range: '24h',
|
||||
group_id: 8,
|
||||
model: 'provider/model:Exact',
|
||||
})
|
||||
expect(
|
||||
applyUsageFilterDraft({
|
||||
range: '24h',
|
||||
group_id: '8',
|
||||
model: 'provider/model:Exact',
|
||||
}),
|
||||
).toEqual({ range: '24h', group_id: 8, model: 'provider/model:Exact' })
|
||||
})
|
||||
|
||||
it('resets draft-only filters without changing the selected range', () => {
|
||||
expect(resetUsageFilterDraft()).toEqual({ group_id: '', model: '' })
|
||||
expect(applyUsageFilterDraft('30d', resetUsageFilterDraft())).toEqual({ range: '30d' })
|
||||
it('resets the complete draft to the canonical default without applying it', () => {
|
||||
expect(resetUsageFilterDraft()).toEqual({ range: '24h', group_id: '', model: '' })
|
||||
expect(applyUsageFilterDraft(resetUsageFilterDraft())).toEqual({ range: '24h' })
|
||||
})
|
||||
|
||||
it('reports ASCII and Unicode control characters without retaining unsafe values in URL state', () => {
|
||||
expect(validateUsageFilterDraft({ group_id: '0', model: ' leading-model' })).toEqual({
|
||||
expect(
|
||||
validateUsageFilterDraft({
|
||||
range: '30d',
|
||||
group_id: '0',
|
||||
model: ' leading-model',
|
||||
}),
|
||||
).toEqual({
|
||||
group_id: 'monitor.usage.errors.positiveId',
|
||||
model: 'monitor.usage.errors.model',
|
||||
})
|
||||
expect(parseAppliedUsageFilters({ range: '24h', model: 'gpt-\u00855.6' })).toEqual({
|
||||
range: '24h',
|
||||
})
|
||||
expect(validateUsageFilterDraft({ group_id: '', model: 'gpt-\u00855.6' })).toEqual({
|
||||
model: 'monitor.usage.errors.model',
|
||||
})
|
||||
expect(
|
||||
validateUsageFilterDraft({ range: '24h', group_id: '', model: 'gpt-\u00855.6' }),
|
||||
).toEqual({ model: 'monitor.usage.errors.model' })
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { UsageFilters } from '@/api/control/usage'
|
||||
|
||||
export interface UsageFilterDraft {
|
||||
range: UsageFilters['range']
|
||||
group_id: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type UsageFilterErrors = Partial<Record<keyof UsageFilterDraft, string>>
|
||||
export type UsageFilterErrors = Partial<Record<Exclude<keyof UsageFilterDraft, 'range'>, string>>
|
||||
|
||||
const emptyDraft = (): UsageFilterDraft => ({ group_id: '', model: '' })
|
||||
const emptyDraft = (): UsageFilterDraft => ({ range: '24h', group_id: '', model: '' })
|
||||
|
||||
export function normalizeUsageRange(raw: unknown): UsageFilters['range'] {
|
||||
return raw === '30d' ? '30d' : '24h'
|
||||
@@ -43,6 +44,7 @@ export function parseAppliedUsageFilters(query: Record<string, unknown>): UsageF
|
||||
export function createUsageFilterDraft(filters: UsageFilters): UsageFilterDraft {
|
||||
return {
|
||||
...emptyDraft(),
|
||||
range: filters.range,
|
||||
group_id: filters.group_id === undefined ? '' : String(filters.group_id),
|
||||
model: filters.model ?? '',
|
||||
}
|
||||
@@ -52,11 +54,8 @@ export function resetUsageFilterDraft(): UsageFilterDraft {
|
||||
return emptyDraft()
|
||||
}
|
||||
|
||||
export function applyUsageFilterDraft(
|
||||
range: UsageFilters['range'],
|
||||
draft: UsageFilterDraft,
|
||||
): UsageFilters {
|
||||
const filters: UsageFilters = { range: normalizeUsageRange(range) }
|
||||
export function applyUsageFilterDraft(draft: UsageFilterDraft): UsageFilters {
|
||||
const filters: UsageFilters = { range: normalizeUsageRange(draft.range) }
|
||||
const groupID = normalizeUsageGroupID(draft.group_id)
|
||||
const model = normalizeUsageModel(draft.model)
|
||||
if (groupID !== undefined) filters.group_id = groupID
|
||||
|
||||
@@ -114,6 +114,11 @@ export default {
|
||||
refresh: 'Refresh',
|
||||
apply: 'Apply',
|
||||
reset: 'Reset',
|
||||
applied: 'Applied filters',
|
||||
anyModel: 'All upstream models',
|
||||
dirty: 'Filter changes are not applied yet.',
|
||||
observedAt: 'Backend observed',
|
||||
refreshedAt: 'Last refreshed',
|
||||
},
|
||||
errors: {
|
||||
positiveId: 'Enter a valid positive Group ID.',
|
||||
|
||||
@@ -116,6 +116,11 @@ export default {
|
||||
refresh: '更新',
|
||||
apply: '適用',
|
||||
reset: 'リセット',
|
||||
applied: '適用中のフィルター',
|
||||
anyModel: 'すべてのアップストリームモデル',
|
||||
dirty: 'フィルターの変更はまだ適用されていません。',
|
||||
observedAt: 'バックエンド観測時刻',
|
||||
refreshedAt: '最終更新時刻',
|
||||
},
|
||||
errors: {
|
||||
positiveId: '有効な正の Group ID を入力してください。',
|
||||
|
||||
@@ -112,6 +112,11 @@ export default {
|
||||
refresh: '刷新',
|
||||
apply: '应用',
|
||||
reset: '重置',
|
||||
applied: '已应用筛选',
|
||||
anyModel: '全部上游模型',
|
||||
dirty: '筛选条件的更改尚未应用。',
|
||||
observedAt: '后端观测时间',
|
||||
refreshedAt: '最近刷新时间',
|
||||
},
|
||||
errors: {
|
||||
positiveId: '请输入有效的正整数 Group ID。',
|
||||
|
||||
Reference in new issue
Block a user