mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 00:05:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76291f448c | ||
|
|
c10efea12c | ||
|
|
806f7ad9ee | ||
|
|
632edee7dc | ||
|
|
22518d064c |
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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 投影。套餐缺失不表示免费套餐,显示时舍入的百分比不用于判断触顶
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface UsageSummaryResponse {
|
||||
cacheWriteInputTokens: string
|
||||
totalTokens: string
|
||||
averageLatencyMs: string
|
||||
averageLatencyMsValue: number | null
|
||||
}
|
||||
|
||||
export interface UsageOverviewHealthPoint {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 757a276fd2...74c9281071
+1
-1
Submodule modules/ui updated: acd8578f82...8177ba7413
+11
-7
@@ -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 @@
|
||||
version: "3.30.1"
|
||||
version: "3.30.2"
|
||||
|
||||
Reference in New Issue
Block a user