feat(web): 完成首页用量摘要与 S4 验收
This commit is contained in:
1 parent
592966b344
commit
25e4eaebe5
11 files changed
+841
-4
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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"}')
|
||||
})
|
||||
|
||||
@@ -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 } }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UsageSummaryCard />
|
||||
|
||||
<section class="groups-section" aria-labelledby="groups-heading">
|
||||
<header class="groups-section__header">
|
||||
<div>
|
||||
|
||||
@@ -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> = {}): 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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((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<UsageReportDto>) {}
|
||||
|
||||
request<T>(path: ApiPath, options?: ApiRequestOptions): Promise<T> {
|
||||
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<HTMLAnchorElement>('[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<UsageReportDto>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { Activity, CircleDollarSign, Database, TriangleAlert } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useApiClient } from '@/api/client-context'
|
||||
import { getUsageReport, type UsageAggregateDto } from '@/api/control/usage'
|
||||
import { controlQueryKeys } from '@/app/query-keys'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import SurfaceCard from '@/components/ui/SurfaceCard.vue'
|
||||
|
||||
const filters = { range: '24h' } as const
|
||||
const client = useApiClient()
|
||||
const { locale, t } = useI18n()
|
||||
const usageQuery = useQuery({
|
||||
queryKey: controlQueryKeys.usage.report(filters),
|
||||
queryFn: ({ signal }) => getUsageReport(client, filters, signal),
|
||||
})
|
||||
const report = computed(() => usageQuery.data.value)
|
||||
const hasQualityWarning = computed(() => {
|
||||
const summary = report.value?.summary
|
||||
return Boolean(
|
||||
summary &&
|
||||
(summary.usage_missing_count > 0 ||
|
||||
summary.partial_count > 0 ||
|
||||
summary.unpriced_request_count > 0),
|
||||
)
|
||||
})
|
||||
const hasPipelineWarning = computed(() =>
|
||||
Boolean(
|
||||
report.value &&
|
||||
(report.value.request_log.dropped_total > 0 ||
|
||||
report.value.request_log.write_failure_total > 0),
|
||||
),
|
||||
)
|
||||
const observedAt = computed(() => {
|
||||
if (!report.value) return ''
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(report.value.observed_at))
|
||||
})
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat(locale.value).format(value)
|
||||
}
|
||||
|
||||
function formatEstimatedCost(aggregate: UsageAggregateDto): string {
|
||||
if (aggregate.unpriced_request_count > 0) {
|
||||
if (aggregate.estimated_cost_usd === 0) return t('home.usage.cost.unknown')
|
||||
return t('home.usage.cost.knownPlusUnknown', {
|
||||
cost: new Intl.NumberFormat(locale.value, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
}).format(aggregate.estimated_cost_usd),
|
||||
})
|
||||
}
|
||||
return new Intl.NumberFormat(locale.value, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
}).format(aggregate.estimated_cost_usd)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="usage-summary-section" aria-labelledby="home-usage-heading">
|
||||
<SurfaceCard class="usage-summary-card">
|
||||
<header class="usage-summary-card__header">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t('home.usage.eyebrow') }}</p>
|
||||
<h2 id="home-usage-heading">{{ t('home.usage.title') }}</h2>
|
||||
<p>{{ t('home.usage.description') }}</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
class="usage-summary-card__detail"
|
||||
data-test="home-usage-detail"
|
||||
to="/monitor?tab=usage&range=24h"
|
||||
>
|
||||
{{ t('home.usage.detail') }}
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<QueryFeedback
|
||||
v-if="usageQuery.isPending.value"
|
||||
state="loading"
|
||||
:message="t('home.usage.loading')"
|
||||
/>
|
||||
<QueryFeedback
|
||||
v-else-if="usageQuery.isError.value && !report"
|
||||
data-test="home-usage-error"
|
||||
state="error"
|
||||
:message="t('home.usage.loadFailed')"
|
||||
:retry-label="t('common.retry')"
|
||||
@retry="usageQuery.refetch()"
|
||||
/>
|
||||
<template v-else-if="report">
|
||||
<QueryFeedback
|
||||
v-if="usageQuery.isError.value"
|
||||
data-test="home-usage-stale"
|
||||
state="stale"
|
||||
:message="t('home.usage.stale')"
|
||||
:retry-label="t('common.retry')"
|
||||
@retry="usageQuery.refetch()"
|
||||
/>
|
||||
|
||||
<EmptyState
|
||||
v-if="report.summary.request_count === 0"
|
||||
data-test="home-usage-empty"
|
||||
:title="t('home.usage.empty.title')"
|
||||
:description="t('home.usage.empty.description')"
|
||||
/>
|
||||
<div v-else class="usage-summary-card__metrics">
|
||||
<div data-test="home-usage-requests">
|
||||
<Activity :size="19" aria-hidden="true" />
|
||||
<span>{{ t('home.usage.requests') }}</span>
|
||||
<strong>{{ formatCount(report.summary.request_count) }}</strong>
|
||||
</div>
|
||||
<div data-test="home-usage-tokens">
|
||||
<Database :size="19" aria-hidden="true" />
|
||||
<span>{{ t('home.usage.tokens') }}</span>
|
||||
<strong>{{ formatCount(report.summary.total_tokens) }}</strong>
|
||||
</div>
|
||||
<div data-test="home-usage-cost">
|
||||
<CircleDollarSign :size="19" aria-hidden="true" />
|
||||
<span>{{ t('home.usage.estimatedCost') }}</span>
|
||||
<strong>{{ formatEstimatedCost(report.summary) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineFeedback v-if="hasQualityWarning" data-test="home-usage-quality" tone="warning">
|
||||
<TriangleAlert :size="16" aria-hidden="true" />
|
||||
{{
|
||||
t('home.usage.quality', {
|
||||
missing: formatCount(report.summary.usage_missing_count),
|
||||
partial: formatCount(report.summary.partial_count),
|
||||
unpriced: formatCount(report.summary.unpriced_request_count),
|
||||
})
|
||||
}}
|
||||
</InlineFeedback>
|
||||
<InlineFeedback
|
||||
v-if="hasPipelineWarning"
|
||||
data-test="home-usage-pipeline-warning"
|
||||
tone="danger"
|
||||
>
|
||||
<TriangleAlert :size="16" aria-hidden="true" />
|
||||
{{
|
||||
t('home.usage.pipelineWarning', {
|
||||
dropped: formatCount(report.request_log.dropped_total),
|
||||
failures: formatCount(report.request_log.write_failure_total),
|
||||
})
|
||||
}}
|
||||
</InlineFeedback>
|
||||
<p class="usage-summary-card__observed">
|
||||
{{ t('home.usage.observedAt') }}
|
||||
<time :datetime="report.observed_at">{{ observedAt }}</time>
|
||||
</p>
|
||||
</template>
|
||||
</SurfaceCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-summary-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.usage-summary-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.usage-summary-card__header h2,
|
||||
.usage-summary-card__header p {
|
||||
margin: 0;
|
||||
}
|
||||
.usage-summary-card__header h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.usage-summary-card__header h2 + p {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.usage-summary-card__detail {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--color-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
.usage-summary-card__detail:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.usage-summary-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.usage-summary-card__metrics > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.usage-summary-card__metrics span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.usage-summary-card__metrics strong {
|
||||
grid-column: 1 / -1;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.usage-summary-card__observed {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.usage-summary-card__header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.usage-summary-card__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'モニター',
|
||||
|
||||
@@ -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: '监控',
|
||||
|
||||
Reference in new issue
Block a user