Compare commits

...
5 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
47 changed files with 567 additions and 115 deletions
@@ -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),
}
}
@@ -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,
}
@@ -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));
}
}
}
@@ -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(
+21 -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,6 +2022,10 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
图表颜色、系列身份和选中状态使用 `metric`,不根据译文或数组位置推断指标
`points[]` 提供趋势图与首页卡片使用的请求数、Tokens、缓存比例、错误数、成功率、
`latencyValue`、首 Token P95、总延迟 P95 和输出吞吐 P50,以及时间标签与展示值
内部调度分位数和容量分布通过用量洞察查询
### Dashboard 客户端身份
`wireProfiles[].attributes[]` 返回稳定的 `key` 和 `value`,字段名称由前端本地化
@@ -2026,7 +2043,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`;前端负责指标名称和数字本地化,相对时间按[页面时间合同](#页面时间合同)翻译 `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。
+5 -9
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
@@ -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 }
@@ -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"
+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"
@@ -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} 的页面数据已失效,请重新选择"
@@ -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": "暂无账号请求记录",
@@ -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} 的頁面數據已失效,請重新選擇"
@@ -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": "暫無賬號請求記錄",
@@ -68,7 +68,6 @@
"weeklyLimitUsd": "周限額(USD)",
"maximumConcurrency": "最大併發",
"requestsPerMinuteRpm": "每分鐘請求數(RPM)",
"saveChanges": "保存更改",
"updated": "API Key 已更新",
"created": "API Key 已創建",
"validation": {
+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>
@@ -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
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
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>
+12 -9
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,
}
}
@@ -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 -7
View File
@@ -1,13 +1,17 @@
# v3.30.1
# v3.30.2
## 升级说明
- 前后端需同步更新。Dashboard 客户端身份属性 `wireProfiles[].attributes[]` 以 `key` 代替 `label`,版本策略值统一为 `fixed` / `latest`,展示文案由前端按语言生成;自行对接这些字段的客户端需同步适配
- 前后端需同步更新,管理端使用新增的账号指标原始值、相对时间结构和平均延迟数值生成本地化显示
- Dashboard 趋势 `points[]` 移除页面未使用的 `latency`、`maxLatency`、`maxLatencyValue`、`minLatency`、`minLatencyValue`、`firstTokenP50Ms`、`admissionDecisionP95Ms`、`accountSelectionWaitP95Ms` 和 `capacityUtilization`;自行对接这些字段的客户端需同步适配,平均延迟仍使用 `latencyValue`,内部调度分位数和容量分布通过用量洞察查询
## 修复与调整
- 恢复“通用额度”及自定义“主额度/次级额度”文案,自定义周期按天、小时或分钟展示,修复“150 分钟额度”变为“9,000 秒额度”的问题
- 恢复账号详情中 Token 用量面板的原有宽度,美元金额统一使用 `$`,避免切换语言后显示为 `US$`
- 修复客户端 ID、xAI 认证协议及固定版本状态依赖中文字段匹配的问题,改用后端稳定属性键和值,由前端处理多语言显示
- 登录页语言切换使用统一图标菜单,悬停背景与主题开关保持一致;修复英文欢迎语对齐,统一英文登录文案
- 将 `hyper-util` 固定到正式版 `0.1.21`,移除临时 Git 补丁
- 修复 Dashboard 活跃账号的指标名称、数字和最近使用时间,以及账号额度刷新时间未随界面语言切换的问题
- 相对时间继续由服务端按查询锚点、当前时间和部署时区确定,前端只翻译分类与单位,保留分钟、小时、天的边界及未来时间的完整日期显示
- 调整分析统计页平均延迟的数值与单位间距,统一账号和 API Key 编辑弹窗的保存文案,并让账号、分组、密钥、代理及主题设置页复用页面标题的响应式高度
## 性能优化
- 统计直方图先合并相同的正值样本再计算对数分桶,减少重复计算,保留原有桶边界、样本权重和统计结果;实际收益取决于样本重复度
- 精简 Dashboard 趋势响应中未使用的字段,减少传输体积
+1 -1
View File
@@ -1 +1 @@
version: "3.30.1"
version: "3.30.2"