feat(web): 完成首页用量摘要与 S4 验收

This commit is contained in:
tbphp committed 2026-07-27 03:31:52 +08:00
1 parent 592966b344
commit 25e4eaebe5
11 files changed
+841 -4

No files matched your search

+196
View File
@@ -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')
+55 -1
View File
@@ -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"}')
})
+20
View File
@@ -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 } }],
+1 -1
View File
@@ -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
+55 -2
View File
@@ -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')
})
+3
View File
@@ -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')
})
})
+243
View File
@@ -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>
+25
View File
@@ -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',
+25
View File
@@ -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: 'モニター',
+24
View File
@@ -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: '监控',