diff --git a/web/e2e/business-flows.spec.ts b/web/e2e/business-flows.spec.ts index 4aaec83a..c654ddfe 100644 --- a/web/e2e/business-flows.spec.ts +++ b/web/e2e/business-flows.spec.ts @@ -10,8 +10,10 @@ const upstreamURL = 'http://127.0.0.1:3108' const discoveredModel = 'e2e-model-one' const secondDiscoveredModel = 'e2e-model-two' const rpmLimit = '37' +const expectedUsageRequests = 3 test.use({ locale: 'en-US' }) +test.setTimeout(120_000) test('critical management journey works through the embedded binary', async ({ page }) => { const browserUpstreamRequests: string[] = [] @@ -23,6 +25,7 @@ test('critical management journey works through the embedded binary', async ({ p }) let groupID = '' + let issuedAccessKey = '' await test.step('login', async () => { await page.goto('/login') @@ -85,6 +88,9 @@ test('critical management journey works through the embedded binary', async ({ p await createDrawer.getByLabel('Requests per minute').fill(rpmLimit) await createDrawer.getByRole('button', { name: 'Save AccessKey' }).click() await expect(createDrawer.getByText('Plaintext AccessKey', { exact: true })).toBeVisible() + await createDrawer.locator('[data-test="access-key-result-reveal"]').click({ timeout: 10_000 }) + issuedAccessKey = await createDrawer.locator('.access-key-drawer__secret code').innerText() + expect(issuedAccessKey).toMatch(/^sk-gl-[0-9a-f]{32}$/) await createDrawer.getByRole('button', { name: 'Close AccessKey editor' }).click() const accessKeyRow = page.getByRole('row').filter({ @@ -116,6 +122,196 @@ test('critical management journey works through the embedded binary', async ({ p await editDrawer.getByRole('button', { name: 'Close AccessKey editor' }).click() }) + await test.step('manage a global price override through the canonical deep link', async () => { + await page.goto('/settings/model-prices') + await expect(page).toHaveURL('/settings/model-prices') + await expect(page.getByRole('heading', { name: 'Model prices' })).toBeVisible() + + const addPrice = page.locator('[data-test="model-price-add"]') + await addPrice.click() + const addDrawer = page.getByRole('dialog', { name: 'Add model price override' }) + await addDrawer.getByLabel('Model pattern').fill('*') + await addDrawer.getByLabel('Uncached input').fill('1') + await expect(addDrawer.getByRole('button', { name: 'Save override' })).toBeDisabled() + await addDrawer.getByLabel('I understand that * shadows every built-in price rule.').check() + await addDrawer.getByRole('button', { name: 'Save override' }).click() + + const overrideRow = page.locator('[data-test="override-price-row-0"]') + await expect(overrideRow).toContainText('*') + await expect(overrideRow).toContainText('$1') + + const editPrice = page.locator('[data-test="override-price-edit-0"]') + await editPrice.click() + const editDrawer = page.getByRole('dialog', { name: 'Edit model price override' }) + await expect(editDrawer.getByLabel('Model pattern')).toHaveAttribute('readonly', '') + await editDrawer.getByLabel('Output').fill('2') + await editDrawer.getByLabel('I understand that * shadows every built-in price rule.').check() + await editDrawer.getByRole('button', { name: 'Save override' }).click() + await expect(editPrice).toBeFocused() + await expect(page.locator('[data-test="override-price-row-0"]')).toContainText('$2') + + const resetPrice = page.locator('[data-test="model-price-reset-open"]') + await resetPrice.click() + const resetDialog = page.getByRole('dialog', { + name: 'Reset this model price override?', + }) + await resetDialog.getByRole('button', { name: 'Cancel' }).click() + await expect(resetPrice).toBeFocused() + await resetPrice.click() + await resetDialog.getByRole('button', { name: 'Reset override' }).click() + await expect(page.getByText('No overrides configured', { exact: true })).toBeVisible() + }) + + await test.step('produce persisted complete, partial, and missing usage safely', async () => { + for (const [content, stream] of [ + ['missing-usage', false], + ['partial-usage', true], + ['complete-usage', false], + ] as const) { + const response = await page.request.post('/v1/chat/completions', { + headers: { + Authorization: `Bearer ${issuedAccessKey}`, + 'Content-Type': 'application/json', + }, + data: { + model: discoveredModel, + messages: [{ role: 'user', content }], + stream, + }, + }) + expect(response.status()).toBe(200) + } + + await expect + .poll( + async () => { + const response = await page.request.get('/api/logs', { + headers: { Authorization: `Bearer ${authKey}` }, + }) + if (!response.ok()) return -1 + const payload = (await response.json()) as { data?: { items?: unknown[] } } + return payload.data?.items?.length ?? -1 + }, + { message: 'wait for all request logs to persist' }, + ) + .toBe(expectedUsageRequests) + + await expect + .poll( + async () => { + const response = await page.request.get('/api/usage?range=24h', { + headers: { Authorization: `Bearer ${authKey}` }, + }) + if (!response.ok()) return { requestCount: -1, status: response.status() } + const payload = (await response.json()) as { + data?: { summary?: { request_count?: number } } + } + return { + requestCount: payload.data?.summary?.request_count ?? -1, + status: response.status(), + } + }, + { message: 'wait for the usage aggregate to include all persisted requests' }, + ) + .toEqual({ requestCount: expectedUsageRequests, status: 200 }) + }) + + await test.step('review usage quality, request detail usage, and the Home summary', async () => { + await page.goto('/monitor?tab=usage&range=24h') + await expect(page).toHaveURL('/monitor?tab=usage&range=24h') + await expect(page.locator('[data-test="usage-quality-missing"]')).toContainText('1') + await expect(page.locator('[data-test="usage-quality-partial"]')).toContainText('1') + await expect(page.locator('[data-test="usage-quality-unpriced"]')).toContainText('3') + await expect(page.locator('[data-test="usage-kpi-cost"]')).toContainText('Unknown') + await expect(page.locator('[data-test="usage-kpi-cost"]')).not.toContainText('$0') + await expect(page.locator('[data-test="usage-kpi-cost"]')).not.toContainText('Free') + await expect(page.locator('[data-test="usage-scope"]')).toContainText('current process') + await expect(page.locator('[data-test="usage-prices-link"]')).toHaveAttribute( + 'href', + '/settings/model-prices', + ) + + await page.goto('/monitor?tab=logs') + const firstLogRow = page.locator('[data-test^="log-row-"]').first() + await expect(firstLogRow).toBeVisible() + await firstLogRow.getByRole('button', { name: 'View details' }).click() + const logDrawer = page.getByRole('dialog', { name: 'Request log details' }) + const logUsage = logDrawer.locator('[data-test="log-usage-cost"]') + await expect(logUsage).toContainText('Complete usage') + await expect(logUsage).toContainText('Estimated cost unknown') + await expect(logUsage).toContainText('Uncached input') + await expect(logUsage).toContainText('11') + await expect(logDrawer.locator('[data-test="log-usage-prices-link"]')).toHaveAttribute( + 'href', + '/settings/model-prices', + ) + await logDrawer.getByRole('button', { name: 'Close request log details' }).click() + + await page.goto('/') + await expect(page.locator('[data-test="home-usage-requests"]')).toContainText( + String(expectedUsageRequests), + ) + await expect(page.locator('[data-test="home-usage-cost"]')).toContainText('Unknown') + await expect(page.locator('[data-test="home-usage-detail"]')).toHaveAttribute( + 'href', + '/monitor?tab=usage&range=24h', + ) + + const visibleText = await page.locator('body').innerText() + for (const secret of [authKey, firstUpstreamKey, secondUpstreamKey, issuedAccessKey]) { + expect(visibleText).not.toContain(secret) + } + }) + + await test.step('verify responsive light and dark layouts with table-local overflow', async () => { + const viewports = [ + { width: 375, height: 812 }, + { width: 768, height: 900 }, + { width: 1024, height: 900 }, + { width: 1440, height: 900 }, + ] as const + + for (const theme of ['light', 'dark'] as const) { + await page.evaluate((value) => localStorage.setItem('gpt-load.theme', value), theme) + for (const viewport of viewports) { + await page.setViewportSize(viewport) + await page.goto('/') + await expect(page.locator('html')).toHaveAttribute('data-theme', theme) + await expect(page.locator('[data-test="home-usage-requests"]')).toBeVisible() + + const layout = await page.evaluate(() => { + const overview = document.querySelector('.home-overview')?.getBoundingClientRect() + const usage = document.querySelector('.usage-summary-section')?.getBoundingClientRect() + const groups = document.querySelector('.groups-section')?.getBoundingClientRect() + return { + documentWidth: document.documentElement.scrollWidth, + overviewBottom: overview?.bottom ?? -1, + usageBottom: usage?.bottom ?? -1, + usageTop: usage?.top ?? -1, + groupsTop: groups?.top ?? -1, + viewportWidth: window.innerWidth, + } + }) + expect(layout.documentWidth).toBeLessThanOrEqual(layout.viewportWidth) + expect(layout.usageTop).toBeGreaterThanOrEqual(layout.overviewBottom) + expect(layout.groupsTop).toBeGreaterThanOrEqual(layout.usageBottom) + } + } + + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/settings/model-prices') + const priceTable = page.locator('.data-table__container').first() + await expect(priceTable).toBeVisible() + const overflow = await priceTable.evaluate((table) => ({ + client: table.clientWidth, + scroll: table.scrollWidth, + document: document.documentElement.scrollWidth, + viewport: window.innerWidth, + })) + expect(overflow.scroll).toBeGreaterThan(overflow.client) + expect(overflow.document).toBeLessThanOrEqual(overflow.viewport) + }) + await test.step('inspect current route', async () => { await page.setViewportSize({ width: 375, height: 812 }) await page.goto('/monitor?tab=inspector') diff --git a/web/e2e/start-e2e-server.mjs b/web/e2e/start-e2e-server.mjs index c046053d..09b12150 100644 --- a/web/e2e/start-e2e-server.mjs +++ b/web/e2e/start-e2e-server.mjs @@ -36,13 +36,67 @@ const modelList = JSON.stringify({ }, ], }) -const fakeUpstream = createServer((request, response) => { +async function requestBody(request) { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + return Buffer.concat(chunks).toString('utf8') +} + +const fakeUpstream = createServer(async (request, response) => { if (request.method === 'GET' && request.url === '/v1/models') { response.writeHead(200, { 'content-type': 'application/json' }) response.end(modelList) return } + if (request.method === 'POST' && request.url === '/v1/chat/completions') { + let payload + try { + payload = JSON.parse(await requestBody(request)) + } catch { + response.writeHead(400, { 'content-type': 'application/json' }) + response.end('{"error":{"message":"invalid request"}}') + return + } + const content = payload.messages?.[0]?.content + if (content === 'partial-usage' && payload.stream === true) { + response.writeHead(200, { + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'content-type': 'text/event-stream', + }) + response.write( + 'data: {"id":"chatcmpl-e2e-partial","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"partial"}}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}\n\n', + ) + response.end('data: [DONE]\n\n') + return + } + + const result = { + id: `chatcmpl-e2e-${content === 'missing-usage' ? 'missing' : 'complete'}`, + object: 'chat.completion', + created: 1_735_689_600, + model: 'e2e-model-one', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + } + if (content !== 'missing-usage') { + result.usage = { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + } + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify(result)) + return + } + response.writeHead(404, { 'content-type': 'application/json' }) response.end('{"error":"not_found"}') }) diff --git a/web/src/api/control/usage.test.ts b/web/src/api/control/usage.test.ts index 64ccf892..0ad971b6 100644 --- a/web/src/api/control/usage.test.ts +++ b/web/src/api/control/usage.test.ts @@ -86,6 +86,26 @@ describe('Usage control API', () => { }) }) + it('accepts the current partial bucket when its start is inside the report range', () => { + const currentBucketReport = { + ...report, + observed_at: '2026-07-27T12:21:48Z', + range: { + ...report.range, + to: '2026-07-27T12:21:48Z', + }, + series: [ + { + ...report.series[0], + bucket_start: '2026-07-27T12:00:00Z', + bucket_end: '2026-07-27T13:00:00Z', + }, + ], + } + + expect(projectUsageReport(currentBucketReport)).toEqual(currentBucketReport) + }) + it.each([ ['non-object report', null], ['missing aggregate field', { ...report, summary: { ...aggregate, total_tokens: undefined } }], diff --git a/web/src/api/control/usage.ts b/web/src/api/control/usage.ts index 1836dbe7..b3b63539 100644 --- a/web/src/api/control/usage.ts +++ b/web/src/api/control/usage.ts @@ -180,7 +180,7 @@ export function projectUsageReport(value: unknown): UsageReportDto { const bucketEnd = timestamp(item.bucket_end) const start = timestampMs(bucketStart) const end = timestampMs(bucketEnd) - if (start < previousBucketEnd || start >= end || end > timestampMs(rangeTo)) { + if (start < previousBucketEnd || start >= end || start >= timestampMs(rangeTo)) { throw new InvalidResponseError() } previousBucketEnd = end diff --git a/web/src/features/home/HomeView.test.ts b/web/src/features/home/HomeView.test.ts index 384da251..98bb4173 100644 --- a/web/src/features/home/HomeView.test.ts +++ b/web/src/features/home/HomeView.test.ts @@ -2,6 +2,7 @@ import { QueryClient } from '@tanstack/vue-query' import { flushPromises } from '@vue/test-utils' import { ApiError } from '@/api/errors' +import type { UsageReportDto } from '@/api/control/usage' import { controlQueryKeys } from '@/app/query-keys' import type { AccessKeyDto, GroupSummary, RuntimeHealthDto } from '@/api/control/types' import { FakeApi } from '@/test/fake-api' @@ -59,7 +60,37 @@ const healthFixture: RuntimeHealthDto = { }, } +const usageFixture: UsageReportDto = { + observed_at: '2026-07-25T10:00:01Z', + range: { + from: '2026-07-24T10:00:00Z', + to: '2026-07-25T10:00:00Z', + granularity: 'hour', + }, + filters: { group_id: null, model: '' }, + summary: { + request_count: 4, + success_count: 3, + failure_count: 1, + uncached_input_tokens: 10, + cache_read_tokens: 2, + cache_write_5m_tokens: 0, + cache_write_1h_tokens: 0, + output_tokens: 5, + total_tokens: 17, + estimated_cost_usd: 0.0025, + usage_missing_count: 0, + partial_count: 0, + unpriced_request_count: 0, + }, + series: [], + breakdown: [], + breakdown_truncated: false, + request_log: healthFixture.request_log, +} + async function mountHome(api: FakeApi, origin?: string, queryClient?: QueryClient) { + api.when('/api/usage?range=24h').resolve(usageFixture) const mounted = await mountApp(HomeView, { api, queryClient: queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }), @@ -97,6 +128,26 @@ describe('HomeView', () => { expect(wrapper.text()).toContain('无法加载 Group') }) + it('keeps overview and Groups mounted when the Usage summary fails independently', async () => { + const api = new FakeApi() + api.when('/api/groups').resolve([groupFixture]) + api.when('/api/health').resolve(healthFixture) + api.when('/api/access-keys').resolve([accessKeyFixture]) + api + .when('/api/usage?range=24h') + .reject(new ApiError(500, 'INTERNAL_SERVER_ERROR', 'usage-secret-canary')) + + const wrapper = await mountHome(api) + + expect(wrapper.text()).toContain('在线') + expect(wrapper.text()).toContain('Base URL') + expect(wrapper.get('[data-group-id="1"]').text()).toContain('Example') + expect(wrapper.get('[data-test="home-usage-error"]').text()).toContain( + '无法加载最近 24 小时用量摘要', + ) + expect(wrapper.text()).not.toContain('usage-secret-canary') + }) + it('retains stale Health data when a background refresh fails', async () => { const api = new FakeApi() api.when('/api/groups').resolve([groupFixture]) @@ -153,7 +204,7 @@ describe('HomeView', () => { expect(wrapper.get('[aria-label="复制 Base URL"]').attributes()).not.toHaveProperty('disabled') }) - it('renders real health counts and no fabricated dashboard metrics', async () => { + it('renders real health and usage counts without fabricated health metrics or a chart', async () => { const api = new FakeApi() api.when('/api/groups').resolve([groupFixture]) api.when('/api/health').resolve(healthFixture) @@ -164,7 +215,9 @@ describe('HomeView', () => { expect(wrapper.text()).toContain('可用 1') expect(wrapper.text()).toContain('冷却 1') expect(wrapper.text()).toContain('修订 8') - expect(wrapper.text()).not.toMatch(/成功率|健康率|吞吐|Token|费用|趋势/) + expect(wrapper.text()).toContain('最近 24 小时') + expect(wrapper.text()).not.toMatch(/健康率|吞吐|趋势/) + expect(wrapper.find('[data-test="usage-sparkline"]').exists()).toBe(false) expect(wrapper.html()).not.toContain('ACCESS_KEY_CANARY') }) diff --git a/web/src/features/home/HomeView.vue b/web/src/features/home/HomeView.vue index d2b7f223..d644e9c1 100644 --- a/web/src/features/home/HomeView.vue +++ b/web/src/features/home/HomeView.vue @@ -18,6 +18,7 @@ import SurfaceCard from '@/components/ui/SurfaceCard.vue' import ConnectionCard from './ConnectionCard.vue' import GroupCard from './GroupCard.vue' +import UsageSummaryCard from './UsageSummaryCard.vue' const props = withDefaults(defineProps<{ origin?: string }>(), { origin: () => window.location.origin, @@ -157,6 +158,8 @@ const observedAt = computed(() => { + +
diff --git a/web/src/features/home/UsageSummaryCard.test.ts b/web/src/features/home/UsageSummaryCard.test.ts new file mode 100644 index 00000000..ca64cdf8 --- /dev/null +++ b/web/src/features/home/UsageSummaryCard.test.ts @@ -0,0 +1,194 @@ +import { QueryClient } from '@tanstack/vue-query' +import { flushPromises } from '@vue/test-utils' + +import type { ApiClient, ApiPath, ApiRequestOptions } from '@/api/client' +import type { UsageAggregateDto, UsageReportDto } from '@/api/control/usage' +import { controlQueryKeys } from '@/app/query-keys' +import { mountApp } from '@/test/mount-app' + +import UsageSummaryCard from './UsageSummaryCard.vue' + +const aggregate: UsageAggregateDto = { + request_count: 12, + success_count: 9, + failure_count: 3, + uncached_input_tokens: 100, + cache_read_tokens: 20, + cache_write_5m_tokens: 3, + cache_write_1h_tokens: 4, + output_tokens: 50, + total_tokens: 177, + estimated_cost_usd: 0.123456, + usage_missing_count: 2, + partial_count: 3, + unpriced_request_count: 4, +} + +function usageReport(overrides: Partial = {}): UsageReportDto { + return { + observed_at: '2026-07-27T04:00:01Z', + range: { + from: '2026-07-26T04:00:00Z', + to: '2026-07-27T04:00:00Z', + granularity: 'hour', + }, + filters: { group_id: null, model: '' }, + summary: { ...aggregate }, + series: [], + breakdown: [], + breakdown_truncated: false, + request_log: { + enqueued_total: 20, + persisted_total: 18, + dropped_not_running_total: 0, + dropped_queue_full_total: 1, + dropped_stopping_total: 0, + dropped_persist_failed_total: 1, + dropped_shutdown_total: 0, + dropped_total: 2, + write_failure_total: 1, + retention_delete_failure_total: 0, + queue_depth: 0, + queue_capacity: 100, + last_write_failure_at: '2026-07-27T03:00:00Z', + last_retention_failure_at: null, + }, + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +class UsageApi implements ApiClient { + readonly requests: Array<{ path: ApiPath; options?: ApiRequestOptions }> = [] + + constructor(private readonly report: UsageReportDto | Promise) {} + + request(path: ApiPath, options?: ApiRequestOptions): Promise { + this.requests.push({ path, options }) + if (path === '/api/usage?range=24h') return Promise.resolve(this.report as T) + throw new Error(`Unexpected request: ${path}`) + } +} + +function queryClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +async function mountSummary( + api: ApiClient, + client = queryClient(), + locale: 'zh-CN' | 'en-US' | 'ja-JP' = 'en-US', +) { + const mounted = await mountApp(UsageSummaryCard, { + api, + queryClient: client, + locale, + }) + await flushPromises() + return { ...mounted, queryClient: client } +} + +describe('UsageSummaryCard', () => { + it('uses the shared unfiltered 24-hour query and renders data with a canonical detail link', async () => { + const api = new UsageApi(usageReport()) + const { wrapper } = await mountSummary(api) + + expect(api.requests).toEqual([ + { + path: '/api/usage?range=24h', + options: { method: 'GET', signal: expect.any(AbortSignal) }, + }, + ]) + expect(wrapper.get('[data-test="home-usage-requests"]').text()).toContain('12') + expect(wrapper.get('[data-test="home-usage-tokens"]').text()).toContain('177') + expect(wrapper.get('[data-test="home-usage-cost"]').text()).toContain('$0.123456') + expect(wrapper.get('time').attributes('datetime')).toBe('2026-07-27T04:00:01Z') + expect( + wrapper.get('[data-test="home-usage-detail"]').attributes('href'), + ).toBe('/monitor?tab=usage&range=24h') + expect(wrapper.find('[data-test="usage-sparkline"]').exists()).toBe(false) + expect(wrapper.find('a[href="/settings/model-prices"]').exists()).toBe(false) + }) + + it('renders a true zero-request state without fabricating a success rate', async () => { + const report = usageReport({ + summary: { + ...aggregate, + request_count: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + estimated_cost_usd: 0, + usage_missing_count: 0, + partial_count: 0, + unpriced_request_count: 0, + }, + request_log: { + ...usageReport().request_log, + dropped_total: 0, + write_failure_total: 0, + }, + }) + const { wrapper } = await mountSummary(new UsageApi(report)) + + expect(wrapper.get('[data-test="home-usage-empty"]').text()).toContain('No persisted requests') + expect(wrapper.text()).not.toMatch(/NaN|100%/) + expect(wrapper.get('[data-test="home-usage-detail"]').attributes('href')).toBe( + '/monitor?tab=usage&range=24h', + ) + }) + + it('keeps incomplete, unpriced, dropped, and write-failure quality visible with process scope', async () => { + const report = usageReport({ + summary: { ...aggregate, estimated_cost_usd: 0 }, + }) + const { wrapper } = await mountSummary(new UsageApi(report)) + + expect(wrapper.get('[data-test="home-usage-cost"]').text()).toContain('Unknown') + expect(wrapper.get('[data-test="home-usage-cost"]').text()).not.toMatch(/\$0|Free/) + expect(wrapper.get('[data-test="home-usage-quality"]').text()).toContain('Missing 2') + expect(wrapper.get('[data-test="home-usage-quality"]').text()).toContain('Partial 3') + expect(wrapper.get('[data-test="home-usage-quality"]').text()).toContain('Unpriced 4') + expect(wrapper.get('[data-test="home-usage-pipeline-warning"]').text()).toMatch( + /current process/i, + ) + expect(wrapper.get('[data-test="home-usage-pipeline-warning"]').text()).toContain('Dropped 2') + expect(wrapper.get('[data-test="home-usage-pipeline-warning"]').text()).toContain( + 'Write failures 1', + ) + }) + + it('shows a safe initial error without exposing the transport error', async () => { + const { wrapper } = await mountSummary( + new UsageApi(Promise.reject(new Error('usage-secret-canary'))), + ) + + expect(wrapper.get('[data-test="home-usage-error"]').text()).toContain( + 'Unable to load the 24-hour usage summary', + ) + expect(wrapper.text()).not.toContain('usage-secret-canary') + }) + + it('retains stale data from the exact Monitor cache key when refresh fails', async () => { + const refresh = deferred() + const client = queryClient() + client.setQueryData(controlQueryKeys.usage.report({ range: '24h' }), usageReport()) + + const { wrapper } = await mountSummary(new UsageApi(refresh.promise), client) + refresh.reject(new Error('stale-secret-canary')) + await flushPromises() + + expect(wrapper.get('[data-test="home-usage-stale"]').text()).toContain('may be stale') + expect(wrapper.get('[data-test="home-usage-requests"]').text()).toContain('12') + expect(wrapper.text()).not.toContain('stale-secret-canary') + }) +}) diff --git a/web/src/features/home/UsageSummaryCard.vue b/web/src/features/home/UsageSummaryCard.vue new file mode 100644 index 00000000..1dbb8edd --- /dev/null +++ b/web/src/features/home/UsageSummaryCard.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index cbad707c..ef6f1be2 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -195,6 +195,31 @@ export default { copyAccessKey: 'Copy AccessKey', modelPlaceholder: 'When no real model is available, replace {model} with an actual model ID.', snippet: 'Client example', + usage: { + eyebrow: 'Usage summary', + title: 'Last 24 hours', + description: + 'Request counts use persisted records; token and estimated-cost totals exclude incomplete or unpriced usage.', + detail: 'View usage details', + loading: 'Loading the 24-hour usage summary…', + loadFailed: 'Unable to load the 24-hour usage summary.', + stale: 'The usage summary may be stale because its background refresh failed.', + empty: { + title: 'No persisted requests in the last 24 hours', + description: 'After a request is sent, its usage and estimated cost summary appears here.', + }, + requests: 'Persisted requests', + tokens: 'Reported tokens', + estimatedCost: 'Estimated cost', + cost: { + unknown: 'Unknown', + knownPlusUnknown: '{cost} + unknown', + }, + quality: 'Missing {missing} · Partial {partial} · Unpriced {unpriced}', + pipelineWarning: + 'Current process pipeline: Dropped {dropped} · Write failures {failures}. These cumulative values do not follow the 24-hour range.', + observedAt: 'Observed at', + }, }, monitor: { title: 'Monitor', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 3425977d..73529b51 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -192,6 +192,31 @@ export default { copyAccessKey: 'AccessKey をコピー', modelPlaceholder: '実在モデルがない場合は、{model} を実際のモデル ID に置き換えてください。', snippet: 'クライアント例', + usage: { + eyebrow: '使用量サマリー', + title: '過去 24 時間', + description: + 'リクエスト数は永続化された記録に基づきます。トークンと推定コストの合計には、不完全または価格未設定の使用量を含みません。', + detail: '使用量の詳細を表示', + loading: '24 時間の使用量サマリーを読み込み中…', + loadFailed: '24 時間の使用量サマリーを読み込めません。', + stale: 'バックグラウンド更新に失敗したため、使用量サマリーが古い可能性があります。', + empty: { + title: '過去 24 時間に永続化されたリクエストはありません', + description: 'リクエスト送信後、使用量と推定コストのサマリーがここに表示されます。', + }, + requests: '永続化リクエスト', + tokens: '報告済みトークン', + estimatedCost: '推定コスト', + cost: { + unknown: '不明', + knownPlusUnknown: '{cost} + 不明', + }, + quality: 'なし {missing} · 部分 {partial} · 未設定 {unpriced}', + pipelineWarning: + '現在プロセスのパイプライン: 破棄 {dropped} · 書き込み失敗 {failures}。これらの累積値は 24 時間の範囲には従いません。', + observedAt: '観測時刻', + }, }, monitor: { title: 'モニター', diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 15cef273..a6efdaa1 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -187,6 +187,30 @@ export default { copyAccessKey: '复制 AccessKey', modelPlaceholder: '未找到真实模型时,请将 {model} 替换为实际模型 ID。', snippet: '客户端示例', + usage: { + eyebrow: '用量摘要', + title: '最近 24 小时', + description: '请求数来自已持久化记录;Token 与预估成本合计不包含未完成或未定价用量。', + detail: '查看用量详情', + loading: '正在加载最近 24 小时用量摘要…', + loadFailed: '无法加载最近 24 小时用量摘要。', + stale: '后台刷新失败,用量摘要可能已过期。', + empty: { + title: '最近 24 小时没有已持久化请求', + description: '发送请求后,可在此查看用量与预估成本摘要。', + }, + requests: '已持久化请求', + tokens: '已报告 Token', + estimatedCost: '预估成本', + cost: { + unknown: '未知', + knownPlusUnknown: '{cost} + 未知', + }, + quality: '缺失 {missing} · 部分 {partial} · 未定价 {unpriced}', + pipelineWarning: + '当前进程写入管线:丢弃 {dropped} · 写入失败 {failures};这些累计值不受 24 小时范围限制。', + observedAt: '观测时间', + }, }, monitor: { title: '监控',