fix(frontend): localize account metrics and polish responsive layouts

Move account metric and relative-time presentation to the frontend using raw API values. Tighten latency unit spacing and unify edit actions and page header sizing.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-23 21:06:12 +08:00
committed by Zyy
co-authored by Codex
parent 22518d064c
commit 632edee7dc
31 changed files with 184 additions and 31 deletions
@@ -241,6 +241,7 @@ pub(super) fn account_quota_view(
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
(
AccountQuotaView {
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
@@ -385,6 +385,7 @@ pub use crate::admin::account_groups::AccountGroupRefView;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaView {
pub refreshed_at: Option<String>,
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
@@ -928,8 +928,10 @@ pub(crate) fn dashboard_view(
.filter(|window| window.used_percent.is_none())
.and_then(|window| window.local_usage.as_ref())
.map(|usage| usage.request_count);
let (metric_label, metric_value) = match local_requests {
let (metric, metric_value_raw, metric_label, metric_value) = match local_requests {
Some(count) => (
DashboardAccountMetric::Requests,
Some(count),
"次数".to_owned(),
if count > 0 {
format_compact_number(count)
@@ -938,6 +940,8 @@ pub(crate) fn dashboard_view(
},
),
None => (
DashboardAccountMetric::Tokens,
credential.total_tokens,
"今日 Token".to_owned(),
credential
.total_tokens
@@ -971,8 +975,11 @@ pub(crate) fn dashboard_view(
usage_window: credential
.quota_window
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
metric,
metric_value_raw,
metric_label,
metric_value,
last_used_at: credential.last_used_at,
last_used: time.relative_optional(credential.last_used_at, range.end),
});
}
@@ -1060,6 +1067,7 @@ pub(crate) fn usage_summary_view(
cache_write_input_tokens: format_compact_number(overview.requests.cache_write_input_tokens),
total_tokens: format_compact_number(overview.requests.total_tokens),
average_latency_ms: display_duration(summary.average_latency_ms),
average_latency_ms_value: summary.average_latency_ms,
logical_requests: request_summary_metrics_view(&overview.requests),
attempts: attempt_metrics_view(&overview.attempts),
}
@@ -401,7 +401,15 @@ pub struct DashboardCacheCardView {
pub average_first_token_latency_ms: String,
}
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影
/// Dashboard 账号概览指标的稳定标识
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DashboardAccountMetric {
Requests,
Tokens,
}
/// Dashboard 账号概览卡片所需的 Provider 安全投影
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountUsageView {
@@ -416,8 +424,11 @@ pub struct DashboardAccountUsageView {
pub request_buckets: Vec<crate::time::RequestBucketView>,
pub quota_used_percent: Option<f64>,
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
pub metric: DashboardAccountMetric,
pub metric_value_raw: Option<u64>,
pub metric_label: String,
pub metric_value: String,
pub last_used_at: Option<DateTime<Utc>>,
pub last_used: String,
}
@@ -610,6 +621,7 @@ pub struct UsageSummaryView {
pub cache_write_input_tokens: String,
pub total_tokens: String,
pub average_latency_ms: String,
pub average_latency_ms_value: Option<u64>,
pub logical_requests: RequestMetricsView,
pub attempts: AttemptMetricsView,
}
@@ -17,7 +17,11 @@ async fn usage_summary_keeps_request_attempt_cost_and_average_latency_wire_field
use gateway_api::admin::observability;
use tower::ServiceExt as _;
for (sum, count, display) in [(404, 3, "134 ms"), (0, 1, "0 ms"), (0, 0, "—")] {
for (sum, count, display, raw) in [
(404, 3, "134 ms", Some(134)),
(0, 1, "0 ms", Some(0)),
(0, 0, "—", None),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
@@ -84,6 +88,7 @@ async fn usage_summary_keeps_request_attempt_cost_and_average_latency_wire_field
"totalRequests": "5", "inputTokens": "400", "outputTokens": "80",
"cachedInputTokens": "30", "cacheWriteInputTokens": "7", "totalTokens": "480",
"averageLatencyMs": display,
"averageLatencyMsValue": raw,
"logicalRequests": {"requestCount":5,"successCount":4,"failureCount":0,"cancelledCount":1,"incompleteCount":0,"callerErrorCount":1,"inputTokens":400,"outputTokens":80,"cachedInputTokens":30,"cacheWriteInputTokens":7,"reasoningOutputTokens":9,"totalTokens":480},
"attempts": {"attemptCount":6,"successCount":4,"failureCount":1,"cancelledCount":1,"incompleteCount":0,"rateLimitedCount":1,"authFailureCount":0,"provider5xxCount":0,"costCoverage":{"known":3,"partial":0,"unknown":1,"notBillable":0},"costs":[{"currency":"USD","estimatedAmount":"1.25"}]}
})
@@ -1472,7 +1477,11 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
http::{Request, StatusCode, header},
};
use chrono::Duration;
use gateway_admin::model::observability::{DashboardObservation, TimeRange};
use gateway_admin::model::{
accounts::AccountUsage,
observability::{DashboardAccountUsage, DashboardObservation, TimeRange},
provider_credentials::{ProviderQuotaWindow, QuotaLocalUsageAttribution},
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -1489,7 +1498,80 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
totals: Default::default(),
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: Vec::new(),
account_usage: [
(None, Some(2_000), Some(now)),
(None, Some(0), None),
(None, None, None),
(Some(9), Some(2_000), Some(now)),
(Some(0), Some(2_000), None),
]
.into_iter()
.enumerate()
.map(|(index, (requests, tokens, last_used_at))| {
let account_id = format!("acct_metric_{index}");
DashboardAccountUsage {
account_id: account_id.clone(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
name: account_id.clone(),
email: None,
plan_type: None,
plan_type_display: None,
request_count: 10,
success_count: 10,
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_write_input_tokens: None,
reasoning_output_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: tokens,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at,
request_buckets: Vec::new(),
quota_used_percent: None,
quota_window: requests.map(|request_count| ProviderQuotaWindow {
key: "rolling".to_owned(),
group: "shortTerm".to_owned(),
label: "滚动窗口".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(AccountUsage {
account_id,
request_count,
success_count: request_count,
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_write_input_tokens: None,
reasoning_output_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: None,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at,
request_buckets: Vec::new(),
models: Vec::new(),
}),
provider_data: None,
}),
models: Vec::new(),
}
})
.collect(),
recent_requests: Vec::new(),
});
@@ -1521,6 +1603,20 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::OK, "{kind}: {value}");
let accounts = value["data"]["accountUsage"].as_array().unwrap();
assert_eq!(accounts.len(), 5);
for (account, (metric, raw, display, last_used_at)) in accounts.iter().zip([
("tokens", Some(2_000), "2K", Some(now)),
("tokens", Some(0), "0", None),
("tokens", None, "—", None),
("requests", Some(9), "9", Some(now)),
("requests", Some(0), "—", None),
]) {
assert_eq!(account["metric"], metric);
assert_eq!(account["metricValueRaw"], json!(raw));
assert_eq!(account["metricValue"], display);
assert_eq!(account["lastUsedAt"], json!(last_used_at));
}
let summary = value["data"]["trend"]["summary"].as_array().unwrap();
assert_eq!(summary.len(), metrics.len());
for (item, metric) in summary.iter().zip(metrics) {
+11 -2
View File
@@ -158,7 +158,10 @@ Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`5020
`YYYY-MM-DD HH:mm:ss`,短时间、相对时间和图表标签按对应视图返回;缺失时间的展示字段为 `null` 或占位文本。
调用者直接显示文本,使用原始时间点进行排序、比较和输入,不把展示文本转回请求时间
账号、模型用量和 Key 列表的最近使用时间由前端根据原始 RFC3339 时间点,通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新;悬停的完整日期仍使用后端 `lastUsedAtFullDisplay`,不使用浏览器本地时区重算
账号、模型用量和 Key 列表以及 Dashboard 的最近使用时间由前端根据原始 RFC3339 `lastUsedAt`,
通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新。账号额度的最近刷新时间
同样使用 `quota.refreshedAt`,缺失时显示占位文本。悬停的完整日期仍使用后端
`lastUsedAtFullDisplay`,不使用浏览器本地时区重算
账号冷却返回 RFC3339 `rateLimitedUntil` 和 `rateLimitRecoveryDisplay`,后者包含剩余时长、
到期时间或等待恢复探测的提示,随账号数据刷新;前端不解析展示文本或自行计算恢复提示
@@ -1997,6 +2000,9 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
| `GET` | `/api/admin/operations/errors` | 运维错误分页列表 |
| `GET` | `/api/admin/diagnostics/export` | 按 `requestId` 或 `errorId` 下载诊断 ZIP 附件 |
用量汇总的 `averageLatencyMsValue` 为可空毫秒数,前端按持续时长格式化;无延迟样本时为 `null`,
`averageLatencyMs` 保留为兼容展示字段
### Dashboard 趋势指标
趋势摘要 `summary[]` 返回稳定的 `metric`、展示数值 `value` 和可空的 `ratio`,指标名称由前端本地化
@@ -2026,7 +2032,10 @@ Dashboard 的 `capacityInfo` 统计普通容量,不含独立审批池;`maxCo
`capacityInfo.totalSlots` 为 `number | null`;可用账号池含无限并发账号时为 `null`,此时 `availableSlots` 也为 `null`。
`usedSlots` 仍表示实际在途数,Redis 不可用时为 `null`;没有可用账号时 `totalSlots` 为 `0`
Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、`metricValue`
Dashboard 的 `accountUsage[]` 返回稳定指标 `metric=requests|tokens`、可空原始数值 `metricValueRaw` 和
可空 RFC3339 `lastUsedAt`,前端负责指标名称、数字和相对时间的本地化。
`requests` 使用代表性额度窗口的本地请求数,`tokens` 使用今日 Token;未知数值为 `null`,零值保留 `0`。
`metricLabel`、`metricValue` 和 `lastUsed` 保留给兼容调用方,当前管理端不解析或显示这些字段
`usageWindow` 复用账号额度窗口合同,缺失额度事实时为 `null`;窗口标签、百分比、触顶状态、重置时间
和本地用量由 Provider/Admin 投影。套餐缺失不表示免费套餐,显示时舍入的百分比不用于判断触顶
+1
View File
@@ -35,6 +35,7 @@ export interface AccountQuotaCredits {
}
export interface AccountQuota {
refreshedAt: string | null
refreshedAtDisplay: string
limitReached: boolean
// 429 临时限流(Redis 冷却)到期时间;非限流中为 null。
+3
View File
@@ -160,8 +160,11 @@ export interface DashboardAccountUsage {
requestBuckets: DashboardAccountRequestBucket[]
quotaUsedPercent: number | null
usageWindow: AccountQuotaWindow | null
metric: 'requests' | 'tokens'
metricValueRaw: number | null
metricLabel: string
metricValue: string
lastUsedAt: string | null
lastUsed: string
}
+1
View File
@@ -229,6 +229,7 @@ export interface UsageSummaryResponse {
cacheWriteInputTokens: string
totalTokens: string
averageLatencyMs: string
averageLatencyMsValue: number | null
}
export interface UsageOverviewHealthPoint {
@@ -141,7 +141,6 @@
"batchEdit": {
"title": "Edit accounts",
"selectionTitle": "Edit {count} account | Edit {count} accounts",
"saveChanges": "Save changes",
"proxyRequired": "Select a proxy that passed the connection test",
"saved": "Updated {count} account | Updated {count} accounts",
"staleAccount": "Account {accountId} is out of date, select it again"
@@ -29,6 +29,7 @@
"unavailableCount": "Unavailable: {value}"
},
"activeAccounts": {
"todayTokens": "Today’s tokens",
"title": "Active account usage",
"sortByRecent": "Sorted by last used",
"empty": "No recent account requests",
-1
View File
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "Weekly limit (USD)",
"maximumConcurrency": "Maximum concurrency",
"requestsPerMinuteRpm": "Requests per minute (RPM)",
"saveChanges": "Save changes",
"updated": "API key updated",
"created": "API key created",
"validation": {
@@ -141,7 +141,6 @@
"batchEdit": {
"title": "批量编辑账号",
"selectionTitle": "编辑 {count} 个账号",
"saveChanges": "保存更改",
"proxyRequired": "请选择已通过测试的代理",
"saved": "已更新 {count} 个账号",
"staleAccount": "账号 {accountId} 的页面数据已失效,请重新选择"
@@ -29,6 +29,7 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"title": "活跃账号用量",
"sortByRecent": "最近使用排序",
"empty": "暂无账号请求记录",
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "周限额(USD)",
"maximumConcurrency": "最大并发",
"requestsPerMinuteRpm": "每分钟请求数(RPM)",
"saveChanges": "保存更改",
"updated": "API Key 已更新",
"created": "API Key 已创建",
"validation": {
@@ -141,7 +141,6 @@
"batchEdit": {
"title": "批量編輯賬號",
"selectionTitle": "編輯 {count} 個賬號",
"saveChanges": "保存更改",
"proxyRequired": "請選擇已通過測試的代理",
"saved": "已更新 {count} 個賬號",
"staleAccount": "賬號 {accountId} 的頁面數據已失效,請重新選擇"
@@ -29,6 +29,7 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"title": "活躍賬號用量",
"sortByRecent": "最近使用排序",
"empty": "暫無賬號請求記錄",
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "周限額(USD)",
"maximumConcurrency": "最大併發",
"requestsPerMinuteRpm": "每分鐘請求數(RPM)",
"saveChanges": "保存更改",
"updated": "API Key 已更新",
"created": "API Key 已創建",
"validation": {
@@ -65,7 +65,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:disabled="selectedCount === 0 || groupsLoading"
@click="emit('save')"
>
{{ t('accounts.batchEdit.saveChanges') }}
{{ t('common.actions.save') }}
</ZButton>
</template>
</ZDialog>
@@ -118,7 +118,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:disabled="!account || groupsLoading || (isOpenAiApiKeyAccount(account) && !configurationReady)"
@click="emit('save')"
>
{{ t('accounts.batchEdit.saveChanges') }}
{{ t('common.actions.save') }}
</ZButton>
</template>
</ZDialog>
@@ -4,6 +4,7 @@ import type { Account } from '@/api'
import { ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { useTimeAgoIntl } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -26,7 +27,13 @@ const emit = defineEmits<{
quotaReset: [accountId: string]
}>()
const { t } = useI18n({ useScope: 'global' })
const { t, locale } = useI18n({ useScope: 'global' })
const refreshedAtDisplay = useTimeAgoIntl(() => props.account.quota.refreshedAt ?? 0, {
get locale() { return locale.value },
relativeTimeFormatOptions: { numeric: 'auto', style: 'short' },
insertSpace: false,
})
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
@@ -59,7 +66,7 @@ watch(hasPersonalInfo, (available) => {
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
</template>
<span>·</span>
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: account.quota.refreshedAtDisplay }) }}</span>
<span>{{ t('accounts.quota.lastRefreshed', { refreshedAtDisplay: account.quota.refreshedAt ? refreshedAtDisplay : '—' }) }}</span>
</p>
</div>
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
-1
View File
@@ -207,7 +207,6 @@ const {
<template>
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<ZPageHeader
class="h-17"
:title="t('navigation.accounts')"
:description="t('accounts.page.description')"
/>
@@ -7,6 +7,7 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
import { formatCompactNumber } from '@/utils/format'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
@@ -251,12 +252,12 @@ const statusBars = computed(() => {
>
<span class="grid min-w-0 gap-1">
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
{{ account.metricLabel }}
{{ account.metric === 'requests' ? t('dashboard.metrics.requestCount') : t('dashboard.activeAccounts.todayTokens') }}
</span>
<strong
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-text"
>
{{ account.metricValue }}
{{ account.metricValueRaw == null || (account.metric === 'requests' && account.metricValueRaw === 0) ? '—' : formatCompactNumber(account.metricValueRaw) }}
</strong>
</span>
@@ -265,7 +266,8 @@ const statusBars = computed(() => {
<span
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-text-secondary xl:whitespace-nowrap"
>
{{ account.lastUsed }}
<LastUsedAtCell v-if="account.lastUsedAt" :value="account.lastUsedAt" :full-display="null" />
<template v-else>—</template>
</span>
</span>
-1
View File
@@ -54,7 +54,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<ZPageHeader
class="h-17"
:title="t('navigation.groups')"
:description="t('groups.page.description')"
/>
@@ -170,7 +170,7 @@ function updateForm<Key extends keyof ApiKeyFormValue>(key: Key, value: ApiKeyFo
:disabled="!form.name.trim()"
@click="emit('save')"
>
{{ editing ? t('keys.editor.saveChanges') : t('common.actions.create') }}
{{ editing ? t('common.actions.save') : t('common.actions.create') }}
</ZButton>
</template>
</ZDialog>
-1
View File
@@ -114,7 +114,6 @@ watch(
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<ZPageHeader
class="h-17"
:title="t('navigation.apiKeys')"
:description="t('keys.page.description')"
/>
-1
View File
@@ -68,7 +68,6 @@ watch(search, () => {
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<ZPageHeader
class="h-17"
:title="t('navigation.proxies')"
:description="t('proxies.page.description')"
/>
@@ -62,7 +62,7 @@ function saveTheme(event: MouseEvent) {
<template>
<div class="flex min-h-0 w-full flex-col">
<ZPageHeader class="xl:h-17" :title="t('navigation.appearance')">
<ZPageHeader :title="t('navigation.appearance')">
<template #description>
<span class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{{ t('appearance.settings.description') }}</span>
@@ -337,6 +337,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
quotaUsedPercent: 32,
metricLabel: t('appearance.preview.dashboard.todaySToken'),
metricValue: '926K',
metric: 'tokens',
metricValueRaw: 926_000,
usageWindow: {
key: 'short',
group: 'shortTerm',
@@ -352,6 +354,7 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
resetAtDisplay: '—',
},
lastUsed: t('appearance.preview.components.justNow'),
lastUsedAt: new Date().toISOString(),
},
{
id: 'preview-xai-1',
@@ -370,6 +373,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
quotaUsedPercent: null,
metricLabel: t('appearance.preview.dashboard.times'),
metricValue: '894',
metric: 'requests',
metricValueRaw: 894,
usageWindow: {
key: 'free-rolling-24h',
group: 'shortTerm',
@@ -394,6 +399,7 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
},
},
lastUsed: t('common.time.minutesAgo', { count: 5 }),
lastUsedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
},
{
id: 'preview-openai-2',
@@ -408,6 +414,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
quotaUsedPercent: 68,
metricLabel: t('appearance.preview.dashboard.todaySToken'),
metricValue: '512K',
metric: 'tokens',
metricValueRaw: 512_000,
usageWindow: {
key: 'short',
group: 'shortTerm',
@@ -423,6 +431,7 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
resetAtDisplay: '—',
},
lastUsed: t('common.time.minutesAgo', { count: 8 }),
lastUsedAt: new Date(Date.now() - 8 * 60_000).toISOString(),
},
],
usageRecords: [
@@ -13,9 +13,17 @@ const props = defineProps<{
const { t } = useI18n({ useScope: 'global' })
function averageLatencyDisplay(value: string) {
return !value || value === '—' || value === '-' ? '0 ms' : value
}
const averageLatency = computed(() => {
const raw = props.summary.averageLatencyMsValue
const value = raw == null || !Number.isFinite(raw) || raw < 0 ? 0 : raw
if (value < 1_000)
return { value: String(Math.round(value)), unit: 'ms' }
if (value < 60_000)
return { value: (value / 1_000).toFixed(2), unit: 's' }
if (value < 3_600_000)
return { value: (value / 60_000).toFixed(1), unit: 'min' }
return { value: (value / 3_600_000).toFixed(1), unit: 'h' }
})
const items = computed(() => [
{
@@ -46,7 +54,8 @@ const items = computed(() => [
key: 'latency',
label: t('usage.averageTimeTaken'),
icon: Timer,
value: averageLatencyDisplay(props.summary.averageLatencyMs),
value: averageLatency.value.value,
unit: averageLatency.value.unit,
detail: t('usage.successfulRequestAverage'),
tone: 'bg-cp-cyan-container text-cp-cyan-on-container',
},
@@ -70,7 +79,7 @@ const items = computed(() => [
{{ item.label }}
</span>
<strong class="my-2 block truncate font-mono text-[24px] leading-none font-extrabold text-cp-text">
{{ item.value }}
{{ item.value }}<span v-if="item.unit" class="ml-0.5 font-sans text-cp font-semibold text-cp-text-secondary">{{ item.unit }}</span>
</strong>
<span class="block truncate text-cp-sm leading-none font-emphasis text-cp-text-quaternary">
{{ item.detail }}
@@ -267,6 +267,7 @@ function emptySummary() {
cacheWriteInputTokens: '0',
totalTokens: '0',
averageLatencyMs: '0 ms',
averageLatencyMsValue: null,
}
return summary
}