mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-14 23:20:19 +08:00
Merge remote-tracking branch 'origin/pr/592'
This commit is contained in:
@@ -9,9 +9,9 @@ use aether_admin::observability::usage::{
|
|||||||
admin_usage_data_unavailable_response, admin_usage_has_fallback, admin_usage_is_failed,
|
admin_usage_data_unavailable_response, admin_usage_has_fallback, admin_usage_is_failed,
|
||||||
admin_usage_matches_search, admin_usage_matches_username, admin_usage_parse_ids,
|
admin_usage_matches_search, admin_usage_matches_username, admin_usage_parse_ids,
|
||||||
admin_usage_parse_limit, admin_usage_parse_offset, admin_usage_provider_key_name,
|
admin_usage_parse_limit, admin_usage_parse_offset, admin_usage_provider_key_name,
|
||||||
admin_usage_record_json, build_admin_usage_active_requests_response,
|
admin_usage_record_json, attach_usage_server_now_header,
|
||||||
build_admin_usage_records_response, build_admin_usage_summary_stats_response_from_summary,
|
build_admin_usage_active_requests_response, build_admin_usage_records_response,
|
||||||
ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
build_admin_usage_summary_stats_response_from_summary, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
|
||||||
};
|
};
|
||||||
use aether_data::repository::users::StoredUserSummary;
|
use aether_data::repository::users::StoredUserSummary;
|
||||||
use aether_data_contracts::repository::{
|
use aether_data_contracts::repository::{
|
||||||
@@ -314,13 +314,15 @@ fn build_admin_usage_records_response_with_attempt_flags(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
attach_usage_server_now_header(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"records": records,
|
"records": records,
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_admin_usage_records_query(
|
fn build_admin_usage_records_query(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use aether_ai_serving::UPSTREAM_IS_STREAM_KEY;
|
|||||||
use aether_billing::{
|
use aether_billing::{
|
||||||
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
|
normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate,
|
||||||
};
|
};
|
||||||
|
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
use aether_data_contracts::repository::usage::{
|
use aether_data_contracts::repository::usage::{
|
||||||
StoredRequestUsageAudit, StoredUsageBreakdownSummaryRow, StoredUsageDailySummary,
|
StoredRequestUsageAudit, StoredUsageBreakdownSummaryRow, StoredUsageDailySummary,
|
||||||
UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageBreakdownGroupBy,
|
UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageBreakdownGroupBy,
|
||||||
@@ -29,6 +30,21 @@ use super::{
|
|||||||
|
|
||||||
const USERS_ME_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "用户用量数据暂不可用";
|
const USERS_ME_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "用户用量数据暂不可用";
|
||||||
|
|
||||||
|
fn users_me_usage_server_now_unix_ms() -> u64 {
|
||||||
|
u64::try_from(Utc::now().timestamp_millis()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_users_me_usage_server_now_header(mut response: Response<Body>) -> Response<Body> {
|
||||||
|
if let Ok(value) = http::HeaderValue::from_str(&users_me_usage_server_now_unix_ms().to_string())
|
||||||
|
{
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::HeaderName::from_static(USAGE_SERVER_NOW_UNIX_MS_HEADER),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
fn build_users_me_usage_reader_unavailable_response() -> Response<Body> {
|
fn build_users_me_usage_reader_unavailable_response() -> Response<Body> {
|
||||||
build_auth_error_response(
|
build_auth_error_response(
|
||||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -1094,7 +1110,7 @@ pub(super) async fn handle_users_me_usage_get(
|
|||||||
&summary_by_provider
|
&summary_by_provider
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Json(payload).into_response()
|
attach_users_me_usage_server_now_header(Json(payload).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_users_me_usage_active_get(
|
pub(super) async fn handle_users_me_usage_active_get(
|
||||||
@@ -1169,13 +1185,15 @@ pub(super) async fn handle_users_me_usage_active_get(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
attach_users_me_usage_server_now_header(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"requests": items
|
"requests": items
|
||||||
.iter()
|
.iter()
|
||||||
.map(build_users_me_usage_active_payload)
|
.map(build_users_me_usage_active_payload)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_users_me_usage_interval_timeline_get(
|
pub(super) async fn handle_users_me_usage_interval_timeline_get(
|
||||||
@@ -1364,12 +1382,20 @@ async fn build_usage_heatmap_summaries(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_users_me_usage_active_payload, build_users_me_usage_record_payload,
|
attach_users_me_usage_server_now_header, build_users_me_usage_active_payload,
|
||||||
users_me_usage_client_is_stream, users_me_usage_is_failed,
|
build_users_me_usage_record_payload, users_me_usage_client_is_stream,
|
||||||
|
users_me_usage_is_failed, users_me_usage_server_now_unix_ms,
|
||||||
users_me_usage_upstream_is_stream,
|
users_me_usage_upstream_is_stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1415,6 +1441,39 @@ mod tests {
|
|||||||
.expect("usage should build")
|
.expect("usage should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn users_me_usage_server_now_unix_ms_uses_epoch_millis() {
|
||||||
|
let before = u64::try_from(Utc::now().timestamp_millis()).unwrap_or_default();
|
||||||
|
let value = users_me_usage_server_now_unix_ms();
|
||||||
|
let after = u64::try_from(Utc::now().timestamp_millis()).unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(value >= before);
|
||||||
|
assert!(value <= after);
|
||||||
|
assert!(value > 1_000_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_users_me_usage_server_now_header(response: &Response<Body>) {
|
||||||
|
let value = response
|
||||||
|
.headers()
|
||||||
|
.get(USAGE_SERVER_NOW_UNIX_MS_HEADER)
|
||||||
|
.expect("user usage response should include server now header")
|
||||||
|
.to_str()
|
||||||
|
.expect("server now header should be valid ASCII")
|
||||||
|
.parse::<u64>()
|
||||||
|
.expect("server now header should be epoch millis");
|
||||||
|
|
||||||
|
assert!(value > 1_000_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn users_me_usage_server_now_header_is_added_to_response() {
|
||||||
|
let response = attach_users_me_usage_server_now_header(
|
||||||
|
Json(json!({ "requests": [] })).into_response(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_users_me_usage_server_now_header(&response);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_usage_record_payload_rehydrates_cache_creation_total_from_classified_fields() {
|
fn user_usage_record_payload_rehydrates_cache_creation_total_from_classified_fields() {
|
||||||
let item = StoredRequestUsageAudit {
|
let item = StoredRequestUsageAudit {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Request, State};
|
use axum::extract::{Request, State};
|
||||||
use axum::http::{self, HeaderValue, Response};
|
use axum::http::{self, HeaderValue, Response};
|
||||||
@@ -6,6 +7,8 @@ use axum::middleware::Next;
|
|||||||
use crate::headers::header_value_str;
|
use crate::headers::header_value_str;
|
||||||
use crate::state::{AppState, FrontdoorCorsConfig};
|
use crate::state::{AppState, FrontdoorCorsConfig};
|
||||||
|
|
||||||
|
const FRONTDOOR_CREDENTIALS_EXPOSE_HEADERS: &str = "*, x-aether-server-now-unix-ms";
|
||||||
|
|
||||||
fn append_vary(headers: &mut http::HeaderMap, value: &'static str) {
|
fn append_vary(headers: &mut http::HeaderMap, value: &'static str) {
|
||||||
headers.append(http::header::VARY, HeaderValue::from_static(value));
|
headers.append(http::header::VARY, HeaderValue::from_static(value));
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,11 @@ fn apply_frontdoor_cors_headers(
|
|||||||
);
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
|
http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||||
HeaderValue::from_static("*"),
|
HeaderValue::from_static(if cors.allow_credentials() {
|
||||||
|
FRONTDOOR_CREDENTIALS_EXPOSE_HEADERS
|
||||||
|
} else {
|
||||||
|
"*"
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
if let Some(value) = requested_headers {
|
if let Some(value) = requested_headers {
|
||||||
if let Ok(value) = HeaderValue::from_str(value) {
|
if let Ok(value) = HeaderValue::from_str(value) {
|
||||||
@@ -109,3 +116,52 @@ pub(crate) async fn frontdoor_cors_middleware(
|
|||||||
);
|
);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_exposes_header(value: &HeaderValue, expected: &str) {
|
||||||
|
let exposed_headers = value
|
||||||
|
.to_str()
|
||||||
|
.expect("expose headers should be valid ASCII");
|
||||||
|
assert!(
|
||||||
|
exposed_headers
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.any(|header| header.eq_ignore_ascii_case(expected)),
|
||||||
|
"{exposed_headers} should include {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontdoor_cors_explicitly_exposes_usage_server_time_for_credentials() {
|
||||||
|
let cors = FrontdoorCorsConfig::new(vec!["http://localhost:5173".to_string()], true)
|
||||||
|
.expect("cors config should build");
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
|
||||||
|
apply_frontdoor_cors_headers(&mut headers, &cors, "http://localhost:5173", None);
|
||||||
|
|
||||||
|
let expose_headers = headers
|
||||||
|
.get(http::header::ACCESS_CONTROL_EXPOSE_HEADERS)
|
||||||
|
.expect("expose headers should be set");
|
||||||
|
assert_exposes_header(expose_headers, "*");
|
||||||
|
assert_exposes_header(expose_headers, USAGE_SERVER_NOW_UNIX_MS_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontdoor_cors_keeps_wildcard_expose_headers_without_credentials() {
|
||||||
|
let cors = FrontdoorCorsConfig::new(vec!["http://localhost:5173".to_string()], false)
|
||||||
|
.expect("cors config should build");
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
|
||||||
|
apply_frontdoor_cors_headers(&mut headers, &cors, "http://localhost:5173", None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::ACCESS_CONTROL_EXPOSE_HEADERS)
|
||||||
|
.expect("expose headers should be set"),
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use crate::constants::{
|
|||||||
};
|
};
|
||||||
use crate::control::resolve_public_request_context;
|
use crate::control::resolve_public_request_context;
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
use crate::tests::{assert_usage_server_now_header_between, unix_epoch_millis_for_tests};
|
||||||
|
|
||||||
const ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "Admin usage data unavailable";
|
const ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "Admin usage data unavailable";
|
||||||
const DAY_1_UNIX_SECS: i64 = 1_711_000_000;
|
const DAY_1_UNIX_SECS: i64 = 1_711_000_000;
|
||||||
@@ -1014,6 +1015,7 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
|
|||||||
);
|
);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let client_send_unix_ms = unix_epoch_millis_for_tests();
|
||||||
let response =
|
let response =
|
||||||
admin_request(reqwest::Client::new().get(format!(
|
admin_request(reqwest::Client::new().get(format!(
|
||||||
"{gateway_url}/api/admin/usage/active?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0"
|
"{gateway_url}/api/admin/usage/active?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0"
|
||||||
@@ -1021,9 +1023,17 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
let client_receive_unix_ms = unix_epoch_millis_for_tests();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
assert_usage_server_now_header_between(
|
||||||
|
&response_headers,
|
||||||
|
client_send_unix_ms,
|
||||||
|
client_receive_unix_ms,
|
||||||
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload.get("server_now_unix_ms").is_none());
|
||||||
assert_eq!(payload["requests"].as_array().expect("array").len(), 1);
|
assert_eq!(payload["requests"].as_array().expect("array").len(), 1);
|
||||||
assert_eq!(payload["requests"][0]["id"], "usage-pending");
|
assert_eq!(payload["requests"][0]["id"], "usage-pending");
|
||||||
assert_eq!(payload["requests"][0]["effective_input_tokens"], 5);
|
assert_eq!(payload["requests"][0]["effective_input_tokens"], 5);
|
||||||
@@ -1233,15 +1243,24 @@ async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principa
|
|||||||
);
|
);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let client_send_unix_ms = unix_epoch_millis_for_tests();
|
||||||
let response = admin_request(reqwest::Client::new().get(format!(
|
let response = admin_request(reqwest::Client::new().get(format!(
|
||||||
"{gateway_url}/api/admin/usage/records?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0&status=failed&provider=Anthropic&limit=10&offset=0"
|
"{gateway_url}/api/admin/usage/records?start_date=2024-03-21&end_date=2024-03-22&tz_offset_minutes=0&status=failed&provider=Anthropic&limit=10&offset=0"
|
||||||
)))
|
)))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
let client_receive_unix_ms = unix_epoch_millis_for_tests();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
assert_usage_server_now_header_between(
|
||||||
|
&response_headers,
|
||||||
|
client_send_unix_ms,
|
||||||
|
client_receive_unix_ms,
|
||||||
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload.get("server_now_unix_ms").is_none());
|
||||||
assert_eq!(payload["total"], 1);
|
assert_eq!(payload["total"], 1);
|
||||||
assert_eq!(payload["records"][0]["id"], "usage-b");
|
assert_eq!(payload["records"][0]["id"], "usage-b");
|
||||||
assert_eq!(payload["records"][0]["username"], "bob");
|
assert_eq!(payload["records"][0]["username"], "bob");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::tests::{
|
|||||||
build_state_with_execution_runtime_override, json, start_server, AppState, Arc, Body,
|
build_state_with_execution_runtime_override, json, start_server, AppState, Arc, Body,
|
||||||
FrontdoorCorsConfig, Mutex, Request, Router, StatusCode, FRONTDOOR_MANIFEST_PATH, READYZ_PATH,
|
FrontdoorCorsConfig, Mutex, Request, Router, StatusCode, FRONTDOOR_MANIFEST_PATH, READYZ_PATH,
|
||||||
};
|
};
|
||||||
|
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||||
use aether_data::repository::auth::InMemoryAuthApiKeySnapshotRepository;
|
use aether_data::repository::auth::InMemoryAuthApiKeySnapshotRepository;
|
||||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||||
@@ -440,12 +441,19 @@ async fn gateway_adds_cors_headers_to_proxied_responses() {
|
|||||||
.expect("allow origin header"),
|
.expect("allow origin header"),
|
||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let expose_headers = response_headers
|
||||||
response_headers
|
|
||||||
.get("access-control-expose-headers")
|
.get("access-control-expose-headers")
|
||||||
.expect("expose headers header"),
|
.expect("expose headers header")
|
||||||
"*"
|
.to_str()
|
||||||
);
|
.expect("expose headers should be valid ASCII");
|
||||||
|
assert!(expose_headers
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.any(|header| header == "*"));
|
||||||
|
assert!(expose_headers
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.any(|header| header.eq_ignore_ascii_case(USAGE_SERVER_NOW_UNIX_MS_HEADER)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||||
1
|
1
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
use crate::tests::{
|
use crate::tests::{
|
||||||
any, build_router, build_router_with_state, json, start_server, to_bytes, AppState, Arc, Body,
|
any, assert_usage_server_now_header_between, build_router, build_router_with_state, json,
|
||||||
Json, Mutex, Request, Router, StatusCode, CONTROL_ROUTE_FAMILY_HEADER,
|
start_server, to_bytes, unix_epoch_millis_for_tests, AppState, Arc, Body, Json, Mutex, Request,
|
||||||
CONTROL_ROUTE_KIND_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
Router, StatusCode, CONTROL_ROUTE_FAMILY_HEADER, CONTROL_ROUTE_KIND_HEADER,
|
||||||
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER, TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||||
};
|
};
|
||||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||||
use aether_data::repository::announcements::{AnnouncementListQuery, AnnouncementReadRepository};
|
use aether_data::repository::announcements::{AnnouncementListQuery, AnnouncementReadRepository};
|
||||||
@@ -5345,6 +5345,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let client_send_unix_ms = unix_epoch_millis_for_tests();
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"{gateway_url}/api/users/me/usage?limit=10&offset=0&search=renamed-key"
|
"{gateway_url}/api/users/me/usage?limit=10&offset=0&search=renamed-key"
|
||||||
@@ -5355,9 +5356,17 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
let client_receive_unix_ms = unix_epoch_millis_for_tests();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
assert_usage_server_now_header_between(
|
||||||
|
&response_headers,
|
||||||
|
client_send_unix_ms,
|
||||||
|
client_receive_unix_ms,
|
||||||
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload.get("server_now_unix_ms").is_none());
|
||||||
assert_eq!(payload["total_requests"], 2);
|
assert_eq!(payload["total_requests"], 2);
|
||||||
assert_eq!(payload["total_input_tokens"], 240);
|
assert_eq!(payload["total_input_tokens"], 240);
|
||||||
assert_eq!(payload["pagination"]["total"], 3);
|
assert_eq!(payload["pagination"]["total"], 3);
|
||||||
@@ -5667,6 +5676,7 @@ async fn gateway_handles_users_me_usage_active_locally_without_proxying_upstream
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let client_send_unix_ms = unix_epoch_millis_for_tests();
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.get(format!("{gateway_url}/api/users/me/usage/active"))
|
.get(format!("{gateway_url}/api/users/me/usage/active"))
|
||||||
.header("authorization", format!("Bearer {access_token}"))
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
@@ -5675,9 +5685,17 @@ async fn gateway_handles_users_me_usage_active_locally_without_proxying_upstream
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
let client_receive_unix_ms = unix_epoch_millis_for_tests();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response_headers = response.headers().clone();
|
||||||
|
assert_usage_server_now_header_between(
|
||||||
|
&response_headers,
|
||||||
|
client_send_unix_ms,
|
||||||
|
client_receive_unix_ms,
|
||||||
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert!(payload.get("server_now_unix_ms").is_none());
|
||||||
let requests = payload["requests"].as_array().expect("requests array");
|
let requests = payload["requests"].as_array().expect("requests array");
|
||||||
assert_eq!(requests.len(), 2);
|
assert_eq!(requests.len(), 2);
|
||||||
assert_eq!(requests[0]["status"], "streaming");
|
assert_eq!(requests[0]["status"], "streaming");
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
pub(super) use std::convert::Infallible;
|
pub(super) use std::convert::Infallible;
|
||||||
pub(super) use std::sync::{Arc, Mutex};
|
pub(super) use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
pub(super) use axum::body::{to_bytes, Body, Bytes};
|
pub(super) use axum::body::{to_bytes, Body, Bytes};
|
||||||
pub(super) use axum::response::Response;
|
pub(super) use axum::response::Response;
|
||||||
pub(super) use axum::routing::any;
|
pub(super) use axum::routing::any;
|
||||||
@@ -29,6 +32,38 @@ pub(super) use super::router::{attach_static_frontend, build_router, build_route
|
|||||||
pub(super) use super::state::{AppState, FrontdoorCorsConfig};
|
pub(super) use super::state::{AppState, FrontdoorCorsConfig};
|
||||||
pub(super) use super::usage::UsageRuntimeConfig;
|
pub(super) use super::usage::UsageRuntimeConfig;
|
||||||
|
|
||||||
|
const SERVER_NOW_HEADER_TEST_TOLERANCE_MS: u64 = 1_000;
|
||||||
|
|
||||||
|
pub(super) fn unix_epoch_millis_for_tests() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("current time should be after epoch")
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.expect("current epoch millis should fit in u64")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn assert_usage_server_now_header_between(
|
||||||
|
headers: &reqwest::header::HeaderMap,
|
||||||
|
lower_bound_unix_ms: u64,
|
||||||
|
upper_bound_unix_ms: u64,
|
||||||
|
) {
|
||||||
|
let server_now_unix_ms = headers
|
||||||
|
.get(USAGE_SERVER_NOW_UNIX_MS_HEADER)
|
||||||
|
.expect("usage response should include server timing header")
|
||||||
|
.to_str()
|
||||||
|
.expect("server timing header should be valid ASCII")
|
||||||
|
.parse::<u64>()
|
||||||
|
.expect("server timing header should be epoch millis");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
server_now_unix_ms >= lower_bound_unix_ms.saturating_sub(SERVER_NOW_HEADER_TEST_TOLERANCE_MS)
|
||||||
|
&& server_now_unix_ms
|
||||||
|
<= upper_bound_unix_ms.saturating_add(SERVER_NOW_HEADER_TEST_TOLERANCE_MS),
|
||||||
|
"server timing header {server_now_unix_ms} should be near request window {lower_bound_unix_ms}..={upper_bound_unix_ms}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn start_server(app: Router) -> (String, tokio::task::JoinHandle<()>) {
|
pub(super) async fn start_server(app: Router) -> (String, tokio::task::JoinHandle<()>) {
|
||||||
let listener = crate::test_support::bind_loopback_listener()
|
let listener = crate::test_support::bind_loopback_listener()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -19,8 +19,24 @@ use serde_json::{json, Value};
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
pub use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||||
|
|
||||||
pub const ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "Admin usage data unavailable";
|
pub const ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "Admin usage data unavailable";
|
||||||
|
|
||||||
|
pub fn usage_server_now_unix_ms() -> u64 {
|
||||||
|
u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach_usage_server_now_header(mut response: Response<Body>) -> Response<Body> {
|
||||||
|
if let Ok(value) = http::HeaderValue::from_str(&usage_server_now_unix_ms().to_string()) {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::HeaderName::from_static(USAGE_SERVER_NOW_UNIX_MS_HEADER),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
pub fn admin_usage_data_unavailable_response(detail: &'static str) -> Response<Body> {
|
pub fn admin_usage_data_unavailable_response(detail: &'static str) -> Response<Body> {
|
||||||
(
|
(
|
||||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -2275,7 +2291,7 @@ pub fn build_admin_usage_active_requests_response(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Json(json!({ "requests": payload })).into_response()
|
attach_usage_server_now_header(Json(json!({ "requests": payload })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -2305,13 +2321,15 @@ pub fn build_admin_usage_records_response(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
attach_usage_server_now_header(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"records": records,
|
"records": records,
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_admin_usage_curl_response(
|
pub fn build_admin_usage_curl_response(
|
||||||
@@ -2503,6 +2521,7 @@ pub fn build_admin_usage_replay_plan_response(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use axum::{body::Body, response::Response};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -2510,7 +2529,10 @@ mod tests {
|
|||||||
admin_usage_has_fallback, admin_usage_is_failed, admin_usage_is_success,
|
admin_usage_has_fallback, admin_usage_is_failed, admin_usage_is_success,
|
||||||
admin_usage_matches_search, admin_usage_matches_status, admin_usage_matches_username,
|
admin_usage_matches_search, admin_usage_matches_status, admin_usage_matches_username,
|
||||||
admin_usage_record_json, admin_usage_resolve_request_capture_body,
|
admin_usage_record_json, admin_usage_resolve_request_capture_body,
|
||||||
admin_usage_total_tokens, admin_usage_upstream_is_stream, build_admin_usage_detail_payload,
|
admin_usage_total_tokens, admin_usage_upstream_is_stream,
|
||||||
|
build_admin_usage_active_requests_response, build_admin_usage_detail_payload,
|
||||||
|
build_admin_usage_records_response, usage_server_now_unix_ms,
|
||||||
|
USAGE_SERVER_NOW_UNIX_MS_HEADER,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField};
|
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField};
|
||||||
|
|
||||||
@@ -2560,6 +2582,62 @@ mod tests {
|
|||||||
.expect("usage should build")
|
.expect("usage should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_server_now_unix_ms_uses_epoch_millis() {
|
||||||
|
let before = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or_default();
|
||||||
|
let value = usage_server_now_unix_ms();
|
||||||
|
let after = u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(value >= before);
|
||||||
|
assert!(value <= after);
|
||||||
|
assert!(value > 1_000_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_usage_server_now_header(response: &Response<Body>) {
|
||||||
|
let value = response
|
||||||
|
.headers()
|
||||||
|
.get(USAGE_SERVER_NOW_UNIX_MS_HEADER)
|
||||||
|
.expect("usage response should include server now header")
|
||||||
|
.to_str()
|
||||||
|
.expect("server now header should be valid ASCII")
|
||||||
|
.parse::<u64>()
|
||||||
|
.expect("server now header should be epoch millis");
|
||||||
|
|
||||||
|
assert!(value > 1_000_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_usage_records_response_sets_server_now_header() {
|
||||||
|
let item = sample_usage("completed", Some(200), None);
|
||||||
|
let response = build_admin_usage_records_response(
|
||||||
|
&[item],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_usage_server_now_header(&response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_usage_active_response_sets_server_now_header() {
|
||||||
|
let item = sample_usage("streaming", Some(200), None);
|
||||||
|
let response = build_admin_usage_active_requests_response(
|
||||||
|
&[item],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
true,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_usage_server_now_header(&response);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_completed_status_wins_over_legacy_failure_fields() {
|
fn explicit_completed_status_wins_over_legacy_failure_fields() {
|
||||||
let item = sample_usage(
|
let item = sample_usage(
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ pub use plan::{
|
|||||||
TRANSPORT_HTTP_MODE_HTTP1_ONLY, TRANSPORT_POOL_SCOPE_KEY,
|
TRANSPORT_HTTP_MODE_HTTP1_ONLY, TRANSPORT_POOL_SCOPE_KEY,
|
||||||
};
|
};
|
||||||
pub use result::{ExecutionResult, ExecutionTelemetry, ResponseBody};
|
pub use result::{ExecutionResult, ExecutionTelemetry, ResponseBody};
|
||||||
pub use usage::{ExecutionStreamTerminalSummary, StandardizedUsage};
|
pub use usage::{
|
||||||
|
ExecutionStreamTerminalSummary, StandardizedUsage, USAGE_SERVER_NOW_UNIX_MS_HEADER,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const USAGE_SERVER_NOW_UNIX_MS_HEADER: &str = "x-aether-server-now-unix-ms";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
pub struct StandardizedUsage {
|
pub struct StandardizedUsage {
|
||||||
pub input_tokens: i64,
|
pub input_tokens: i64,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
SERVER_NOW_UNIX_MS_HEADER,
|
||||||
|
buildServerTimingMetadata,
|
||||||
|
readServerNowUnixMsFromHeaders,
|
||||||
|
withServerTiming,
|
||||||
|
} from '../serverTiming'
|
||||||
|
|
||||||
|
describe('serverTiming', () => {
|
||||||
|
it('reads server time from response headers', () => {
|
||||||
|
expect(readServerNowUnixMsFromHeaders({
|
||||||
|
[SERVER_NOW_UNIX_MS_HEADER]: '1779999000123',
|
||||||
|
})).toBe(1_779_999_000_123)
|
||||||
|
expect(readServerNowUnixMsFromHeaders({
|
||||||
|
'X-Aether-Server-Now-Unix-Ms': '1779999000456',
|
||||||
|
})).toBe(1_779_999_000_456)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fall back to body fields', () => {
|
||||||
|
const timing = buildServerTimingMetadata({
|
||||||
|
headers: {},
|
||||||
|
data: {
|
||||||
|
server_now_unix_ms: 1_779_999_000_123,
|
||||||
|
},
|
||||||
|
}, 1_000, 1_100)
|
||||||
|
|
||||||
|
expect(timing).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds metadata with round trip duration', () => {
|
||||||
|
const timing = buildServerTimingMetadata({
|
||||||
|
headers: {
|
||||||
|
[SERVER_NOW_UNIX_MS_HEADER]: '1050',
|
||||||
|
},
|
||||||
|
}, 1_000, 1_125)
|
||||||
|
|
||||||
|
expect(timing).toEqual({
|
||||||
|
server_now_unix_ms: 1_050,
|
||||||
|
client_send_unix_ms: 1_000,
|
||||||
|
client_receive_unix_ms: 1_125,
|
||||||
|
round_trip_ms: 125,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the original payload when the header is missing or invalid', () => {
|
||||||
|
const payload = { records: [] }
|
||||||
|
|
||||||
|
expect(withServerTiming({ data: payload, headers: {} }, 1_000)).toBe(payload)
|
||||||
|
expect(withServerTiming({
|
||||||
|
data: payload,
|
||||||
|
headers: { [SERVER_NOW_UNIX_MS_HEADER]: 'not-a-number' },
|
||||||
|
}, 1_000)).toBe(payload)
|
||||||
|
expect(withServerTiming({
|
||||||
|
data: payload,
|
||||||
|
headers: { [SERVER_NOW_UNIX_MS_HEADER]: '0' },
|
||||||
|
}, 1_000)).toBe(payload)
|
||||||
|
expect(withServerTiming({
|
||||||
|
data: payload,
|
||||||
|
headers: { [SERVER_NOW_UNIX_MS_HEADER]: '1050.5' },
|
||||||
|
}, 1_000)).toBe(payload)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
||||||
getMock: vi.fn(),
|
getMock: vi.fn(),
|
||||||
@@ -29,6 +29,10 @@ describe('usageApi contract alignment', () => {
|
|||||||
buildCacheKeyMock.mockClear()
|
buildCacheKeyMock.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
|
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
|
||||||
getMock.mockResolvedValueOnce({
|
getMock.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
@@ -115,6 +119,56 @@ describe('usageApi contract alignment', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('captures admin user usage server timing when the records request resolves', async () => {
|
||||||
|
let now = 1_000
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||||
|
|
||||||
|
let resolveStats: ((value: unknown) => void) | null = null
|
||||||
|
getMock.mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/admin/usage/stats') {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
resolveStats = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/usage/records') {
|
||||||
|
return Promise.resolve({
|
||||||
|
headers: {
|
||||||
|
'x-aether-server-now-unix-ms': '10050',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
records: [{ id: 'record-3' }],
|
||||||
|
total: 1,
|
||||||
|
limit: 25,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`unexpected url: ${url}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
const resultPromise = usageApi.getUserUsage('user-123', { page: 1, page_size: 25 })
|
||||||
|
now = 1_100
|
||||||
|
await Promise.resolve()
|
||||||
|
now = 20_000
|
||||||
|
resolveStats?.({
|
||||||
|
data: {
|
||||||
|
total_requests: 1,
|
||||||
|
total_tokens: 10,
|
||||||
|
total_cost: 0.1,
|
||||||
|
avg_response_time: 500,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await resultPromise
|
||||||
|
|
||||||
|
expect(result.server_timing).toEqual({
|
||||||
|
server_now_unix_ms: 10_050,
|
||||||
|
client_send_unix_ms: 1_000,
|
||||||
|
client_receive_unix_ms: 1_100,
|
||||||
|
round_trip_ms: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('uses an extended timeout and cache bypass option for admin analytics', async () => {
|
it('uses an extended timeout and cache bypass option for admin analytics', async () => {
|
||||||
getMock
|
getMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
+11
-3
@@ -5,6 +5,11 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
import type { UserSession } from '@/types/session'
|
import type { UserSession } from '@/types/session'
|
||||||
import type { FeatureSettingsMap } from '@/utils/featureSettings'
|
import type { FeatureSettingsMap } from '@/utils/featureSettings'
|
||||||
|
import {
|
||||||
|
beginServerTimingSample,
|
||||||
|
withServerTiming,
|
||||||
|
type ServerTimedPayload,
|
||||||
|
} from './serverTiming'
|
||||||
|
|
||||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
@@ -142,7 +147,7 @@ export interface ApiFormatSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用统计响应接口
|
// 使用统计响应接口
|
||||||
export interface UsageResponse {
|
export interface UsageResponse extends ServerTimedPayload {
|
||||||
total_requests: number
|
total_requests: number
|
||||||
total_input_tokens: number
|
total_input_tokens: number
|
||||||
total_output_tokens: number
|
total_output_tokens: number
|
||||||
@@ -321,12 +326,14 @@ export const meApi = {
|
|||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}): Promise<UsageResponse> {
|
}): Promise<UsageResponse> {
|
||||||
|
const clientSendUnixMs = beginServerTimingSample()
|
||||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||||
return response.data
|
return withServerTiming(response, clientSendUnixMs)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取活跃请求状态(用于轮询更新)
|
// 获取活跃请求状态(用于轮询更新)
|
||||||
async getActiveRequests(ids?: string): Promise<{
|
async getActiveRequests(ids?: string): Promise<{
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
requests: Array<{
|
requests: Array<{
|
||||||
id: string
|
id: string
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
@@ -358,8 +365,9 @@ export const meApi = {
|
|||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
const params = ids ? { ids } : {}
|
const params = ids ? { ids } : {}
|
||||||
|
const clientSendUnixMs = beginServerTimingSample()
|
||||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
||||||
return response.data
|
return withServerTiming(response, clientSendUnixMs)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取可用的提供商
|
// 获取可用的提供商
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
export const SERVER_NOW_UNIX_MS_HEADER = 'x-aether-server-now-unix-ms'
|
||||||
|
|
||||||
|
export interface ServerTimingMetadata {
|
||||||
|
server_now_unix_ms: number
|
||||||
|
client_send_unix_ms: number
|
||||||
|
client_receive_unix_ms: number
|
||||||
|
round_trip_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerTimedPayload {
|
||||||
|
server_timing?: ServerTimingMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginServerTimingSample(): number {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderValue(headers: unknown, name: string): unknown {
|
||||||
|
if (!headers || typeof headers !== 'object') return undefined
|
||||||
|
|
||||||
|
const get = (headers as { get?: unknown }).get
|
||||||
|
if (typeof get === 'function') {
|
||||||
|
return get.call(headers, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerName = name.toLowerCase()
|
||||||
|
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||||
|
if (key.toLowerCase() === lowerName) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readServerNowUnixMsFromHeaders(headers: unknown): number | null {
|
||||||
|
const value = readHeaderValue(headers, SERVER_NOW_UNIX_MS_HEADER)
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value
|
||||||
|
const parsed = typeof raw === 'number'
|
||||||
|
? raw
|
||||||
|
: typeof raw === 'string'
|
||||||
|
? Number(raw.trim())
|
||||||
|
: Number.NaN
|
||||||
|
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildServerTimingMetadata(
|
||||||
|
response: Pick<AxiosResponse, 'headers'> | { headers?: unknown } | null | undefined,
|
||||||
|
clientSendUnixMs: number,
|
||||||
|
clientReceiveUnixMs = Date.now()
|
||||||
|
): ServerTimingMetadata | undefined {
|
||||||
|
const serverNowUnixMs = readServerNowUnixMsFromHeaders(response?.headers)
|
||||||
|
if (serverNowUnixMs == null) return undefined
|
||||||
|
if (!Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) return undefined
|
||||||
|
if (clientReceiveUnixMs < clientSendUnixMs) return undefined
|
||||||
|
|
||||||
|
const roundTripMs = clientReceiveUnixMs - clientSendUnixMs
|
||||||
|
|
||||||
|
return {
|
||||||
|
server_now_unix_ms: serverNowUnixMs,
|
||||||
|
client_send_unix_ms: clientSendUnixMs,
|
||||||
|
client_receive_unix_ms: clientReceiveUnixMs,
|
||||||
|
round_trip_ms: roundTripMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withServerTiming<T extends object>(
|
||||||
|
response: Pick<AxiosResponse<T>, 'data' | 'headers'>,
|
||||||
|
clientSendUnixMs: number
|
||||||
|
): T & ServerTimedPayload {
|
||||||
|
const serverTiming = buildServerTimingMetadata(response, clientSendUnixMs)
|
||||||
|
if (!serverTiming) return response.data
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
server_timing: serverTiming,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import apiClient from './client'
|
|||||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
import type { ActivityHeatmap } from '@/types/activity'
|
||||||
import type { ImageProgress } from './requestTrace'
|
import type { ImageProgress } from './requestTrace'
|
||||||
|
import {
|
||||||
|
beginServerTimingSample,
|
||||||
|
withServerTiming,
|
||||||
|
type ServerTimedPayload,
|
||||||
|
} from './serverTiming'
|
||||||
|
|
||||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
|
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
|
||||||
@@ -127,7 +132,7 @@ export interface UsageRequestOptions {
|
|||||||
skipCache?: boolean
|
skipCache?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsageListResponse = {
|
type UsageListResponse = ServerTimedPayload & {
|
||||||
records?: unknown
|
records?: unknown
|
||||||
pagination?: {
|
pagination?: {
|
||||||
total?: unknown
|
total?: unknown
|
||||||
@@ -199,6 +204,7 @@ function normalizeUsageRecordPage(
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
} {
|
} {
|
||||||
const records = assertUsageRecords(payload.records)
|
const records = assertUsageRecords(payload.records)
|
||||||
const pagination = payload.pagination
|
const pagination = payload.pagination
|
||||||
@@ -220,6 +226,7 @@ function normalizeUsageRecordPage(
|
|||||||
total,
|
total,
|
||||||
page: resolvedPage,
|
page: resolvedPage,
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
|
...(payload.server_timing ? { server_timing: payload.server_timing } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,10 +373,12 @@ export const usageApi = {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
}> {
|
}> {
|
||||||
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
||||||
|
const clientSendUnixMs = beginServerTimingSample()
|
||||||
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
||||||
return normalizeUsageRecordPage(response.data, pagination)
|
return normalizeUsageRecordPage(withServerTiming(response, clientSendUnixMs), pagination)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
|
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
|
||||||
@@ -444,17 +453,25 @@ export const usageApi = {
|
|||||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
||||||
records: UsageRecord[]
|
records: UsageRecord[]
|
||||||
stats: UsageStats
|
stats: UsageStats
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
}> {
|
}> {
|
||||||
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
||||||
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
||||||
|
const statsRequest = apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams })
|
||||||
|
const recordsClientSendUnixMs = beginServerTimingSample()
|
||||||
|
const recordsRequest = apiClient
|
||||||
|
.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams })
|
||||||
|
.then(response => withServerTiming(response, recordsClientSendUnixMs))
|
||||||
|
|
||||||
const [statsResponse, recordsResponse] = await Promise.all([
|
const [statsResponse, recordsResponse] = await Promise.all([
|
||||||
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
statsRequest,
|
||||||
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
recordsRequest,
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records: assertUsageRecords(recordsResponse.data.records),
|
records: assertUsageRecords(recordsResponse.records),
|
||||||
stats: statsResponse.data,
|
stats: statsResponse.data,
|
||||||
|
...(recordsResponse.server_timing ? { server_timing: recordsResponse.server_timing } : {}),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -479,11 +496,13 @@ export const usageApi = {
|
|||||||
total: number
|
total: number
|
||||||
limit: number
|
limit: number
|
||||||
offset: number
|
offset: number
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
}> {
|
}> {
|
||||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||||
return dedupedRequest(key, async () => {
|
return dedupedRequest(key, async () => {
|
||||||
|
const clientSendUnixMs = beginServerTimingSample()
|
||||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||||
return response.data
|
return withServerTiming(response, clientSendUnixMs)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -495,6 +514,7 @@ export const usageApi = {
|
|||||||
ids?: string[],
|
ids?: string[],
|
||||||
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
|
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
|
||||||
): Promise<{
|
): Promise<{
|
||||||
|
server_timing?: ServerTimedPayload['server_timing']
|
||||||
requests: Array<{
|
requests: Array<{
|
||||||
id: string
|
id: string
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
@@ -548,8 +568,9 @@ export const usageApi = {
|
|||||||
if (typeof timeRange?.tz_offset_minutes === 'number') {
|
if (typeof timeRange?.tz_offset_minutes === 'number') {
|
||||||
params.tz_offset_minutes = timeRange.tz_offset_minutes
|
params.tz_offset_minutes = timeRange.tz_offset_minutes
|
||||||
}
|
}
|
||||||
|
const clientSendUnixMs = beginServerTimingSample()
|
||||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||||
return response.data
|
return withServerTiming(response, clientSendUnixMs)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,26 +3,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
responseTimeMs?: number | null
|
responseTimeMs?: number | null
|
||||||
|
displayNowMs?: number | null
|
||||||
precision?: number
|
precision?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
status: null,
|
status: null,
|
||||||
responseTimeMs: null,
|
responseTimeMs: null,
|
||||||
|
displayNowMs: null,
|
||||||
precision: 2,
|
precision: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = ref(Date.now())
|
|
||||||
const precision = computed(() => Math.max(0, props.precision))
|
const precision = computed(() => Math.max(0, props.precision))
|
||||||
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
|
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
|
||||||
|
|
||||||
let rafId: number | null = null
|
|
||||||
|
|
||||||
function parseCreatedAtMs(value: string | null | undefined): number {
|
function parseCreatedAtMs(value: string | null | undefined): number {
|
||||||
if (!value) return Number.NaN
|
if (!value) return Number.NaN
|
||||||
// 后端有时返回无时区时间,按 UTC 解析,和列表时间显示逻辑保持一致
|
// 后端有时返回无时区时间,按 UTC 解析,和列表时间显示逻辑保持一致
|
||||||
@@ -30,35 +29,6 @@ function parseCreatedAtMs(value: string | null | undefined): number {
|
|||||||
return new Date(normalized).getTime()
|
return new Date(normalized).getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRaf() {
|
|
||||||
if (rafId == null) return
|
|
||||||
cancelAnimationFrame(rafId)
|
|
||||||
rafId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
now.value = Date.now()
|
|
||||||
rafId = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRaf() {
|
|
||||||
stopRaf()
|
|
||||||
now.value = Date.now()
|
|
||||||
rafId = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isActive, (active) => {
|
|
||||||
if (active) {
|
|
||||||
startRaf()
|
|
||||||
} else {
|
|
||||||
stopRaf()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopRaf()
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayText = computed(() => {
|
const displayText = computed(() => {
|
||||||
if (!isActive.value) {
|
if (!isActive.value) {
|
||||||
if (props.responseTimeMs == null) return '-'
|
if (props.responseTimeMs == null) return '-'
|
||||||
@@ -70,7 +40,11 @@ const displayText = computed(() => {
|
|||||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||||
if (Number.isNaN(createdAtMs)) return '-'
|
if (Number.isNaN(createdAtMs)) return '-'
|
||||||
|
|
||||||
const elapsedMs = Math.max(0, now.value - createdAtMs)
|
// 活跃请求里的 response_time_ms 可能只是首字或中间值;终态才使用后端最终耗时。
|
||||||
|
const nowMs = typeof props.displayNowMs === 'number' && Number.isFinite(props.displayNowMs)
|
||||||
|
? props.displayNowMs
|
||||||
|
: Date.now()
|
||||||
|
const elapsedMs = Math.max(0, nowMs - createdAtMs)
|
||||||
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -324,6 +324,7 @@
|
|||||||
:created-at="record.created_at"
|
:created-at="record.created_at"
|
||||||
:status="getDisplayStatus(record)"
|
:status="getDisplayStatus(record)"
|
||||||
:response-time-ms="record.response_time_ms ?? null"
|
:response-time-ms="record.response_time_ms ?? null"
|
||||||
|
:display-now-ms="displayNowMs ?? null"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -940,6 +941,7 @@
|
|||||||
:created-at="record.created_at"
|
:created-at="record.created_at"
|
||||||
:status="getDisplayStatus(record)"
|
:status="getDisplayStatus(record)"
|
||||||
:response-time-ms="record.response_time_ms ?? null"
|
:response-time-ms="record.response_time_ms ?? null"
|
||||||
|
:display-now-ms="displayNowMs ?? null"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1114,6 +1116,7 @@ const props = defineProps<{
|
|||||||
pageSizeOptions: number[]
|
pageSizeOptions: number[]
|
||||||
// 自动刷新
|
// 自动刷新
|
||||||
autoRefresh: boolean
|
autoRefresh: boolean
|
||||||
|
displayNowMs?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createApp, type App } from 'vue'
|
||||||
|
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
|
||||||
|
const app = createApp(ElapsedTimeText, props)
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ElapsedTimeText', () => {
|
||||||
|
it('uses the supplied display clock for active requests', () => {
|
||||||
|
const root = mountElapsedTimeText({
|
||||||
|
createdAt: '2026-05-28T12:00:00Z',
|
||||||
|
status: 'streaming',
|
||||||
|
responseTimeMs: 10_000,
|
||||||
|
displayNowMs: Date.parse('2026-05-28T12:00:40Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(root.textContent).toBe('40.00s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps terminal requests pinned to the backend final duration', () => {
|
||||||
|
const root = mountElapsedTimeText({
|
||||||
|
createdAt: '2026-05-28T12:00:00Z',
|
||||||
|
status: 'completed',
|
||||||
|
responseTimeMs: 42_340,
|
||||||
|
displayNowMs: Date.parse('2026-05-28T12:01:30Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(root.textContent).toBe('42.34s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps active elapsed time at zero when the display clock is behind', () => {
|
||||||
|
const root = mountElapsedTimeText({
|
||||||
|
createdAt: '2026-05-28T12:00:40Z',
|
||||||
|
status: 'pending',
|
||||||
|
displayNowMs: Date.parse('2026-05-28T12:00:00Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(root.textContent).toBe('0.00s')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,8 +91,16 @@ vi.mock('lucide-vue-next', async () => {
|
|||||||
vi.mock('../ElapsedTimeText.vue', () => ({
|
vi.mock('../ElapsedTimeText.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'ElapsedTimeTextStub',
|
name: 'ElapsedTimeTextStub',
|
||||||
setup() {
|
props: {
|
||||||
return () => h('span', 'elapsed')
|
displayNowMs: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('span', {
|
||||||
|
'data-display-now-ms': props.displayNowMs == null ? '' : String(props.displayNowMs),
|
||||||
|
}, 'elapsed')
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
@@ -243,6 +251,16 @@ describe('UsageRecordsTable', () => {
|
|||||||
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
|
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('passes the shared display clock to active elapsed text', () => {
|
||||||
|
const root = mountUsageRecordsTable([buildRecord({
|
||||||
|
status: 'streaming',
|
||||||
|
response_time_ms: null,
|
||||||
|
first_byte_time_ms: 500,
|
||||||
|
})], { displayNowMs: 1_779_999_000_000 })
|
||||||
|
|
||||||
|
expect(root.querySelector('[data-display-now-ms="1779999000000"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('shows failed when Codex image progress fails before the usage record finalizes', () => {
|
it('shows failed when Codex image progress fails before the usage record finalizes', () => {
|
||||||
const root = mountUsageRecordsTable([buildRecord({
|
const root = mountUsageRecordsTable([buildRecord({
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
import { useActiveElapsedDisplayClock } from '../useActiveElapsedDisplayClock'
|
||||||
|
|
||||||
|
type TestRecord = {
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useActiveElapsedDisplayClock', () => {
|
||||||
|
it('ticks only while visible active records exist', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let nowMs = 1_000
|
||||||
|
|
||||||
|
const records = ref<TestRecord[]>([])
|
||||||
|
const isPageVisible = ref(true)
|
||||||
|
const serverClockOffsetMs = ref(0)
|
||||||
|
const hasServerClockOffset = ref(false)
|
||||||
|
const clock = useActiveElapsedDisplayClock({
|
||||||
|
records,
|
||||||
|
isPageVisible,
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
resolveStatus: record => record.status,
|
||||||
|
intervalMs: 250,
|
||||||
|
now: () => nowMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clock.hasVisibleActiveRecords.value).toBe(false)
|
||||||
|
expect(clock.displayNowMs.value).toBe(1_000)
|
||||||
|
|
||||||
|
nowMs = 1_250
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(clock.displayNowMs.value).toBe(1_000)
|
||||||
|
|
||||||
|
records.value = [{ status: 'streaming' }]
|
||||||
|
await nextTick()
|
||||||
|
expect(clock.hasVisibleActiveRecords.value).toBe(true)
|
||||||
|
expect(clock.displayNowMs.value).toBe(1_250)
|
||||||
|
|
||||||
|
nowMs = 1_500
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(clock.displayNowMs.value).toBe(1_500)
|
||||||
|
|
||||||
|
isPageVisible.value = false
|
||||||
|
await nextTick()
|
||||||
|
nowMs = 1_750
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(clock.displayNowMs.value).toBe(1_500)
|
||||||
|
|
||||||
|
clock.stopActiveElapsedDisplayClock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies server clock offset to the display time', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
|
||||||
|
const clock = useActiveElapsedDisplayClock({
|
||||||
|
records: ref<TestRecord[]>([{ status: 'pending' }]),
|
||||||
|
isPageVisible: ref(true),
|
||||||
|
serverClockOffsetMs: ref(-11_000),
|
||||||
|
hasServerClockOffset: ref(true),
|
||||||
|
resolveStatus: record => record.status,
|
||||||
|
intervalMs: 250,
|
||||||
|
now: () => 2_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clock.calibratedDisplayNowMs.value).toBe(-9_000)
|
||||||
|
clock.stopActiveElapsedDisplayClock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops when active records disappear', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let nowMs = 3_000
|
||||||
|
|
||||||
|
const records = ref<TestRecord[]>([{ status: 'pending' }])
|
||||||
|
const clock = useActiveElapsedDisplayClock({
|
||||||
|
records,
|
||||||
|
isPageVisible: ref(true),
|
||||||
|
serverClockOffsetMs: ref(0),
|
||||||
|
hasServerClockOffset: ref(false),
|
||||||
|
resolveStatus: record => record.status,
|
||||||
|
intervalMs: 250,
|
||||||
|
now: () => nowMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
nowMs = 3_250
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(clock.displayNowMs.value).toBe(3_250)
|
||||||
|
|
||||||
|
records.value = [{ status: 'completed' }]
|
||||||
|
await nextTick()
|
||||||
|
nowMs = 3_500
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(clock.displayNowMs.value).toBe(3_250)
|
||||||
|
|
||||||
|
clock.stopActiveElapsedDisplayClock()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
calculateServerClockOffsetMs,
|
||||||
|
shouldUseServerClockSample,
|
||||||
|
useServerClock
|
||||||
|
} from '../useServerClock'
|
||||||
|
|
||||||
|
describe('useServerClock', () => {
|
||||||
|
it('calculates offset from the response receive time', () => {
|
||||||
|
const offset = calculateServerClockOffsetMs({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_200,
|
||||||
|
round_trip_ms: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(offset).toBe(-9_700)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores missing or invalid timing samples', () => {
|
||||||
|
expect(calculateServerClockOffsetMs(undefined)).toBeNull()
|
||||||
|
expect(calculateServerClockOffsetMs({
|
||||||
|
server_now_unix_ms: Number.NaN,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_200,
|
||||||
|
round_trip_ms: 200,
|
||||||
|
})).toBeNull()
|
||||||
|
expect(calculateServerClockOffsetMs({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_200,
|
||||||
|
client_receive_unix_ms: 20_000,
|
||||||
|
round_trip_ms: 200,
|
||||||
|
})).toBeNull()
|
||||||
|
expect(calculateServerClockOffsetMs({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_200,
|
||||||
|
round_trip_ms: Number.NaN,
|
||||||
|
})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the previous offset when a response has no server timing', () => {
|
||||||
|
const clock = useServerClock()
|
||||||
|
|
||||||
|
clock.updateServerClockOffset({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_200,
|
||||||
|
round_trip_ms: 200,
|
||||||
|
})
|
||||||
|
clock.updateServerClockOffset(undefined)
|
||||||
|
|
||||||
|
expect(clock.hasServerClockOffset.value).toBe(true)
|
||||||
|
expect(clock.serverClockOffsetMs.value).toBe(-9_700)
|
||||||
|
expect(clock.serverClockSampleRoundTripMs.value).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a much slower sample overwrite a better clock offset', () => {
|
||||||
|
const clock = useServerClock()
|
||||||
|
|
||||||
|
clock.updateServerClockOffset({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_050,
|
||||||
|
round_trip_ms: 50,
|
||||||
|
})
|
||||||
|
clock.updateServerClockOffset({
|
||||||
|
server_now_unix_ms: 20_500,
|
||||||
|
client_send_unix_ms: 30_000,
|
||||||
|
client_receive_unix_ms: 30_500,
|
||||||
|
round_trip_ms: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clock.serverClockOffsetMs.value).toBe(-9_550)
|
||||||
|
expect(clock.serverClockSampleRoundTripMs.value).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a faster sample after an initial slow sample', () => {
|
||||||
|
const clock = useServerClock()
|
||||||
|
|
||||||
|
clock.updateServerClockOffset({
|
||||||
|
server_now_unix_ms: 10_500,
|
||||||
|
client_send_unix_ms: 20_000,
|
||||||
|
client_receive_unix_ms: 20_500,
|
||||||
|
round_trip_ms: 500,
|
||||||
|
})
|
||||||
|
clock.updateServerClockOffset({
|
||||||
|
server_now_unix_ms: 20_500,
|
||||||
|
client_send_unix_ms: 30_000,
|
||||||
|
client_receive_unix_ms: 30_050,
|
||||||
|
round_trip_ms: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clock.serverClockOffsetMs.value).toBe(-9_550)
|
||||||
|
expect(clock.serverClockSampleRoundTripMs.value).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows small RTT regressions so the offset can stay fresh', () => {
|
||||||
|
expect(shouldUseServerClockSample(140, 50)).toBe(true)
|
||||||
|
expect(shouldUseServerClockSample(151, 50)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { useUsageData } from './useUsageData'
|
export { useUsageData } from './useUsageData'
|
||||||
|
export { useActiveElapsedDisplayClock } from './useActiveElapsedDisplayClock'
|
||||||
export { useUsageFilters } from './useUsageFilters'
|
export { useUsageFilters } from './useUsageFilters'
|
||||||
export { useUsagePagination } from './useUsagePagination'
|
export { useUsagePagination } from './useUsagePagination'
|
||||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||||
|
|
||||||
|
type ActiveElapsedStatus = string | null | undefined
|
||||||
|
|
||||||
|
export interface UseActiveElapsedDisplayClockOptions<TRecord> {
|
||||||
|
records: Ref<TRecord[]> | ComputedRef<TRecord[]>
|
||||||
|
isPageVisible: Ref<boolean> | ComputedRef<boolean>
|
||||||
|
serverClockOffsetMs: Ref<number> | ComputedRef<number>
|
||||||
|
hasServerClockOffset: Ref<boolean> | ComputedRef<boolean>
|
||||||
|
resolveStatus: (record: TRecord) => ActiveElapsedStatus
|
||||||
|
intervalMs?: number
|
||||||
|
now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveElapsedDisplayClock<TRecord>(
|
||||||
|
options: UseActiveElapsedDisplayClockOptions<TRecord>
|
||||||
|
) {
|
||||||
|
const intervalMs = options.intervalMs ?? 250
|
||||||
|
const now = options.now ?? Date.now
|
||||||
|
const displayNowMs = ref(now())
|
||||||
|
|
||||||
|
let activeElapsedDisplayTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const hasVisibleActiveRecords = computed(() => {
|
||||||
|
return options.records.value.some((record) => {
|
||||||
|
const displayStatus = options.resolveStatus(record)
|
||||||
|
return displayStatus === 'pending' || displayStatus === 'streaming'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const calibratedDisplayNowMs = computed(() => {
|
||||||
|
return options.hasServerClockOffset.value
|
||||||
|
? displayNowMs.value + options.serverClockOffsetMs.value
|
||||||
|
: displayNowMs.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function tickActiveElapsedDisplay() {
|
||||||
|
displayNowMs.value = now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startActiveElapsedDisplayTimer() {
|
||||||
|
if (activeElapsedDisplayTimer) return
|
||||||
|
if (!options.isPageVisible.value || !hasVisibleActiveRecords.value) return
|
||||||
|
tickActiveElapsedDisplay()
|
||||||
|
activeElapsedDisplayTimer = setInterval(tickActiveElapsedDisplay, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActiveElapsedDisplayTimer() {
|
||||||
|
if (activeElapsedDisplayTimer) {
|
||||||
|
clearInterval(activeElapsedDisplayTimer)
|
||||||
|
activeElapsedDisplayTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveElapsedDisplayTimer() {
|
||||||
|
if (options.isPageVisible.value && hasVisibleActiveRecords.value) {
|
||||||
|
startActiveElapsedDisplayTimer()
|
||||||
|
} else {
|
||||||
|
stopActiveElapsedDisplayTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopActiveElapsedDisplayWatch = watch(
|
||||||
|
[hasVisibleActiveRecords, options.isPageVisible],
|
||||||
|
syncActiveElapsedDisplayTimer,
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function stopActiveElapsedDisplayClock() {
|
||||||
|
stopActiveElapsedDisplayWatch()
|
||||||
|
stopActiveElapsedDisplayTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayNowMs,
|
||||||
|
calibratedDisplayNowMs,
|
||||||
|
hasVisibleActiveRecords,
|
||||||
|
tickActiveElapsedDisplay,
|
||||||
|
startActiveElapsedDisplayTimer,
|
||||||
|
stopActiveElapsedDisplayTimer,
|
||||||
|
syncActiveElapsedDisplayTimer,
|
||||||
|
stopActiveElapsedDisplayClock,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ServerTimingMetadata } from '@/api/serverTiming'
|
||||||
|
|
||||||
|
const SERVER_CLOCK_RTT_REGRESSION_TOLERANCE_MS = 100
|
||||||
|
|
||||||
|
export function calculateServerClockOffsetMs(timing: ServerTimingMetadata | null | undefined): number | null {
|
||||||
|
if (!timing) return null
|
||||||
|
const {
|
||||||
|
server_now_unix_ms: serverNowUnixMs,
|
||||||
|
client_send_unix_ms: clientSendUnixMs,
|
||||||
|
client_receive_unix_ms: clientReceiveUnixMs,
|
||||||
|
round_trip_ms: roundTripMs,
|
||||||
|
} = timing
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(serverNowUnixMs) ||
|
||||||
|
!Number.isFinite(clientSendUnixMs) ||
|
||||||
|
!Number.isFinite(clientReceiveUnixMs) ||
|
||||||
|
!Number.isFinite(roundTripMs)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (clientReceiveUnixMs < clientSendUnixMs || roundTripMs < 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverNowUnixMs - clientReceiveUnixMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseServerClockSample(
|
||||||
|
nextRoundTripMs: number,
|
||||||
|
currentRoundTripMs: number | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!Number.isFinite(nextRoundTripMs) || nextRoundTripMs < 0) return false
|
||||||
|
if (currentRoundTripMs == null || !Number.isFinite(currentRoundTripMs)) return true
|
||||||
|
return nextRoundTripMs <= currentRoundTripMs + SERVER_CLOCK_RTT_REGRESSION_TOLERANCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerClock() {
|
||||||
|
const serverClockOffsetMs = ref(0)
|
||||||
|
const hasServerClockOffset = ref(false)
|
||||||
|
const serverClockSampleRoundTripMs = ref<number | null>(null)
|
||||||
|
|
||||||
|
function updateServerClockOffset(timing: ServerTimingMetadata | null | undefined): void {
|
||||||
|
const offsetMs = calculateServerClockOffsetMs(timing)
|
||||||
|
if (offsetMs == null) return
|
||||||
|
if (!shouldUseServerClockSample(timing?.round_trip_ms ?? Number.NaN, serverClockSampleRoundTripMs.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClockOffsetMs.value = offsetMs
|
||||||
|
serverClockSampleRoundTripMs.value = timing?.round_trip_ms ?? null
|
||||||
|
hasServerClockOffset.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
serverClockSampleRoundTripMs,
|
||||||
|
updateServerClockOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { createDefaultStats } from '../types'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import { getErrorStatus } from '@/types/api-error'
|
import { getErrorStatus } from '@/types/api-error'
|
||||||
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
|
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
|
||||||
|
import { useServerClock } from './useServerClock'
|
||||||
|
|
||||||
export interface UseUsageDataOptions {
|
export interface UseUsageDataOptions {
|
||||||
isAdminPage: Ref<boolean>
|
isAdminPage: Ref<boolean>
|
||||||
@@ -65,6 +66,11 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||||
const availableModels = ref<string[]>([])
|
const availableModels = ref<string[]>([])
|
||||||
const availableProviders = ref<string[]>([])
|
const availableProviders = ref<string[]>([])
|
||||||
|
const {
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
updateServerClockOffset,
|
||||||
|
} = useServerClock()
|
||||||
|
|
||||||
// 增强的模型统计(包含效率分析)
|
// 增强的模型统计(包含效率分析)
|
||||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||||
@@ -213,6 +219,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (requestId !== loadStatsRequestId) {
|
if (requestId !== loadStatsRequestId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
updateServerClockOffset(userData.server_timing)
|
||||||
|
|
||||||
stats.value = {
|
stats.value = {
|
||||||
total_requests: userData.total_requests || 0,
|
total_requests: userData.total_requests || 0,
|
||||||
@@ -366,6 +373,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (requestId !== loadRecordsRequestId) {
|
if (requestId !== loadRecordsRequestId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
updateServerClockOffset(response.server_timing)
|
||||||
const nextRecords = (response.records || []) as UsageRecord[]
|
const nextRecords = (response.records || []) as UsageRecord[]
|
||||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||||
totalRecords.value = response.total || 0
|
totalRecords.value = response.total || 0
|
||||||
@@ -375,6 +383,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (requestId !== loadRecordsRequestId) {
|
if (requestId !== loadRecordsRequestId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
updateServerClockOffset(userData.server_timing)
|
||||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||||
@@ -557,6 +566,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
apiFormatStats,
|
apiFormatStats,
|
||||||
currentRecords,
|
currentRecords,
|
||||||
totalRecords,
|
totalRecords,
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
|
||||||
// 筛选选项
|
// 筛选选项
|
||||||
availableModels,
|
availableModels,
|
||||||
@@ -568,6 +579,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
// 方法
|
// 方法
|
||||||
loadStats,
|
loadStats,
|
||||||
loadRecords,
|
loadRecords,
|
||||||
refreshData
|
refreshData,
|
||||||
|
updateServerClockOffset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
:total-records="effectiveTotalRecords"
|
:total-records="effectiveTotalRecords"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
|
:display-now-ms="calibratedDisplayNowMs"
|
||||||
@update:time-range="handleTimeRangeChange"
|
@update:time-range="handleTimeRangeChange"
|
||||||
@update:filter-search="handleFilterSearchChange"
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@@ -148,6 +149,7 @@ import {
|
|||||||
IntervalTimelineCard
|
IntervalTimelineCard
|
||||||
} from '@/features/usage/components'
|
} from '@/features/usage/components'
|
||||||
import {
|
import {
|
||||||
|
useActiveElapsedDisplayClock,
|
||||||
useUsageData,
|
useUsageData,
|
||||||
getDateRangeFromPeriod
|
getDateRangeFromPeriod
|
||||||
} from '@/features/usage/composables'
|
} from '@/features/usage/composables'
|
||||||
@@ -246,7 +248,10 @@ const {
|
|||||||
availableModels,
|
availableModels,
|
||||||
availableProviders,
|
availableProviders,
|
||||||
loadStats,
|
loadStats,
|
||||||
loadRecords
|
loadRecords,
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
updateServerClockOffset
|
||||||
} = useUsageData({ isAdminPage })
|
} = useUsageData({ isAdminPage })
|
||||||
|
|
||||||
// 热力图状态
|
// 热力图状态
|
||||||
@@ -434,6 +439,7 @@ const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
|||||||
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
||||||
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
||||||
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
||||||
|
const ACTIVE_ELAPSED_DISPLAY_INTERVAL = 250 // 共享显示时钟,避免每行单独动画
|
||||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
||||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||||
|
|
||||||
@@ -445,10 +451,14 @@ const discoveredActiveRequestIds = new Set<string>()
|
|||||||
|
|
||||||
async function loadActiveRequestUpdates(ids?: string[]) {
|
async function loadActiveRequestUpdates(ids?: string[]) {
|
||||||
if (isAdminPage.value) {
|
if (isAdminPage.value) {
|
||||||
return usageApi.getActiveRequests(ids, timeRange.value)
|
const result = await usageApi.getActiveRequests(ids, timeRange.value)
|
||||||
|
updateServerClockOffset(result.server_timing)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
const idsParam = ids?.length ? ids.join(',') : undefined
|
const idsParam = ids?.length ? ids.join(',') : undefined
|
||||||
return meApi.getActiveRequests(idsParam)
|
const result = await meApi.getActiveRequests(idsParam)
|
||||||
|
updateServerClockOffset(result.server_timing)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollActiveRequests() {
|
async function pollActiveRequests() {
|
||||||
@@ -714,8 +724,10 @@ function handleVisibilityChange() {
|
|||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
stopActiveDiscovery()
|
stopActiveDiscovery()
|
||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
|
stopActiveElapsedDisplayTimer()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
syncActiveElapsedDisplayTimer()
|
||||||
if (hasActiveRequests.value) {
|
if (hasActiveRequests.value) {
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
}
|
}
|
||||||
@@ -732,6 +744,7 @@ onUnmounted(() => {
|
|||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
stopActiveDiscovery()
|
stopActiveDiscovery()
|
||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
|
stopActiveElapsedDisplayClock()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
|
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
|
||||||
@@ -755,6 +768,20 @@ const effectiveTotalRecords = computed(() => {
|
|||||||
// 显示的记录
|
// 显示的记录
|
||||||
const displayRecords = computed(() => paginatedRecords.value)
|
const displayRecords = computed(() => paginatedRecords.value)
|
||||||
|
|
||||||
|
const {
|
||||||
|
calibratedDisplayNowMs,
|
||||||
|
stopActiveElapsedDisplayTimer,
|
||||||
|
syncActiveElapsedDisplayTimer,
|
||||||
|
stopActiveElapsedDisplayClock,
|
||||||
|
} = useActiveElapsedDisplayClock({
|
||||||
|
records: displayRecords,
|
||||||
|
isPageVisible,
|
||||||
|
serverClockOffsetMs,
|
||||||
|
hasServerClockOffset,
|
||||||
|
resolveStatus: resolveDisplayRequestStatus,
|
||||||
|
intervalMs: ACTIVE_ELAPSED_DISPLAY_INTERVAL,
|
||||||
|
})
|
||||||
|
|
||||||
const availableClientFamilies = computed(() => {
|
const availableClientFamilies = computed(() => {
|
||||||
const families = new Set<string>()
|
const families = new Set<string>()
|
||||||
currentRecords.value.forEach((record) => {
|
currentRecords.value.forEach((record) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user