From e4e9220b70eaae85d0cc7ab13cc237602345d684 Mon Sep 17 00:00:00 2001 From: tbphp Date: Tue, 28 Jul 2026 22:42:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=BB=9F=E4=B8=80=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E7=AD=9B=E9=80=89=E8=8D=89=E7=A8=BF=E4=B8=8E=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/features/monitor/UsageTab.test.ts | 48 +++++-- web/src/features/monitor/UsageTab.vue | 119 ++++++++++++++---- .../features/monitor/usage-filters.test.ts | 33 +++-- web/src/features/monitor/usage-filters.ts | 13 +- web/src/i18n/locales/en-US/monitor.ts | 5 + web/src/i18n/locales/ja-JP/monitor.ts | 5 + web/src/i18n/locales/zh-CN/monitor.ts | 5 + 7 files changed, 177 insertions(+), 51 deletions(-) diff --git a/web/src/features/monitor/UsageTab.test.ts b/web/src/features/monitor/UsageTab.test.ts index d41c06e4..0dd767be 100644 --- a/web/src/features/monitor/UsageTab.test.ts +++ b/web/src/features/monitor/UsageTab.test.ts @@ -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('[data-test="usage-range"]').element.value).toBe('24h') + expect(wrapper.get('[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('[data-test="usage-range"]').element.value).toBe('30d') expect(wrapper.get('[data-test="usage-group"]').element.value).toBe('') expect(wrapper.get('[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 () => { diff --git a/web/src/features/monitor/UsageTab.vue b/web/src/features/monitor/UsageTab.vue index 5c822fbb..a810c617 100644 --- a/web/src/features/monitor/UsageTab.vue +++ b/web/src/features/monitor/UsageTab.vue @@ -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 { - const range = (event.target as HTMLSelectElement).value === '30d' ? '30d' : '24h' - await navigate({ ...appliedFilters.value, range }) -} - async function applyFilters(): Promise { 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 { +function resetFilters(): void { draft.value = resetUsageFilterDraft() filterErrors.value = {} - await navigate({ range: appliedFilters.value.range }) } async function navigate(filters: UsageFilters): Promise { @@ -119,12 +133,7 @@ async function navigate(filters: UsageFilters): Promise { >
- @@ -207,6 +216,7 @@ async function navigate(filters: UsageFilters): Promise { data-test="usage-refresh" type="button" variant="secondary" + :busy="usageQuery.isFetching.value" @click="usageQuery.refetch()" >
+
+ {{ t('monitor.usage.filters.applied') }} + + {{ + appliedFilters.range === '30d' + ? t('monitor.usage.filters.range30d') + : t('monitor.usage.filters.range24h') + }} + + + {{ + appliedFilters.group_id === undefined + ? t('monitor.usage.filters.anyGroup') + : groupLabel(appliedFilters.group_id) + }} + + + {{ appliedFilters.model ?? t('monitor.usage.filters.anyModel') }} + +
+ + {{ t('monitor.usage.filters.dirty') }} + + { @retry="usageQuery.refetch()" /> +
+

+ {{ t('monitor.usage.filters.observedAt') }} + +

+

+ {{ t('monitor.usage.filters.refreshedAt') }} + +

+
+ { } .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 { .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 { 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); diff --git a/web/src/features/monitor/usage-filters.test.ts b/web/src/features/monitor/usage-filters.test.ts index 059a54e9..94020642 100644 --- a/web/src/features/monitor/usage-filters.test.ts +++ b/web/src/features/monitor/usage-filters.test.ts @@ -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' }) }) }) diff --git a/web/src/features/monitor/usage-filters.ts b/web/src/features/monitor/usage-filters.ts index f0a7cb98..52f16d2b 100644 --- a/web/src/features/monitor/usage-filters.ts +++ b/web/src/features/monitor/usage-filters.ts @@ -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> +export type UsageFilterErrors = Partial, 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): 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 diff --git a/web/src/i18n/locales/en-US/monitor.ts b/web/src/i18n/locales/en-US/monitor.ts index 86bee014..5ea89a3d 100644 --- a/web/src/i18n/locales/en-US/monitor.ts +++ b/web/src/i18n/locales/en-US/monitor.ts @@ -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.', diff --git a/web/src/i18n/locales/ja-JP/monitor.ts b/web/src/i18n/locales/ja-JP/monitor.ts index c8a5705f..9ab5959c 100644 --- a/web/src/i18n/locales/ja-JP/monitor.ts +++ b/web/src/i18n/locales/ja-JP/monitor.ts @@ -116,6 +116,11 @@ export default { refresh: '更新', apply: '適用', reset: 'リセット', + applied: '適用中のフィルター', + anyModel: 'すべてのアップストリームモデル', + dirty: 'フィルターの変更はまだ適用されていません。', + observedAt: 'バックエンド観測時刻', + refreshedAt: '最終更新時刻', }, errors: { positiveId: '有効な正の Group ID を入力してください。', diff --git a/web/src/i18n/locales/zh-CN/monitor.ts b/web/src/i18n/locales/zh-CN/monitor.ts index b2c77836..f8599f2a 100644 --- a/web/src/i18n/locales/zh-CN/monitor.ts +++ b/web/src/i18n/locales/zh-CN/monitor.ts @@ -112,6 +112,11 @@ export default { refresh: '刷新', apply: '应用', reset: '重置', + applied: '已应用筛选', + anyModel: '全部上游模型', + dirty: '筛选条件的更改尚未应用。', + observedAt: '后端观测时间', + refreshedAt: '最近刷新时间', }, errors: { positiveId: '请输入有效的正整数 Group ID。',