Compare commits

...
7 Commits
Author SHA1 Message Date
ZyyandCodex 76291f448c chore(release): v3.30.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:47:55 +08:00
Zyy c10efea12c perf(dashboard): deduplicate histogram samples and trim unused trend fields
Group identical positive samples before numeric logarithm evaluation while retaining histogram boundaries and weights. Remove nine unused trend fields and synchronize frontend types, preview data, and API documentation.

Validate against the 30-million-row Docker dataset, matching retained statistics and reducing uncached today-summary median latency from 623 ms to 319 ms. Run 133 related tests, Clippy, formatting, frontend checks, and browser tab checks.
2026-10-23 21:06:12 +08:00
ZyyandCodex 806f7ad9ee fix(i18n): preserve server-authoritative relative times
Keep dashboard and quota relative-time thresholds and future timestamps owned by the backend deployment timezone. Localize only the structured presentation in the frontend.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:06:12 +08:00
ZyyandCodex 632edee7dc 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>
2026-10-23 21:06:12 +08:00
ZyyandCodex 22518d064c chore(deps): sync UI and plugin release references
Track UI v0.6.0 and Plugins v0.6.1 release commits.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:06:12 +08:00
ZyyandCodex 64c1ec2afd chore(release): v3.30.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 19:05:30 +08:00
ZyyandCodex a3f0c1b028 fix: correct localized UI and update hyper-util
Restore quota labels, custom window durations, account usage panel width,
and consistent USD symbols. Return stable provider identity attribute keys
for frontend localization and refine login controls, copy, and alignment.

Pin hyper-util to the 0.1.21 registry release and remove the Git patch.

Tests were not rerun for this combined commit, as requested.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 19:02:48 +08:00
68 changed files with 691 additions and 245 deletions
+4 -2
View File
@@ -2253,8 +2253,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -2262,6 +2263,7 @@ dependencies = [
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
-2
View File
@@ -57,7 +57,5 @@ codegen-units = 1
panic = "abort"
[patch.crates-io]
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
@@ -1190,10 +1190,10 @@ pub struct DashboardWireProfile {
pub release: Option<DashboardDesktopRelease>,
}
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
/// Provider 画像的差异字段,以稳定标识传递语义,名称由前端本地化
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardWireAttribute {
pub label: String,
pub key: String,
pub value: String,
}
@@ -1044,7 +1044,7 @@ pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
},
user_agent: "gateway-admin-test".to_owned(),
attributes: vec![DashboardWireAttribute {
label: "Core".to_owned(),
key: "core".to_owned(),
value: "test".to_owned(),
}],
verified_at: Some(chrono::Utc::now()),
@@ -241,6 +241,10 @@ 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_relative: quota
.observed_at
.map(|value| time.relative_view(value, now)),
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
@@ -385,6 +385,8 @@ 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_relative: Option<crate::time::RelativeTimeView>,
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
@@ -686,20 +686,10 @@ pub(crate) fn trend_point_view(
tokens_value: point.metrics.total_tokens,
errors: format_compact_number(point.service_failure_count),
errors_value: point.service_failure_count,
latency: display_duration(point.average_latency_ms),
latency_value: point.average_latency_ms,
max_latency: display_duration(point.metrics.max_latency_ms),
max_latency_value: point.metrics.max_latency_ms,
min_latency: display_duration(point.metrics.min_latency_ms),
min_latency_value: point.metrics.min_latency_ms,
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
success_rate_value,
first_token_p50_ms: point
.metrics
.first_token_latency_percentiles
.p50_ms
.map(|value| value.as_f64()),
first_token_p95_ms: point
.metrics
.first_token_latency_percentiles
@@ -711,20 +701,6 @@ pub(crate) fn trend_point_view(
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p50: point.metrics.output_throughput_p50,
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
capacity_utilization: point
.metrics
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
}
}
@@ -875,7 +851,7 @@ pub(crate) fn wire_profile_view(
.attributes
.into_iter()
.map(|attribute| DashboardWireAttributeView {
label: attribute.label,
key: attribute.key,
value: attribute.value,
})
.collect(),
@@ -928,8 +904,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 +916,8 @@ pub(crate) fn dashboard_view(
},
),
None => (
DashboardAccountMetric::Tokens,
credential.total_tokens,
"今日 Token".to_owned(),
credential
.total_tokens
@@ -971,8 +951,14 @@ 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_relative: credential
.last_used_at
.map(|value| time.relative_view(value, range.end)),
last_used: time.relative_optional(credential.last_used_at, range.end),
});
}
@@ -1060,6 +1046,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),
}
@@ -306,21 +306,12 @@ pub struct TrendPointView {
pub tokens_value: u64,
pub errors: String,
pub errors_value: u64,
pub latency: String,
pub latency_value: Option<u64>,
pub max_latency: String,
pub max_latency_value: Option<u64>,
pub min_latency: String,
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub output_throughput_p50: Option<u64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub capacity_utilization: Option<f64>,
}
/// 趋势指标的稳定身份,展示名称由管理端本地化
@@ -401,7 +392,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 +415,12 @@ 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_relative: Option<crate::time::RelativeTimeView>,
pub last_used: String,
}
@@ -530,7 +533,7 @@ pub struct DashboardWireProfileView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardWireAttributeView {
pub label: String,
pub key: String,
pub value: String,
}
@@ -610,6 +613,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,
}
+54 -6
View File
@@ -101,21 +101,51 @@ impl TimePresenter {
self.timezone.local(*value).to_rfc3339()
}
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
pub(crate) fn relative_view(
self,
value: DateTime<Utc>,
now: DateTime<Utc>,
) -> RelativeTimeView {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return self.datetime(&value);
return RelativeTimeView::Absolute {
value: self.datetime(&value),
};
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
return RelativeTimeView::JustNow;
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
return RelativeTimeView::Elapsed {
value: elapsed.num_minutes(),
unit: RelativeTimeUnit::Minute,
};
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
return RelativeTimeView::Elapsed {
value: elapsed.num_hours(),
unit: RelativeTimeUnit::Hour,
};
}
RelativeTimeView::Elapsed {
value: elapsed.num_days(),
unit: RelativeTimeUnit::Day,
}
}
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
match self.relative_view(value, now) {
RelativeTimeView::JustNow => "刚刚".to_owned(),
RelativeTimeView::Elapsed { value, unit } => {
let unit = match unit {
RelativeTimeUnit::Minute => "分钟",
RelativeTimeUnit::Hour => "小时",
RelativeTimeUnit::Day => "天",
};
format!("{value} {unit}前")
}
RelativeTimeView::Absolute { value } => value,
}
format!("{} 天前", elapsed.num_days())
}
pub(crate) fn relative_optional(
@@ -127,6 +157,24 @@ impl TimePresenter {
}
}
/// 后端按查询锚点与部署时区决定展示方式,前端只本地化分类和单位
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RelativeTimeView {
JustNow,
Elapsed { value: i64, unit: RelativeTimeUnit },
Absolute { value: String },
}
/// 相对时间保持分钟、小时、天的固定精度,不引入月或年的日历推算
#[derive(Debug, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RelativeTimeUnit {
Minute,
Hour,
Day,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestBucketView {
@@ -160,6 +160,90 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
}
}
#[tokio::test]
async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone() {
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::provider_credentials::ProviderQuota;
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
use serde_json::json;
for zone in ["UTC", "Asia/Shanghai", "Asia/Kathmandu", "America/New_York"] {
let zone = zone
.parse::<gateway_core::time::DeploymentTimeZone>()
.unwrap();
let fixture = AdminTestFixture::with_timezone(zone).await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
now,
"oauth",
));
let future = now + Duration::days(1);
let future_display = zone.local(future).format("%Y-%m-%d %H:%M:%S").to_string();
for (observed_at, expected, legacy) in [
(None, json!(null), "—"),
(Some(now), json!({ "kind": "just_now" }), "刚刚"),
(
Some(now - Duration::seconds(90)),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(now - Duration::hours(25)),
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
"1 天前",
),
(
Some(future),
json!({ "kind": "absolute", "value": future_display }),
future_display.as_str(),
),
] {
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
plan_type: None,
observed_at,
refresh_token_expires_at: None,
windows: Vec::new(),
credits: None,
limit_reached: false,
provider_data: None,
});
let response = admin::accounts::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/accounts")
.header("x-request-id", "req_quota_relative_time")
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::ACCEPT_LANGUAGE, "en")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 32768).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let quota = &value["data"]["items"][0]["quota"];
assert_eq!(quota["refreshedAtRelative"], expected);
assert_eq!(quota["refreshedAtDisplay"], legacy);
let refreshed_at = quota["refreshedAt"]
.as_str()
.map(|value| DateTime::parse_from_rfc3339(value).unwrap().to_utc());
assert_eq!(refreshed_at, observed_at);
}
}
}
fn account_page_item(
facts: gateway_core::account::AccountStatusFacts,
now: chrono::DateTime<chrono::Utc>,
+23 -3
View File
@@ -112,6 +112,7 @@ pub(super) struct AdminTestFixture {
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
pub account: Arc<Mutex<Option<AccountPageItem>>>,
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
}
@@ -187,9 +188,18 @@ impl AdminTestFixture {
plugin_ports.clone(),
plugin_ports.clone(),
);
let provider_quota = Arc::new(Mutex::new(None));
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))),
Arc::new(UnusedProvider::new(
"openai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
)),
Arc::new(UnusedProvider::new(
"xai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
)),
];
let bundle = gateway_admin::initialize(
AdminConfig {
@@ -236,6 +246,7 @@ impl AdminTestFixture {
dashboard_observation,
dashboard_summary_range,
provider_error,
provider_quota,
account,
account_usage,
}
@@ -1590,13 +1601,19 @@ impl ObservabilityStore for UnusedStore {
struct UnusedProvider {
kind: ProviderKind,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
}
impl UnusedProvider {
fn new(kind: &str, error: Arc<Mutex<Option<ProviderAdminError>>>) -> Self {
fn new(
kind: &str,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
error,
quota,
}
}
}
@@ -1691,6 +1708,9 @@ impl ProviderAdmin for UnusedProvider {
&self,
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
if let Some(quota) = self.quota.lock().expect("provider quota").clone() {
return Ok(quota);
}
Err(self
.error
.lock()
@@ -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) {
@@ -1530,3 +1626,144 @@ async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
}
}
}
#[tokio::test]
async fn dashboard_relative_time_preserves_server_anchor_boundaries_and_deployment_timezone() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::{DateTime, Duration};
use gateway_admin::model::observability::{
DashboardAccountUsage, DashboardObservation, TimeRange,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use crate::admin::{AdminTestFixture, AdminTestState};
let now = "2026-06-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
for (zone, future_display) in [
("UTC", "2026-06-15 00:00:01"),
("Asia/Shanghai", "2026-06-15 08:00:01"),
("Asia/Kathmandu", "2026-06-15 05:45:01"),
("America/New_York", "2026-06-14 20:00:01"),
] {
for (elapsed_ms, expected, legacy) in [
(None, json!(null), "—"),
(
Some(-1_000),
json!({ "kind": "absolute", "value": future_display }),
future_display,
),
(Some(-1), json!({ "kind": "just_now" }), "刚刚"),
(Some(0), json!({ "kind": "just_now" }), "刚刚"),
(Some(59_999), json!({ "kind": "just_now" }), "刚刚"),
(
Some(60_000),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(119_999),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(3_599_999),
json!({ "kind": "elapsed", "value": 59, "unit": "minute" }),
"59 分钟前",
),
(
Some(3_600_000),
json!({ "kind": "elapsed", "value": 1, "unit": "hour" }),
"1 小时前",
),
(
Some(86_399_999),
json!({ "kind": "elapsed", "value": 23, "unit": "hour" }),
"23 小时前",
),
(
Some(86_400_000),
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
"1 天前",
),
(
Some(34_560_000_000),
json!({ "kind": "elapsed", "value": 400, "unit": "day" }),
"400 天前",
),
] {
// 每个边界使用独立缓存,避免相同锚点复用上一轮观测数据
let fixture = AdminTestFixture::with_timezone(zone.parse().unwrap()).await;
fixture.auth.insert_session("valid-session");
let last_used_at = elapsed_ms.map(|value| now - Duration::milliseconds(value));
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
yesterday: Default::default(),
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
totals: Default::default(),
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: vec![DashboardAccountUsage {
account_id: "acct_relative_time".to_owned(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
name: "Relative time fixture".to_owned(),
email: None,
plan_type: None,
plan_type_display: None,
request_count: 1,
success_count: 1,
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(),
quota_used_percent: None,
quota_window: None,
models: Vec::new(),
}],
recent_requests: Vec::new(),
});
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri(format!(
"/api/admin/dashboard/summary?asOf={}",
now.timestamp_millis()
))
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::ACCEPT_LANGUAGE, "en")
.header("x-request-id", "req_relative_time")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
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, "{zone}: {value}");
let account = &value["data"]["accountUsage"][0];
assert_eq!(
account["lastUsedRelative"], expected,
"{zone}: {elapsed_ms:?}"
);
assert_eq!(account["lastUsed"], legacy, "{zone}: {elapsed_ms:?}");
assert_eq!(account["lastUsedAt"], json!(last_used_at));
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ http = "1.4.0"
http-body-util = "0.1.3"
hyper = { version = "=1.11.1", default-features = false }
hyper-rustls = { version = "=0.27.7", default-features = false, features = ["http1", "http2", "tls12"] }
hyper-util = { version = "0.1.20", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "client-proxy", "http1", "http2", "tokio"] }
ipnet = "2.11.0"
percent-encoding = "2.3.2"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
@@ -89,8 +89,10 @@ pub(super) fn push_fact_aggregation_selected(
}
query.push(") sample(metric,value) where value is not null), sample_stats as (select output_bucket,name,metric,count(*) as count,sum(value) as sum,min(value) as minimum,max(value) as maximum,count(*) filter(where value=0) as zero");
query.push(" from samples group by output_bucket,name,metric)");
// 对数桶定义与 Histogram::add 相同,零值单独保留,不对零取对数
query.push(", bin_counts as (select output_bucket,name,metric,floor(log(2::numeric,value::numeric)*16)::integer as bin,count(*) as count from samples where value>0 group by output_bucket,name,metric,bin), merged_bins as (select output_bucket,name,metric,jsonb_object_agg(bin,count) as bins from bin_counts group by output_bucket,name,metric)");
// 先合并同值样本,避免逐条重复计算 numeric 对数,保持原有桶边界和样本权重
// 零值仍由 sample_stats 单独保留,不参与对数计算
query.push(", sample_values as materialized (select output_bucket,name,metric,value,count(*) as count from samples where value>0 group by output_bucket,name,metric,value)");
query.push(", bin_counts as (select output_bucket,name,metric,floor(log(2::numeric,value::numeric)*16)::integer as bin,sum(count)::bigint as count from sample_values group by output_bucket,name,metric,bin), merged_bins as (select output_bucket,name,metric,jsonb_object_agg(bin,count) as bins from bin_counts group by output_bucket,name,metric)");
query.push(", merged_distributions as (select s.output_bucket,s.name,jsonb_object_agg(s.metric,jsonb_build_object('count',s.count,'sum',s.sum,'minimum',s.minimum,'maximum',s.maximum,'zero',s.zero,'bins',");
query.push("coalesce(b.bins,'{}'::jsonb)");
query.push(")) as distributions");
@@ -1692,16 +1692,18 @@ async fn daily_rank_counts_follow_rebuilds_rollbacks_deletions_and_dirty_days()
}
#[tokio::test]
async fn sql_edge_histograms_match_persisted_power_of_two_boundaries() {
async fn sql_edge_histograms_preserve_repeated_samples_and_power_of_two_boundaries() {
let Some(database) = TestDatabase::create("analytics_sql_histogram_boundaries").await else {
return;
};
let start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
for (index, value) in [0_i64, 1, 2, 4, 8, 16, 31, 32, 33, 64, 128, 1024, 1_048_576]
.into_iter()
.enumerate()
for (index, value) in [
0_i64, 0, 1, 1, 1, 2, 2, 4, 8, 16, 31, 32, 32, 33, 64, 128, 1024, 1_048_576,
]
.into_iter()
.enumerate()
{
let id = format!("sql_hist_{index}");
seed(
+2 -2
View File
@@ -25,13 +25,13 @@ getrandom = "0.4.3"
hex = "0.4.3"
# TLS 依赖以官方 Core 锁文件为基线,安全补丁经握手特征核验后精确固定
# 包含用于约束传递依赖的直接声明
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
# hyper-util 固定到包含 SOCKS IPv6 修复的正式版
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
aws-lc-rs = "=1.18.1"
h2 = "=0.4.20"
hyper = { version = "=1.11.1", default-features = false }
hyper-rustls = "=0.27.7"
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
# 与官方 Codex Desktop 一致:HTTP 默认走 native-tls,rustls 保留给自定义 CA 回退;
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
native-tls = "=0.2.14"
+4 -9
View File
@@ -298,7 +298,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
},
user_agent,
attributes: vec![DashboardWireAttribute {
label: "客户端标识".to_owned(),
key: "client_identifier".to_owned(),
value: client_identity,
}],
verified_at: Some(profile.verified_at),
@@ -366,7 +366,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
},
attributes: vec![
DashboardWireAttribute {
label: "客户端标识".to_owned(),
key: "client_identifier".to_owned(),
value: if profile.client_kind == ClientKind::Desktop {
format!("{}; {}", profile.originator, profile.desktop_version)
} else {
@@ -374,13 +374,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
},
},
DashboardWireAttribute {
label: "版本策略".to_owned(),
value: if custom {
"固定身份"
} else {
"自动最新"
}
.to_owned(),
key: "version_policy".to_owned(),
value: if custom { "fixed" } else { "latest" }.to_owned(),
},
],
verified_at: (!custom && profile.verified_at != chrono::DateTime::UNIX_EPOCH)
@@ -472,10 +472,18 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
profile
.attributes
.iter()
.find(|attribute| attribute.label == "客户端标识")
.find(|attribute| attribute.key == "client_identifier")
.map(|attribute| attribute.value.as_str()),
Some("Codex Desktop; 1.2026.190")
);
assert_eq!(
profile
.attributes
.iter()
.find(|attribute| attribute.key == "version_policy")
.map(|attribute| attribute.value.as_str()),
Some("fixed")
);
assert!(profile.release.is_none());
let billing = admin
.calculated_billing(&ProviderBillingInput {
+6 -6
View File
@@ -369,15 +369,15 @@ impl ProviderAdmin for XaiAdminProvider {
view.target.terminal = profile.client_mode.clone();
view.attributes = vec![
DashboardWireAttribute {
label: "客户端标识".to_owned(),
key: "client_identifier".to_owned(),
value: profile.client_identifier,
},
DashboardWireAttribute {
label: "运行模式".to_owned(),
key: "client_mode".to_owned(),
value: profile.client_mode,
},
DashboardWireAttribute {
label: "Token 认证".to_owned(),
key: "token_auth".to_owned(),
value: "xai-grok-cli".to_owned(),
},
];
@@ -429,15 +429,15 @@ impl ProviderAdmin for XaiAdminProvider {
user_agent: profile.user_agent(),
attributes: vec![
DashboardWireAttribute {
label: "客户端标识".to_owned(),
key: "client_identifier".to_owned(),
value: profile.client_identifier,
},
DashboardWireAttribute {
label: "运行模式".to_owned(),
key: "client_mode".to_owned(),
value: profile.client_mode,
},
DashboardWireAttribute {
label: "Token 认证".to_owned(),
key: "token_auth".to_owned(),
value: "xai-grok-cli".to_owned(),
},
],
+32 -1
View File
@@ -160,6 +160,16 @@ Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`5020
账号、模型用量和 Key 列表的最近使用时间由前端根据原始 RFC3339 时间点,通过 VueUse `useTimeAgoIntl` 按界面语言显示短相对时间,并随时间更新;悬停的完整日期仍使用后端 `lastUsedAtFullDisplay`,不使用浏览器本地时区重算
Dashboard 的 `lastUsedRelative` 和账号额度的 `refreshedAtRelative` 由后端确定时间语义,
分别以查询锚点和服务端当前时间计算,随接口刷新更新。前端只翻译分类与单位,不使用浏览器时钟或时区重算
- 缺失时间返回 `null`,前端显示占位文本
- `kind=just_now` 表示不足 60 秒
- `kind=elapsed` 包含整数 `value` 和 `unit=minute|hour|day`,分钟不足 60、小时不足 24,之后始终用天;数值向下取整
- `kind=absolute` 的 `value` 是后端按部署 `host.timezone` 生成的完整日期文本,用于按整秒判断的未来时间点
原始 RFC3339 `lastUsedAt`、`quota.refreshedAt` 与兼容展示字段保持可用,不用于前端重新决定上述展示方式
账号冷却返回 RFC3339 `rateLimitedUntil` 和 `rateLimitRecoveryDisplay`,后者包含剩余时长、
到期时间或等待恢复探测的提示,随账号数据刷新;前端不解析展示文本或自行计算恢复提示
@@ -1997,6 +2007,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`,指标名称由前端本地化
@@ -2009,13 +2022,31 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
图表颜色、系列身份和选中状态使用 `metric`,不根据译文或数组位置推断指标
`points[]` 提供趋势图与首页卡片使用的请求数、Tokens、缓存比例、错误数、成功率、
`latencyValue`、首 Token P95、总延迟 P95 和输出吞吐 P50,以及时间标签与展示值
内部调度分位数和容量分布通过用量洞察查询
### Dashboard 客户端身份
`wireProfiles[].attributes[]` 返回稳定的 `key` 和 `value`,字段名称由前端本地化
- `client_identifier`:上游客户端标识
- `client_mode`:客户端运行模式
- `token_auth`:认证协议
- `version_policy`:版本选择策略,值为 `fixed` 或 `latest`
页面按 `key` 取值;状态判断使用原始值,不依赖界面译文
### Dashboard 容量与账号用量
Dashboard 的 `capacityInfo` 统计普通容量,不含独立审批池;`maxConcurrentPerAccount` 为默认账号并发上限,`0` 表示不限制。
`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`;前端负责指标名称和数字本地化,相对时间按[页面时间合同](#页面时间合同)翻译 `lastUsedRelative`。
`requests` 使用代表性额度窗口的本地请求数,`tokens` 使用今日 Token;未知数值为 `null`,零值保留 `0`。
`metricLabel`、`metricValue` 和 `lastUsed` 保留给兼容调用方,当前管理端不解析或显示这些字段
`usageWindow` 复用账号额度窗口合同,缺失额度事实时为 `null`;窗口标签、百分比、触顶状态、重置时间
和本地用量由 Provider/Admin 投影。套餐缺失不表示免费套餐,显示时舍入的百分比不用于判断触顶
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#acd8578f82a91dc5d6da70b68c3bd354a1dde061",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#8177ba741376564f229aef50da8e76d29e4a5282",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.59.0",
+6 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.44(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#acd8578f82a91dc5d6da70b68c3bd354a1dde061
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#8177ba741376564f229aef50da8e76d29e4a5282
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -443,9 +443,9 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061':
resolution: {gitHosted: true, integrity: sha512-Zme/PI94AL8qt3mQ2YsYMlYWAtqQhqZDxDq7s/R1uE/lEt9ZCcpLkG+o14UmMbal9fLTUMMjAjudOCUUhevk3A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061}
version: 0.5.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282':
resolution: {gitHosted: true, integrity: sha512-ZXS77U0WYM25AEmqleT3rQmenv3GcIvmOJsxxvzJWZ7KQvwMXvSwwIqYrQw/y0Ch37214IVeqYTf68yDjx4mvA==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282}
version: 0.6.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -3422,7 +3422,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/acd8578f82a91dc5d6da70b68c3bd354a1dde061(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/8177ba741376564f229aef50da8e76d29e4a5282(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
+3
View File
@@ -1,4 +1,5 @@
import type { RequestOptions } from '../request'
import type { RelativeTimeView } from '../time'
import type { AccountGroupRef } from './groups'
import request from '../request'
@@ -35,6 +36,8 @@ export interface AccountQuotaCredits {
}
export interface AccountQuota {
refreshedAt: string | null
refreshedAtRelative: RelativeTimeView | null
refreshedAtDisplay: string
limitReached: boolean
// 429 临时限流(Redis 冷却)到期时间;非限流中为 null。
+6 -10
View File
@@ -1,4 +1,5 @@
import type { RequestOptions } from '../request'
import type { RelativeTimeView } from '../time'
import type { AccountQuotaWindow } from './accounts'
import type { AnalyticsMetadata } from './analytics'
import type { UsageListRecord } from './usage'
@@ -25,21 +26,12 @@ export interface DashboardTrendPoint {
tokensValue: number
errors: string
errorsValue: number
latency: string
latencyValue: number | null
maxLatency: string
maxLatencyValue: number | null
minLatency: string
minLatencyValue: number | null
successRate: string
successRateValue: number | null
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
latencyP95Ms: number | null
outputThroughputP50: number | null
admissionDecisionP95Ms: number | null
accountSelectionWaitP95Ms: number | null
capacityUtilization: number | null
}
export type DashboardTrendMetric
@@ -129,7 +121,7 @@ export interface DashboardWireProfile {
terminal: string
}
userAgent: string
attributes: Array<{ label: string, value: string }>
attributes: Array<{ key: string, value: string }>
verifiedAt?: string
verifiedAtDisplay?: string
release?: {
@@ -160,8 +152,12 @@ export interface DashboardAccountUsage {
requestBuckets: DashboardAccountRequestBucket[]
quotaUsedPercent: number | null
usageWindow: AccountQuotaWindow | null
metric: 'requests' | 'tokens'
metricValueRaw: number | null
metricLabel: string
metricValue: string
lastUsedAt: string | null
lastUsedRelative: RelativeTimeView | 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 {
+4
View File
@@ -0,0 +1,4 @@
export type RelativeTimeView
= { kind: 'just_now' }
| { kind: 'elapsed', value: number, unit: 'minute' | 'hour' | 'day' }
| { kind: 'absolute', value: string }
@@ -257,18 +257,29 @@ export function quotaWindowLabel(window: AccountQuotaWindow, includeLimitName =
seconds >= period.seconds * 0.95 && seconds <= period.seconds * 1.05,
)
let label: string
if (period)
if (period) {
label = period.label()
else if (seconds !== null && seconds > 0)
label = t('accounts.quota.windows.duration', { duration: rollingWindowDurationDisplay(seconds) })
else if (window.role === 'monthly')
}
else if (seconds !== null && seconds > 0) {
if (seconds % 86_400 === 0)
label = t('accounts.quota.windows.days', { duration: formatInteger(seconds / 86_400) })
else if (seconds % 3_600 === 0)
label = t('accounts.quota.windows.hours', { duration: formatInteger(seconds / 3_600) })
else
label = t('accounts.quota.windows.minutes', { duration: formatInteger(Math.ceil(seconds / 60)) })
}
else if (window.role === 'monthly') {
label = t('accounts.quota.windows.monthly')
else if (window.role === 'primary')
label = t('accounts.quota.windows.usage')
else if (window.role === 'secondary')
}
else if (window.role === 'primary') {
label = t('accounts.quota.windows.primary')
}
else if (window.role === 'secondary') {
label = t('accounts.quota.windows.secondary')
else
}
else {
label = window.windowLabelDisplay
}
return includeLimitName && window.limitName && window.limitId !== 'codex'
? `${window.limitName} · ${label}`
+6 -4
View File
@@ -1,6 +1,7 @@
{
"accounts": {
"quota": {
"general": "General quota",
"usageInThisWindow": "Usage in this window: {localUsageDisplay}",
"quotaUsed": "Quota used: {rate}",
"resets": "Resets: {resetAtDisplay}",
@@ -48,9 +49,11 @@
"weekly": "Weekly limit",
"monthly": "Monthly limit",
"annual": "Annual limit",
"usage": "Usage limit",
"secondary": "Secondary usage limit",
"duration": "{duration} limit"
"primary": "Primary limit",
"secondary": "Secondary limit",
"days": "{duration}-day limit",
"hours": "{duration}-hour limit",
"minutes": "{duration}-minute limit"
}
},
"account": "Account",
@@ -138,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"
@@ -114,8 +114,6 @@
"demoKey": "Demo Key",
"requestHealthTimeline": "Request a health timeline",
"validRequestAvailability": "Valid request availability",
"clientId": "Client ID",
"tokenAuthentication": "Token authentication",
"todaySToken": "Today's tokens",
"hours": "5 hours",
"requests": "08-23 {value}:00–{value2}:00 · Requests: {count}",
+7 -7
View File
@@ -1,18 +1,18 @@
{
"auth": {
"signOut": "Sign out",
"adminSignIn": "Admin sign-in",
"apiKeySignIn": "API key sign-in",
"signingIn": "Signing in…",
"signIn": "Sign in",
"method": "Sign-in method",
"title": "Sign in",
"adminSignIn": "Admin login",
"apiKeySignIn": "API key login",
"signingIn": "Logging in…",
"signIn": "Log in",
"method": "Login method",
"title": "Console login",
"welcome": "Welcome back",
"username": "Admin account",
"usernamePlaceholder": "Enter your admin account",
"passwordPlaceholder": "Enter your password",
"apiKeyPlaceholder": "Enter your API key",
"pageLabel": "Sign in to Codex Proxy RS",
"pageLabel": "Log in to Codex Proxy RS",
"password": "Password",
"apiKey": "API key",
"adminRealm": "Admin",
+4 -1
View File
@@ -126,7 +126,10 @@
"copied": "Copied"
},
"time": {
"minutesAgo": "{count} minute ago | {count} minutes ago"
"justNow": "Just now",
"minutesAgo": "{count} minute ago | {count} minutes ago",
"hoursAgo": "{count} hour ago | {count} hours ago",
"daysAgo": "{count} day ago | {count} days ago"
},
"units": {
"tokens": "Tokens"
+3 -2
View File
@@ -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",
@@ -78,8 +79,9 @@
"empty": "No successful requests"
},
"clientIdentity": {
"notChecked": "Not checked yet",
"clientMode": "Client mode",
"versionPolicy": "Version policy",
"notChecked": "Not checked yet",
"customVersion": "Custom version",
"active": "Active",
"activeDescription": "New requests using the global settings use this identity",
@@ -94,7 +96,6 @@
"verifiedAt": "Profile verified: {verifiedAt}",
"checkedAt": "Release checked: {checkedAt}",
"clientId": "Client ID",
"tokenAuth": "Token authentication",
"title": "Default upstream identity",
"provider": "Upstream platform",
"empty": "No request identity available",
-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": {
@@ -1,6 +1,7 @@
{
"accounts": {
"quota": {
"general": "通用额度",
"usageInThisWindow": "窗口消耗:{localUsageDisplay}",
"quotaUsed": "额度已使用:{rate}",
"resets": "重置时间: {resetAtDisplay}",
@@ -48,9 +49,11 @@
"weekly": "周额度",
"monthly": "月额度",
"annual": "年额度",
"usage": "用量上限",
"secondary": "次级用量上限",
"duration": "{duration}额度"
"primary": "主额度",
"secondary": "次级额度",
"days": "{duration}日额度",
"hours": "{duration}小时额度",
"minutes": "{duration}分钟额度"
}
},
"account": "账号",
@@ -138,7 +141,6 @@
"batchEdit": {
"title": "批量编辑账号",
"selectionTitle": "编辑 {count} 个账号",
"saveChanges": "保存更改",
"proxyRequired": "请选择已通过测试的代理",
"saved": "已更新 {count} 个账号",
"staleAccount": "账号 {accountId} 的页面数据已失效,请重新选择"
@@ -114,8 +114,6 @@
"demoKey": "演示 Key",
"requestHealthTimeline": "请求健康时间线",
"validRequestAvailability": "有效请求可用性",
"clientId": "客户端 ID",
"tokenAuthentication": "Token 认证",
"todaySToken": "今日 Token",
"hours": "5 小时",
"requests": "08-23 {value}:00–{value2}:00 · {count} 次请求",
@@ -126,7 +126,10 @@
"copied": "已复制"
},
"time": {
"minutesAgo": "{count} 分钟前"
"justNow": "刚刚",
"minutesAgo": "{count} 分钟前",
"hoursAgo": "{count} 小时前",
"daysAgo": "{count} 天前"
},
"units": {
"tokens": "Token"
@@ -29,6 +29,7 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"title": "活跃账号用量",
"sortByRecent": "最近使用排序",
"empty": "暂无账号请求记录",
@@ -78,8 +79,9 @@
"empty": "暂无成功记录"
},
"clientIdentity": {
"notChecked": "尚未检查",
"clientMode": "运行模式",
"versionPolicy": "版本策略",
"notChecked": "尚未检查",
"customVersion": "固定自定义",
"active": "当前生效",
"activeDescription": "跟随通用设置的新请求使用此身份",
@@ -94,7 +96,6 @@
"verifiedAt": "画像核验 {verifiedAt}",
"checkedAt": "发布检查 {checkedAt}",
"clientId": "客户端 ID",
"tokenAuth": "Token 认证",
"title": "通用上游身份",
"provider": "上游平台",
"empty": "暂无请求身份",
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "周限额(USD)",
"maximumConcurrency": "最大并发",
"requestsPerMinuteRpm": "每分钟请求数(RPM)",
"saveChanges": "保存更改",
"updated": "API Key 已更新",
"created": "API Key 已创建",
"validation": {
@@ -1,6 +1,7 @@
{
"accounts": {
"quota": {
"general": "通用額度",
"usageInThisWindow": "窗口消耗:{localUsageDisplay}",
"quotaUsed": "額度已使用:{rate}",
"resets": "重置時間: {resetAtDisplay}",
@@ -48,9 +49,11 @@
"weekly": "周額度",
"monthly": "月額度",
"annual": "年額度",
"usage": "用量上限",
"secondary": "次級用量上限",
"duration": "{duration}額度"
"primary": "主額度",
"secondary": "次級額度",
"days": "{duration}日額度",
"hours": "{duration}小時額度",
"minutes": "{duration}分鐘額度"
}
},
"account": "賬號",
@@ -138,7 +141,6 @@
"batchEdit": {
"title": "批量編輯賬號",
"selectionTitle": "編輯 {count} 個賬號",
"saveChanges": "保存更改",
"proxyRequired": "請選擇已通過測試的代理",
"saved": "已更新 {count} 個賬號",
"staleAccount": "賬號 {accountId} 的頁面數據已失效,請重新選擇"
@@ -114,8 +114,6 @@
"demoKey": "演示 Key",
"requestHealthTimeline": "請求健康時間線",
"validRequestAvailability": "有效請求可用性",
"clientId": "客戶端 ID",
"tokenAuthentication": "Token 認證",
"todaySToken": "今日 Token",
"hours": "5 小時",
"requests": "08-23 {value}:00–{value2}:00 · {count} 次請求",
@@ -126,7 +126,10 @@
"copied": "已複製"
},
"time": {
"minutesAgo": "{count} 分鐘前"
"justNow": "剛剛",
"minutesAgo": "{count} 分鐘前",
"hoursAgo": "{count} 小時前",
"daysAgo": "{count} 天前"
},
"units": {
"tokens": "Token"
@@ -29,6 +29,7 @@
"unavailableCount": "不可用 {value}"
},
"activeAccounts": {
"todayTokens": "今日 Token",
"title": "活躍賬號用量",
"sortByRecent": "最近使用排序",
"empty": "暫無賬號請求記錄",
@@ -78,8 +79,9 @@
"empty": "暫無成功記錄"
},
"clientIdentity": {
"notChecked": "尚未檢查",
"clientMode": "運行模式",
"versionPolicy": "版本策略",
"notChecked": "尚未檢查",
"customVersion": "固定自定義",
"active": "當前生效",
"activeDescription": "跟隨通用設置的新請求使用此身份",
@@ -94,7 +96,6 @@
"verifiedAt": "畫像覈驗 {verifiedAt}",
"checkedAt": "發佈檢查 {checkedAt}",
"clientId": "客戶端 ID",
"tokenAuth": "Token 認證",
"title": "通用上游身份",
"provider": "上游平臺",
"empty": "暫無請求身份",
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "周限額(USD)",
"maximumConcurrency": "最大併發",
"requestsPerMinuteRpm": "每分鐘請求數(RPM)",
"saveChanges": "保存更改",
"updated": "API Key 已更新",
"created": "API Key 已創建",
"validation": {
+1 -1
View File
@@ -70,7 +70,7 @@ export function formatUsd(value?: string | number | null, precise = false) {
if (parsed == null)
return '—'
const fractionDigits = precise || (Math.abs(parsed) > 0 && Math.abs(parsed) < 0.01) ? 4 : 2
return new Intl.NumberFormat(numberLocale(), {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: fractionDigits,
+17
View File
@@ -0,0 +1,17 @@
import type { RelativeTimeView } from '@/api/time'
import { t } from '@/i18n'
export function formatRelativeTime(value: RelativeTimeView | null | undefined) {
if (value == null)
return '—'
if (value.kind === 'absolute')
return value.value
if (value.kind === 'just_now')
return t('common.time.justNow')
switch (value.unit) {
case 'minute': return t('common.time.minutesAgo', { count: value.value }, value.value)
case 'hour': return t('common.time.hoursAgo', { count: value.value }, value.value)
case 'day': return t('common.time.daysAgo', { count: value.value }, value.value)
}
}
@@ -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>
@@ -35,7 +35,7 @@ const modelUsageColumns = computed(() => (defineTableColumns<AccountModelUsage>(
</script>
<template>
<section class="grid gap-4 rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary xl:min-h-0 xl:grid-cols-[minmax(10rem,1fr)_minmax(0,4fr)]">
<section class="grid gap-4 rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary xl:min-h-0 xl:grid-cols-[0.52fr_1.48fr]">
<div class="xl:flex xl:min-h-0 xl:flex-col">
<div class="mb-3 flex shrink-0 items-center justify-between gap-3">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
@@ -9,6 +9,7 @@ import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import { formatProviderLabel } from '@/utils/providers'
import { formatRelativeTime } from '@/utils/relative-time'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountProfileModal from '../account-profile-modal/index.vue'
import AccountQuotaForecastModal from '../account-quota-forecast-modal/index.vue'
@@ -59,7 +60,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: formatRelativeTime(account.quota.refreshedAtRelative) }) }}</span>
</p>
</div>
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
+1 -1
View File
@@ -194,7 +194,7 @@ function windowRoleOrder(role: AccountQuotaWindow['role']) {
function quotaLimitLabel(window: AccountQuotaWindow) {
if (window.limitId === 'codex')
return 'Codex'
return t('accounts.quota.general')
return window.limitName
}
-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')"
/>
@@ -8,6 +8,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
import { formatCompactNumber } from '@/utils/format'
import { formatRelativeTime } from '@/utils/relative-time'
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,7 @@ 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 }}
{{ formatRelativeTime(account.lastUsedRelative) }}
</span>
</span>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { DashboardWireProfile } from '@/api/modules/dashboard'
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { Box, CheckCircle2, Monitor, RefreshCw, ShieldCheck, Terminal, TriangleAlert } from '@lucide/vue'
@@ -8,36 +9,11 @@ import { useI18n } from 'vue-i18n'
import { formatProviderLabel, isSupportedProvider, providerIcon } from '@/utils/providers'
const props = defineProps<{
profiles: WireProfile[]
profiles: DashboardWireProfile[]
}>()
const { t } = useI18n({ useScope: 'global' })
interface WireProfile {
provider: string
product: string
version: string
build?: string | null
target: {
osType: string
osVersion: string
arch: string
terminal: string
}
userAgent: string
attributes: Array<{ label: string, value: string }>
verifiedAt?: string | null
verifiedAtDisplay?: string | null
release?: {
status?: 'unchecked' | 'aligned' | 'review_required' | 'check_failed'
checkedAt?: string | null
checkedAtDisplay?: string | null
latestVersion?: string | null
latestBuild?: string | null
error?: string | null
} | null
}
const activeProvider = shallowRef('')
const providerOptions = computed(() =>
@@ -75,7 +51,7 @@ const releaseStatus = computed(() => {
const current = profile.value
if (!current?.release) {
return {
label: current?.attributes.some(item => item.label === t('dashboard.clientIdentity.versionPolicy') && item.value === t('dashboard.clientIdentity.customVersion')) ? t('dashboard.clientIdentity.customVersion') : t('dashboard.clientIdentity.active'),
label: current?.attributes.some(item => item.key === 'version_policy' && item.value === 'fixed') ? t('dashboard.clientIdentity.customVersion') : t('dashboard.clientIdentity.active'),
title: t('dashboard.clientIdentity.activeDescription'),
tone: 'bg-cp-info-container text-cp-info-on-container',
icon: ShieldCheck,
@@ -137,21 +113,33 @@ const clientIdentity = computed(() => {
const current = profile.value
if (!current)
return '—'
const value = current.attributes.find(attribute => attribute.label === t('dashboard.clientIdentity.clientId'))?.value ?? '—'
const value = current.attributes.find(attribute => attribute.key === 'client_identifier')?.value
if (!value)
return '—'
return current.provider === 'xai' ? toPascalCase(value) : value
})
const attributeLabels = computed<Record<string, string>>(() => ({
client_identifier: t('dashboard.clientIdentity.clientId'),
token_auth: t('dashboard.clientIdentity.authProtocol'),
client_mode: t('dashboard.clientIdentity.clientMode'),
version_policy: t('dashboard.clientIdentity.versionPolicy'),
}))
const identityAttributes = computed(() => {
const current = profile.value
if (!current)
return []
return isSupportedProvider(current.provider)
? [{ label: t('dashboard.clientIdentity.clientId'), value: clientIdentity.value }]
: current.attributes
: current.attributes.map(attribute => ({
label: attributeLabels.value[attribute.key] ?? attribute.key,
value: attribute.value,
}))
})
const authProtocol = computed(() => {
const value = profile.value?.attributes.find(attribute => attribute.label === t('dashboard.clientIdentity.tokenAuth'))?.value
const value = profile.value?.attributes.find(attribute => attribute.key === 'token_auth')?.value
if (!value)
return '—'
return toPascalCase(value)
-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')"
/>
+3 -4
View File
@@ -1,15 +1,14 @@
import { t } from '@/i18n'
import { numberLocale } from '@/utils/format'
export function money(value: string | number | null, currency = 'USD') {
export function money(value: string | number | null) {
if (value === null)
return t('usage.metrics.notPriced')
const amount = Number(value)
if (!Number.isFinite(amount))
return '—'
return new Intl.NumberFormat(numberLocale(), {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 6,
}).format(amount)
@@ -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')"
/>
@@ -83,7 +83,7 @@ const submitLabel = computed(() => {
>
{{ t('auth.title') }}
</h1>
<p class="m-0 -ml-2 text-sm leading-[1.45] font-normal text-(--cp-login-description-color)">
<p class="m-0 text-sm leading-[1.45] font-normal text-(--cp-login-description-color) [&:lang(zh)]:-ml-2">
{{ t('auth.welcome') }}
</p>
</section>
+2 -11
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import { ZSelect } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useLocale } from '@/composables/useLocale'
import AppLanguageMenu from '@/components/AppLanguageMenu.vue'
import { useAuthStore } from '@/stores/modules/auth'
import LoginBackground from './components/LoginBackground.vue'
@@ -19,7 +17,6 @@ type LoginRealm = 'admin' | 'key'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { locale, localeOptions, setLocale } = useLocale()
const realm = shallowRef<LoginRealm>(normalizeRealm(window.history.state?.loginMode))
const username = shallowRef('')
@@ -81,13 +78,7 @@ function resolveDestination(): string {
<LoginBackground />
<div class="absolute top-5 right-5 z-20 flex items-center gap-2 max-[560px]:top-4 max-[560px]:right-4">
<ZSelect
:model-value="locale"
:options="localeOptions"
:aria-label="t('common.language')"
class="w-36"
@update:model-value="setLocale"
/>
<AppLanguageMenu size="default" class="[&>button:hover]:bg-cp-bg-text-hover" />
<LoginThemeToggle />
</div>
-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>
+16 -13
View File
@@ -81,21 +81,12 @@ function previewTrendPoint(requestsValue: number, index: number): DashboardTrend
tokensValue: inputTokensValue + outputTokensValue,
errors: errorsValue.toLocaleString('zh-CN'),
errorsValue,
latency: `${410 + index * 7} ms`,
latencyValue: 410 + index * 7,
maxLatency: `${760 + index * 12} ms`,
maxLatencyValue: 760 + index * 12,
minLatency: `${180 + index * 3} ms`,
minLatencyValue: 180 + index * 3,
successRate: `${successRateValue.toFixed(1)}%`,
successRateValue,
firstTokenP50Ms: 210 + index * 3,
firstTokenP95Ms: 420 + index * 5,
latencyP95Ms: 760 + index * 12,
outputThroughputP50: 52 + (index % 5),
admissionDecisionP95Ms: 2 + (index % 3),
accountSelectionWaitP95Ms: 8 + (index % 4),
capacityUtilization: 0.42 + (index % 6) * 0.04,
}
}
@@ -286,8 +277,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
},
userAgent: 'codex_cli_rs/0.163.0-alpha.10.2 (Mac OS 27.0.1; arm64) Codex Desktop; 26.1013.40934',
attributes: [
{ label: t('appearance.preview.dashboard.clientId'), value: 'Codex Desktop; 26.1013.40934' },
{ label: t('appearance.preview.dashboard.tokenAuthentication'), value: 'bearer' },
{ key: 'client_identifier', value: 'Codex Desktop; 26.1013.40934' },
{ key: 'token_auth', value: 'bearer' },
],
verifiedAt: '2026-10-17T20:15:28+08:00',
verifiedAtDisplay: '2026-10-17 20:15:28',
@@ -310,8 +301,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
},
userAgent: 'grok_cli/0.7.3',
attributes: [
{ label: t('appearance.preview.dashboard.clientId'), value: 'grok_cli' },
{ label: t('appearance.preview.dashboard.tokenAuthentication'), value: 'bearer' },
{ key: 'client_identifier', value: 'grok_cli' },
{ key: 'token_auth', value: 'bearer' },
],
verifiedAt: '2026-08-23T10:25:00+08:00',
verifiedAtDisplay: '2026-08-23 10:25:00',
@@ -337,6 +328,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 +345,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
resetAtDisplay: '—',
},
lastUsed: t('appearance.preview.components.justNow'),
lastUsedAt: '2026-08-23T10:30:00+08:00',
lastUsedRelative: { kind: 'just_now' },
},
{
id: 'preview-xai-1',
@@ -370,6 +365,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 +391,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
},
},
lastUsed: t('common.time.minutesAgo', { count: 5 }),
lastUsedAt: '2026-08-23T10:25:00+08:00',
lastUsedRelative: { kind: 'elapsed', value: 5, unit: 'minute' },
},
{
id: 'preview-openai-2',
@@ -408,6 +407,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 +424,8 @@ export const themeDashboardSummary = computed<DashboardSummaryResponse>(() => ({
resetAtDisplay: '—',
},
lastUsed: t('common.time.minutesAgo', { count: 8 }),
lastUsedAt: '2026-08-23T10:22:00+08:00',
lastUsedRelative: { kind: 'elapsed', value: 8, unit: 'minute' },
},
],
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
}
+11 -19
View File
@@ -1,25 +1,17 @@
# v3.30.0
# v3.30.2
## 升级说明
- 前后端需同步更新。管理 API 的账号额度窗口由 `windowLabelDisplay` 改为 `windowKind`,趋势摘要由 `label` 改为 `metric`,展示文案由前端按语言生成;自行对接这些字段的客户端需同步适配
- 用量请求明细改用 `clientApiKeyName`、`accountName` 和 `model` 按字面包含筛选,移除旧的通用 `search` 及其他未保留的筛选参数;汇总与洞察仅按时间和 Provider 筛选。自定义管理 API 调用方请按本版 API 文档调整参数
- 诊断导出接口改为返回 ZIP,包内包含 `diagnostics.json`、`summary.txt` 和 `README.txt`;直接消费原 JSON 下载响应的工具需改为解包读取
## 新功能
- 管理端与 Key 用量页支持简体中文、繁體中文和 English,切换后同步更新表格、图表、通知、共享组件与主题预览,并保存语言选择
- 诊断包补充请求输入与最终准备发送正文的指纹、上游终态用量和网关归集值,为关键事件保留采集预算,并明确标记缺失或截断的证据,便于排查缓存、用量与重试问题
- API 密钥创建、配置和列表操作统一使用客户端导入弹窗,提供 CC Switch 导入入口及项目链接
- 前后端需同步更新,管理端使用新增的账号指标原始值、相对时间结构和平均延迟数值生成本地化显示
- Dashboard 趋势 `points[]` 移除页面未使用的 `latency`、`maxLatency`、`maxLatencyValue`、`minLatency`、`minLatencyValue`、`firstTokenP50Ms`、`admissionDecisionP95Ms`、`accountSelectionWaitP95Ms` 和 `capacityUtilization`;自行对接这些字段的客户端需同步适配,平均延迟仍使用 `latencyValue`,内部调度分位数和容量分布通过用量洞察查询
## 修复与调整
- OpenAI 出站会话、窗口和缓存关联标识按选中账号稳定隔离,剥离客户端 trace;轮次 ID、父子轮次引用和续接引用保留客户端原值
- Live 语音引导、sideband 与挂断转发业务扩展头,保留 `X-Session-Id`、`Voice-Session-Id` 及通话归属;追踪标识按账号隔离,HTTP 业务头保留重复值和原始字节
- 修复位置覆盖在跨日、续接或重放时刷新历史上下文日期的问题,保留原日期,仅调整时区和搜索位置
- 调整新建的工作区隐私预设,仅改写附加元数据中的用户名和 Git 远端命名空间,保留项目名与工具执行路径;已有自定义规则保持原配置
- 修复 SSE / WebSocket 流内 404 请求错误被误判为路径封禁、进而重试换号或将账号标为凭据无效的问题
- 完善取消期间的请求证据与插件中间件处理边界;备份创建、删除请求与审计记录原子写入,账号分组变更保留已提交结果,重启受理后阻止并发更新、回滚和重复重启
- 统一管理端流式请求和插件页面的会话恢复,身份切换后取消迟到响应,及时清理失效的 API 密钥明文
- 优化用量汇总、请求计数、账号额度和冷却状态查询,减少重复数据库读取;统一管理端与 Key 用量页的字段筛选,并调整表格间距、菜单、统计卡片和窄屏布局
- 更新 DOMPurify、`ctutils`、`chacha20` 和 `wnaf` 等依赖,修复已知安全问题并移出已撤回的依赖版本
- 修复 Dashboard 活跃账号的指标名称、数字和最近使用时间,以及账号额度刷新时间未随界面语言切换的问题
- 相对时间继续由服务端按查询锚点、当前时间和部署时区确定,前端只翻译分类与单位,保留分钟、小时、天的边界及未来时间的完整日期显示
- 调整分析统计页平均延迟的数值与单位间距,统一账号和 API Key 编辑弹窗的保存文案,并让账号、分组、密钥、代理及主题设置页复用页面标题的响应式高度
## 性能优化
- 统计直方图先合并相同的正值样本再计算对数分桶,减少重复计算,保留原有桶边界、样本权重和统计结果;实际收益取决于样本重复度
- 精简 Dashboard 趋势响应中未使用的字段,减少传输体积
+1 -1
View File
@@ -1 +1 @@
version: "3.30.0"
version: "3.30.2"