revert: remove usage elapsed clock calibration

This commit is contained in:
elky
2026-05-31 20:30:51 +08:00
parent 40eb3cf6e1
commit 905453d62b
24 changed files with 91 additions and 1151 deletions
@@ -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, attach_usage_server_now_header, admin_usage_record_json, build_admin_usage_active_requests_response,
build_admin_usage_active_requests_response, build_admin_usage_records_response, build_admin_usage_records_response, build_admin_usage_summary_stats_response_from_summary,
build_admin_usage_summary_stats_response_from_summary, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL, 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::{
@@ -338,15 +338,13 @@ 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,7 +4,6 @@ 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,
@@ -30,21 +29,6 @@ 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,
@@ -1110,7 +1094,7 @@ pub(super) async fn handle_users_me_usage_get(
&summary_by_provider &summary_by_provider
)); ));
} }
attach_users_me_usage_server_now_header(Json(payload).into_response()) Json(payload).into_response()
} }
pub(super) async fn handle_users_me_usage_active_get( pub(super) async fn handle_users_me_usage_active_get(
@@ -1185,15 +1169,13 @@ 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(
@@ -1382,20 +1364,12 @@ 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::{
attach_users_me_usage_server_now_header, build_users_me_usage_active_payload, build_users_me_usage_active_payload, build_users_me_usage_record_payload,
build_users_me_usage_record_payload, users_me_usage_client_is_stream, users_me_usage_client_is_stream, users_me_usage_is_failed,
users_me_usage_is_failed, users_me_usage_server_now_unix_ms,
users_me_usage_upstream_is_stream, users_me_usage_upstream_is_stream,
}; };
@@ -1441,39 +1415,6 @@ 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,4 +1,3 @@
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};
@@ -7,8 +6,6 @@ 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));
} }
@@ -34,11 +31,7 @@ fn apply_frontdoor_cors_headers(
); );
headers.insert( headers.insert(
http::header::ACCESS_CONTROL_EXPOSE_HEADERS, http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
HeaderValue::from_static(if cors.allow_credentials() { HeaderValue::from_static("*"),
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) {
@@ -116,52 +109,3 @@ 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,7 +33,6 @@ 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;
@@ -1015,7 +1014,6 @@ 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"
@@ -1023,17 +1021,9 @@ 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);
@@ -1243,24 +1233,15 @@ 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");
+6 -14
View File
@@ -7,7 +7,6 @@ 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;
@@ -452,19 +451,12 @@ async fn gateway_adds_cors_headers_to_proxied_responses_inner() {
.expect("allow origin header"), .expect("allow origin header"),
"http://localhost:3000" "http://localhost:3000"
); );
let expose_headers = response_headers assert_eq!(
.get("access-control-expose-headers") response_headers
.expect("expose headers header") .get("access-control-expose-headers")
.to_str() .expect("expose headers header"),
.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, assert_usage_server_now_header_between, build_router, build_router_with_state, json, any, build_router, build_router_with_state, json, start_server, to_bytes, AppState, Arc, Body,
start_server, to_bytes, unix_epoch_millis_for_tests, AppState, Arc, Body, Json, Mutex, Request, Json, Mutex, Request, Router, StatusCode, CONTROL_ROUTE_FAMILY_HEADER,
Router, StatusCode, CONTROL_ROUTE_FAMILY_HEADER, CONTROL_ROUTE_KIND_HEADER, CONTROL_ROUTE_KIND_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER, TRUSTED_ADMIN_USER_ROLE_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,7 +5345,6 @@ 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"
@@ -5356,17 +5355,9 @@ 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);
@@ -5676,7 +5667,6 @@ 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}"))
@@ -5685,17 +5675,9 @@ 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");
-35
View File
@@ -1,9 +1,6 @@
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;
@@ -32,38 +29,6 @@ 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
+9 -87
View File
@@ -19,24 +19,8 @@ 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,
@@ -2291,7 +2275,7 @@ pub fn build_admin_usage_active_requests_response(
}) })
.collect(); .collect();
attach_usage_server_now_header(Json(json!({ "requests": payload })).into_response()) Json(json!({ "requests": payload })).into_response()
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -2321,15 +2305,13 @@ 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(
@@ -2521,7 +2503,6 @@ 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::{
@@ -2529,10 +2510,7 @@ 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, admin_usage_total_tokens, admin_usage_upstream_is_stream, build_admin_usage_detail_payload,
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};
@@ -2582,62 +2560,6 @@ 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(
@@ -1,62 +0,0 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { 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,10 +29,6 @@ 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: {
@@ -119,56 +115,6 @@ 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({
+3 -11
View File
@@ -5,11 +5,6 @@ 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
@@ -147,7 +142,7 @@ export interface ApiFormatSummary {
} }
// 使用统计响应接口 // 使用统计响应接口
export interface UsageResponse extends ServerTimedPayload { export interface UsageResponse {
total_requests: number total_requests: number
total_input_tokens: number total_input_tokens: number
total_output_tokens: number total_output_tokens: number
@@ -326,14 +321,12 @@ 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 withServerTiming(response, clientSendUnixMs) return response.data
}, },
// 获取活跃请求状态(用于轮询更新) // 获取活跃请求状态(用于轮询更新)
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'
@@ -365,9 +358,8 @@ 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 withServerTiming(response, clientSendUnixMs) return response.data
}, },
// 获取可用的提供商 // 获取可用的提供商
-78
View File
@@ -1,78 +0,0 @@
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,
}
}
+7 -28
View File
@@ -2,11 +2,6 @@ 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
@@ -132,7 +127,7 @@ export interface UsageRequestOptions {
skipCache?: boolean skipCache?: boolean
} }
type UsageListResponse = ServerTimedPayload & { type UsageListResponse = {
records?: unknown records?: unknown
pagination?: { pagination?: {
total?: unknown total?: unknown
@@ -204,7 +199,6 @@ 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
@@ -226,7 +220,6 @@ function normalizeUsageRecordPage(
total, total,
page: resolvedPage, page: resolvedPage,
page_size: limit, page_size: limit,
...(payload.server_timing ? { server_timing: payload.server_timing } : {}),
} }
} }
@@ -373,12 +366,10 @@ 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(withServerTiming(response, clientSendUnixMs), pagination) return normalizeUsageRecordPage(response.data, pagination)
}, },
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> { async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
@@ -453,25 +444,17 @@ 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([
statsRequest, apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
recordsRequest, apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
]) ])
return { return {
records: assertUsageRecords(recordsResponse.records), records: assertUsageRecords(recordsResponse.data.records),
stats: statsResponse.data, stats: statsResponse.data,
...(recordsResponse.server_timing ? { server_timing: recordsResponse.server_timing } : {}),
} }
}, },
@@ -497,13 +480,11 @@ 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 withServerTiming(response, clientSendUnixMs) return response.data
}) })
}, },
@@ -515,7 +496,6 @@ 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'
@@ -569,9 +549,8 @@ 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 withServerTiming(response, clientSendUnixMs) return response.data
}, },
/** /**
@@ -3,26 +3,25 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useActiveElapsedDisplayNowMs } from '../composables/useActiveElapsedDisplayClock'
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')
const injectedDisplayNowMs = useActiveElapsedDisplayNowMs()
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
@@ -31,6 +30,35 @@ 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 '-'
@@ -42,14 +70,7 @@ const displayText = computed(() => {
const createdAtMs = parseCreatedAtMs(props.createdAt) const createdAtMs = parseCreatedAtMs(props.createdAt)
if (Number.isNaN(createdAtMs)) return '-' if (Number.isNaN(createdAtMs)) return '-'
// response_time_ms 使 const elapsedMs = Math.max(0, now.value - createdAtMs)
const injectedNowMs = injectedDisplayNowMs?.value
const nowMs = typeof props.displayNowMs === 'number' && Number.isFinite(props.displayNowMs)
? props.displayNowMs
: typeof injectedNowMs === 'number' && Number.isFinite(injectedNowMs)
? injectedNowMs
: 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>
@@ -1,79 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, nextTick, ref, type App } from 'vue'
import { ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, type ActiveElapsedDisplayNowMsRef } from '../../composables/useActiveElapsedDisplayClock'
import ElapsedTimeText from '../ElapsedTimeText.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountElapsedTimeText(
props: Record<string, unknown>,
options: { providedDisplayNowMs?: ActiveElapsedDisplayNowMsRef } = {}
) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(ElapsedTimeText, props)
if (options.providedDisplayNowMs) {
app.provide(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, options.providedDisplayNowMs)
}
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('uses the injected shared display clock when no prop is supplied', async () => {
const displayNowMs = ref(Date.parse('2026-05-28T12:00:40Z'))
const root = mountElapsedTimeText({
createdAt: '2026-05-28T12:00:00Z',
status: 'streaming',
responseTimeMs: 10_000,
}, { providedDisplayNowMs: displayNowMs })
expect(root.textContent).toBe('40.00s')
displayNowMs.value = Date.parse('2026-05-28T12:00:45Z')
await nextTick()
expect(root.textContent).toBe('45.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')
})
})
@@ -92,15 +92,8 @@ vi.mock('lucide-vue-next', async () => {
vi.mock('../ElapsedTimeText.vue', () => ({ vi.mock('../ElapsedTimeText.vue', () => ({
default: defineComponent({ default: defineComponent({
name: 'ElapsedTimeTextStub', name: 'ElapsedTimeTextStub',
props: { setup() {
displayNowMs: { return () => h('span', 'elapsed')
type: Number,
},
},
setup(props) {
return () => h('span', {
'data-display-now-ms': props.displayNowMs == null ? 'missing' : String(props.displayNowMs),
}, 'elapsed')
}, },
}), }),
})) }))
@@ -252,16 +245,6 @@ 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('leaves shared clock subscription inside active elapsed text', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'streaming',
response_time_ms: null,
first_byte_time_ms: 500,
})])
expect(root.querySelector('[data-display-now-ms="missing"]')).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',
@@ -1,79 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
import {
provideActiveElapsedDisplayClock,
useActiveElapsedDisplayNowMs,
} from '../useActiveElapsedDisplayClock'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
function mountApp(component: ReturnType<typeof defineComponent>) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(component)
app.mount(root)
mountedApps.push({ app, root })
return root
}
describe('active elapsed display clock render scope', () => {
it('updates injected elapsed text without re-rendering the surrounding table', async () => {
const displayNowMs = ref(1_000)
const rows = Array.from({ length: 1_000 }, (_, index) => ({
id: `row-${index}`,
model: `model-${index}`,
}))
let tableRenderCount = 0
let elapsedRenderCount = 0
const ElapsedTextProbe = defineComponent({
name: 'ElapsedTextProbe',
setup() {
const injectedDisplayNowMs = useActiveElapsedDisplayNowMs()
return () => {
elapsedRenderCount += 1
return h('span', injectedDisplayNowMs?.value)
}
},
})
const TableProbe = defineComponent({
name: 'TableProbe',
setup() {
return () => {
tableRenderCount += 1
return h('table', rows.map(row => h('tr', { key: row.id }, [
h('td', row.model),
row.id === 'row-0' ? h('td', h(ElapsedTextProbe)) : h('td', '-'),
])))
}
},
})
const Harness = defineComponent({
name: 'Harness',
setup() {
provideActiveElapsedDisplayClock(displayNowMs as Ref<number>)
return () => h(TableProbe)
},
})
mountApp(Harness)
expect(tableRenderCount).toBe(1)
expect(elapsedRenderCount).toBe(1)
displayNowMs.value = 1_250
await nextTick()
expect(tableRenderCount).toBe(1)
expect(elapsedRenderCount).toBe(2)
})
})
@@ -1,101 +0,0 @@
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()
})
})
@@ -1,102 +0,0 @@
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,9 +1,4 @@
export { useUsageData } from './useUsageData' export { useUsageData } from './useUsageData'
export {
provideActiveElapsedDisplayClock,
useActiveElapsedDisplayClock,
useActiveElapsedDisplayNowMs,
} 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'
@@ -1,98 +0,0 @@
import { computed, inject, provide, ref, watch, type ComputedRef, type InjectionKey, type Ref } from 'vue'
type ActiveElapsedStatus = string | null | undefined
export interface ActiveElapsedDisplayNowMsRef {
readonly value: number
}
export const ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY: InjectionKey<ActiveElapsedDisplayNowMsRef> =
Symbol('activeElapsedDisplayNowMs')
export function provideActiveElapsedDisplayClock(displayNowMs: ActiveElapsedDisplayNowMsRef): void {
provide(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, displayNowMs)
}
export function useActiveElapsedDisplayNowMs(): ActiveElapsedDisplayNowMsRef | undefined {
return inject(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, 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,
}
}
@@ -1,62 +0,0 @@
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,7 +14,6 @@ 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>
@@ -67,11 +66,6 @@ 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[]>(() => {
@@ -220,7 +214,6 @@ 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,
@@ -377,7 +370,6 @@ 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
@@ -387,7 +379,6 @@ 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
@@ -570,8 +561,6 @@ export function useUsageData(options: UseUsageDataOptions) {
apiFormatStats, apiFormatStats,
currentRecords, currentRecords,
totalRecords, totalRecords,
serverClockOffsetMs,
hasServerClockOffset,
// 筛选选项 // 筛选选项
availableModels, availableModels,
@@ -583,7 +572,6 @@ export function useUsageData(options: UseUsageDataOptions) {
// 方法 // 方法
loadStats, loadStats,
loadRecords, loadRecords,
refreshData, refreshData
updateServerClockOffset
} }
} }
+3 -31
View File
@@ -150,8 +150,6 @@ import {
IntervalTimelineCard IntervalTimelineCard
} from '@/features/usage/components' } from '@/features/usage/components'
import { import {
provideActiveElapsedDisplayClock,
useActiveElapsedDisplayClock,
useUsageData, useUsageData,
getDateRangeFromPeriod getDateRangeFromPeriod
} from '@/features/usage/composables' } from '@/features/usage/composables'
@@ -251,10 +249,7 @@ const {
availableModels, availableModels,
availableProviders, availableProviders,
loadStats, loadStats,
loadRecords, loadRecords
serverClockOffsetMs,
hasServerClockOffset,
updateServerClockOffset
} = useUsageData({ isAdminPage }) } = useUsageData({ isAdminPage })
// //
@@ -456,7 +451,6 @@ 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)
@@ -468,14 +462,10 @@ const discoveredActiveRequestIds = new Set<string>()
async function loadActiveRequestUpdates(ids?: string[]) { async function loadActiveRequestUpdates(ids?: string[]) {
if (isAdminPage.value) { if (isAdminPage.value) {
const result = await usageApi.getActiveRequests(ids, timeRange.value) return 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
const result = await meApi.getActiveRequests(idsParam) return meApi.getActiveRequests(idsParam)
updateServerClockOffset(result.server_timing)
return result
} }
async function pollActiveRequests() { async function pollActiveRequests() {
@@ -749,10 +739,8 @@ function handleVisibilityChange() {
stopAutoRefresh() stopAutoRefresh()
stopActiveDiscovery() stopActiveDiscovery()
stopGlobalAutoRefresh() stopGlobalAutoRefresh()
stopActiveElapsedDisplayTimer()
return return
} }
syncActiveElapsedDisplayTimer()
if (hasActiveRequests.value) { if (hasActiveRequests.value) {
startAutoRefresh() startAutoRefresh()
} }
@@ -769,7 +757,6 @@ onUnmounted(() => {
stopAutoRefresh() stopAutoRefresh()
stopActiveDiscovery() stopActiveDiscovery()
stopGlobalAutoRefresh() stopGlobalAutoRefresh()
stopActiveElapsedDisplayClock()
}) })
// + // +
@@ -793,21 +780,6 @@ 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,
})
provideActiveElapsedDisplayClock(calibratedDisplayNowMs)
const availableClientFamilies = computed(() => { const availableClientFamilies = computed(() => {
const families = new Set<string>() const families = new Set<string>()
currentRecords.value.forEach((record) => { currentRecords.value.forEach((record) => {