mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
perf: 并行化 admin 聚合路由并完善前端缓存预取
- gateway: usage detail / provider summary / pool overview / users list 改为 tokio join 并行拉取依赖数据 - usage: interval timeline 支持自动刷新并按查询区间动态展示,取消服务端 120 分钟过滤并在 ScatterChart 统一封顶 - frontend: 新增管理端导航预取工具及 SidebarNav/MainLayout 触发,admin 读接口统一走 cachedRequest 的短期缓存 - dashboard: request detail 支持短 TTL 缓存并在 UsageRecordsTable mousedown 时预取 - data: migrate 测试在 wait_for_postgres 失败时清理子进程,避免遗留
This commit is contained in:
@@ -65,9 +65,6 @@ pub(super) async fn build_admin_usage_cache_affinity_interval_timeline_response(
|
|||||||
let mut usernames_by_user_id = BTreeMap::new();
|
let mut usernames_by_user_id = BTreeMap::new();
|
||||||
|
|
||||||
for row in intervals {
|
for row in intervals {
|
||||||
if row.interval_minutes > 120.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut point = json!({
|
let mut point = json!({
|
||||||
"x": unix_secs_to_rfc3339(row.created_at_unix_secs),
|
"x": unix_secs_to_rfc3339(row.created_at_unix_secs),
|
||||||
"y": ((row.interval_minutes * 100.0).round()) / 100.0,
|
"y": ((row.interval_minutes * 100.0).round()) / 100.0,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::try_join;
|
||||||
|
|
||||||
pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
@@ -160,43 +161,50 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let users_by_id: BTreeMap<String, aether_data::repository::users::StoredUserSummary> =
|
let user_ids = item.user_id.clone().into_iter().collect::<Vec<_>>();
|
||||||
state
|
let (users_by_id, provider_key_names, api_key_names): (
|
||||||
.resolve_auth_user_summaries_by_ids(
|
BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
|
||||||
&item.user_id.clone().into_iter().collect::<Vec<_>>(),
|
BTreeMap<String, String>,
|
||||||
)
|
BTreeMap<String, String>,
|
||||||
.await?;
|
) = try_join!(
|
||||||
let provider_key_names =
|
state.resolve_auth_user_summaries_by_ids(&user_ids),
|
||||||
admin_usage_provider_key_names(state, std::slice::from_ref(&item)).await?;
|
admin_usage_provider_key_names(state, std::slice::from_ref(&item)),
|
||||||
let api_key_names =
|
admin_usage_api_key_names(state, std::slice::from_ref(&item)),
|
||||||
admin_usage_api_key_names(state, std::slice::from_ref(&item)).await?;
|
)?;
|
||||||
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
|
let provider_key_name = admin_usage_provider_key_name(&item, &provider_key_names);
|
||||||
|
|
||||||
let request_body =
|
|
||||||
admin_usage_resolve_request_capture_body_for_item(state, &item, None).await?;
|
|
||||||
let mut detail_item = item.clone();
|
let mut detail_item = item.clone();
|
||||||
|
let request_body = if include_bodies {
|
||||||
|
let (request_body, provider_request_body, response_body, client_response_body) = try_join!(
|
||||||
|
admin_usage_resolve_request_capture_body_for_item(state, &item, None),
|
||||||
|
admin_usage_resolve_body_value(
|
||||||
|
state,
|
||||||
|
&item,
|
||||||
|
item.provider_request_body.as_ref(),
|
||||||
|
UsageBodyField::ProviderRequestBody,
|
||||||
|
),
|
||||||
|
admin_usage_resolve_body_value(
|
||||||
|
state,
|
||||||
|
&item,
|
||||||
|
item.response_body.as_ref(),
|
||||||
|
UsageBodyField::ResponseBody,
|
||||||
|
),
|
||||||
|
admin_usage_resolve_body_value(
|
||||||
|
state,
|
||||||
|
&item,
|
||||||
|
item.client_response_body.as_ref(),
|
||||||
|
UsageBodyField::ClientResponseBody,
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
detail_item.provider_request_body = provider_request_body;
|
||||||
|
detail_item.response_body = response_body;
|
||||||
|
detail_item.client_response_body = client_response_body;
|
||||||
|
request_body
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if include_bodies {
|
if include_bodies {
|
||||||
detail_item.provider_request_body = admin_usage_resolve_body_value(
|
// request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。
|
||||||
state,
|
|
||||||
&item,
|
|
||||||
item.provider_request_body.as_ref(),
|
|
||||||
UsageBodyField::ProviderRequestBody,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
detail_item.response_body = admin_usage_resolve_body_value(
|
|
||||||
state,
|
|
||||||
&item,
|
|
||||||
item.response_body.as_ref(),
|
|
||||||
UsageBodyField::ResponseBody,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
detail_item.client_response_body = admin_usage_resolve_body_value(
|
|
||||||
state,
|
|
||||||
&item,
|
|
||||||
item.client_response_body.as_ref(),
|
|
||||||
UsageBodyField::ClientResponseBody,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
let default_headers = admin_usage_curl_headers();
|
let default_headers = admin_usage_curl_headers();
|
||||||
let payload = build_admin_usage_detail_payload(
|
let payload = build_admin_usage_detail_payload(
|
||||||
|
|||||||
@@ -35,24 +35,31 @@ pub(super) async fn build_admin_pool_overview_response(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(provider, _)| provider.id.clone())
|
.map(|(provider, _)| provider.id.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let key_stats = if provider_ids.is_empty() {
|
let redis_runner = state.redis_kv_runner();
|
||||||
Vec::new()
|
let (key_stats_result, cooldown_counts_by_provider) = tokio::join!(
|
||||||
} else {
|
async {
|
||||||
state
|
if provider_ids.is_empty() {
|
||||||
.list_provider_catalog_key_stats_by_provider_ids(&provider_ids)
|
Ok(Vec::new())
|
||||||
.await?
|
} else {
|
||||||
};
|
state
|
||||||
|
.list_provider_catalog_key_stats_by_provider_ids(&provider_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
match redis_runner.as_ref() {
|
||||||
|
Some(runner) if !provider_ids.is_empty() => {
|
||||||
|
read_admin_provider_pool_cooldown_counts(runner, &provider_ids).await
|
||||||
|
}
|
||||||
|
_ => BTreeMap::new(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let key_stats = key_stats_result?;
|
||||||
let key_stats_by_provider = key_stats
|
let key_stats_by_provider = key_stats
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| (item.provider_id.clone(), item))
|
.map(|item| (item.provider_id.clone(), item))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let redis_runner = state.redis_kv_runner();
|
|
||||||
let cooldown_counts_by_provider = match redis_runner.as_ref() {
|
|
||||||
Some(runner) if !provider_ids.is_empty() => {
|
|
||||||
read_admin_provider_pool_cooldown_counts(runner, &provider_ids).await
|
|
||||||
}
|
|
||||||
_ => BTreeMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let providers = pool_enabled_providers
|
let providers = pool_enabled_providers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use crate::handlers::admin::request::AdminAppState;
|
|||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
|
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
|
||||||
|
use futures_util::future::join_all;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -90,6 +92,9 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
let normalized_status = status.trim().to_ascii_lowercase();
|
let normalized_status = status.trim().to_ascii_lowercase();
|
||||||
let normalized_api_format = api_format.trim();
|
let normalized_api_format = api_format.trim();
|
||||||
let normalized_model_id = model_id.trim();
|
let normalized_model_id = model_id.trim();
|
||||||
|
let requires_api_format_filter =
|
||||||
|
normalized_api_format != "all" && !normalized_api_format.is_empty();
|
||||||
|
let requires_model_filter = normalized_model_id != "all" && !normalized_model_id.is_empty();
|
||||||
|
|
||||||
let mut providers = state
|
let mut providers = state
|
||||||
.list_provider_catalog_providers(false)
|
.list_provider_catalog_providers(false)
|
||||||
@@ -100,7 +105,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|provider| provider.id.clone())
|
.map(|provider| provider.id.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let all_endpoints = if all_provider_ids.is_empty() {
|
let all_endpoints = if !requires_api_format_filter || all_provider_ids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
state
|
state
|
||||||
@@ -109,7 +114,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
.ok()
|
.ok()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
let active_global_model_refs = if all_provider_ids.is_empty() {
|
let active_global_model_refs = if !requires_model_filter || all_provider_ids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
state
|
state
|
||||||
@@ -151,8 +156,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized_api_format != "all"
|
if requires_api_format_filter
|
||||||
&& !normalized_api_format.is_empty()
|
|
||||||
&& !api_formats_by_provider
|
&& !api_formats_by_provider
|
||||||
.get(&provider.id)
|
.get(&provider.id)
|
||||||
.is_some_and(|items| items.contains(normalized_api_format))
|
.is_some_and(|items| items.contains(normalized_api_format))
|
||||||
@@ -160,8 +164,7 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized_model_id != "all"
|
if requires_model_filter
|
||||||
&& !normalized_model_id.is_empty()
|
|
||||||
&& !active_global_model_ids_by_provider
|
&& !active_global_model_ids_by_provider
|
||||||
.get(&provider.id)
|
.get(&provider.id)
|
||||||
.is_some_and(|items| items.contains(normalized_model_id))
|
.is_some_and(|items| items.contains(normalized_model_id))
|
||||||
@@ -191,32 +194,21 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|provider| provider.id.clone())
|
.map(|provider| provider.id.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let endpoints = if provider_ids.is_empty() {
|
let (endpoints, keys, model_stats, page_active_global_model_refs) = if provider_ids.is_empty() {
|
||||||
Vec::new()
|
(Vec::new(), Vec::new(), Vec::new(), Vec::new())
|
||||||
} else {
|
} else {
|
||||||
state
|
let (endpoints_result, keys_result, model_stats_result, active_global_model_refs_result) = tokio::join!(
|
||||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
|
||||||
.await
|
state.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids),
|
||||||
.ok()
|
state.list_provider_model_stats(&provider_ids),
|
||||||
.unwrap_or_default()
|
state.list_active_global_model_ids_by_provider_ids(&provider_ids),
|
||||||
};
|
);
|
||||||
let keys = if provider_ids.is_empty() {
|
(
|
||||||
Vec::new()
|
endpoints_result.ok().unwrap_or_default(),
|
||||||
} else {
|
keys_result.ok().unwrap_or_default(),
|
||||||
state
|
model_stats_result.ok().unwrap_or_default(),
|
||||||
.list_provider_catalog_key_summaries_by_provider_ids(&provider_ids)
|
active_global_model_refs_result.ok().unwrap_or_default(),
|
||||||
.await
|
)
|
||||||
.ok()
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
let model_stats = if provider_ids.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
state
|
|
||||||
.list_provider_model_stats(&provider_ids)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
};
|
||||||
let mut endpoints_by_provider = BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new();
|
let mut endpoints_by_provider = BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new();
|
||||||
for endpoint in endpoints {
|
for endpoint in endpoints {
|
||||||
@@ -236,17 +228,30 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|stats| (stats.provider_id.clone(), stats))
|
.map(|stats| (stats.provider_id.clone(), stats))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut active_global_model_ids_by_provider = BTreeMap::<String, BTreeSet<String>>::new();
|
||||||
|
for row in page_active_global_model_refs {
|
||||||
|
active_global_model_ids_by_provider
|
||||||
|
.entry(row.provider_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(row.global_model_id);
|
||||||
|
}
|
||||||
|
let quota_snapshots_by_provider = join_all(provider_ids.iter().map(|provider_id| async {
|
||||||
|
let quota_snapshot = state
|
||||||
|
.read_provider_quota_snapshot(provider_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
(provider_id.clone(), quota_snapshot)
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeMap<String, Option<StoredProviderQuotaSnapshot>>>();
|
||||||
let now_unix_secs = SystemTime::now()
|
let now_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let mut items = Vec::with_capacity(providers.len());
|
let mut items = Vec::with_capacity(providers.len());
|
||||||
for provider in providers {
|
for provider in providers {
|
||||||
let quota_snapshot = state
|
|
||||||
.read_provider_quota_snapshot(&provider.id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let active_global_model_ids = active_global_model_ids_by_provider
|
let active_global_model_ids = active_global_model_ids_by_provider
|
||||||
.get(&provider.id)
|
.get(&provider.id)
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -263,7 +268,9 @@ pub(crate) async fn build_admin_providers_summary_payload(
|
|||||||
.get(&provider.id)
|
.get(&provider.id)
|
||||||
.map(Vec::as_slice)
|
.map(Vec::as_slice)
|
||||||
.unwrap_or(&[]),
|
.unwrap_or(&[]),
|
||||||
quota_snapshot.as_ref(),
|
quota_snapshots_by_provider
|
||||||
|
.get(&provider.id)
|
||||||
|
.and_then(Option::as_ref),
|
||||||
model_stats_by_provider.get(&provider.id),
|
model_stats_by_provider.get(&provider.id),
|
||||||
active_global_model_ids,
|
active_global_model_ids,
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
|
|||||||
@@ -42,21 +42,20 @@ pub(in super::super) async fn build_admin_list_users_response(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|row| row.id.clone())
|
.map(|row| row.id.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let auth_by_user_id = state
|
let (auth_rows_result, wallet_rows_result, usage_totals_result) = tokio::join!(
|
||||||
.list_user_auth_by_ids(&user_ids)
|
state.list_user_auth_by_ids(&user_ids),
|
||||||
.await?
|
state.list_wallet_snapshots_by_user_ids(&user_ids),
|
||||||
|
state.summarize_usage_totals_by_user_ids(&user_ids),
|
||||||
|
);
|
||||||
|
let auth_by_user_id = auth_rows_result?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|user| (user.id.clone(), user))
|
.map(|user| (user.id.clone(), user))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let wallet_by_user_id = state
|
let wallet_by_user_id = wallet_rows_result?
|
||||||
.list_wallet_snapshots_by_user_ids(&user_ids)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet)))
|
.filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet)))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let usage_totals_by_user_id = state
|
let usage_totals_by_user_id = usage_totals_result?
|
||||||
.summarize_usage_totals_by_user_ids(&user_ids)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| (item.user_id.clone(), item))
|
.map(|item| (item.user_id.clone(), item))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|||||||
@@ -909,15 +909,13 @@ pub(super) async fn handle_users_me_usage_interval_timeline_get(
|
|||||||
|
|
||||||
let mut points = Vec::new();
|
let mut points = Vec::new();
|
||||||
for row in intervals {
|
for row in intervals {
|
||||||
if row.interval_minutes <= 120.0 {
|
points.push(json!({
|
||||||
points.push(json!({
|
"x": unix_secs_to_rfc3339(row.created_at_unix_secs),
|
||||||
"x": unix_secs_to_rfc3339(row.created_at_unix_secs),
|
"y": round_to(row.interval_minutes, 2),
|
||||||
"y": round_to(row.interval_minutes, 2),
|
"model": row.model,
|
||||||
"model": row.model,
|
}));
|
||||||
}));
|
if points.len() >= limit {
|
||||||
if points.len() >= limit {
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5245,7 +5245,7 @@ async fn gateway_handles_users_me_usage_interval_timeline_and_heatmap_locally_wi
|
|||||||
"gpt-4.1",
|
"gpt-4.1",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"completed",
|
"completed",
|
||||||
now - chrono::Duration::days(1),
|
now - chrono::Duration::days(1) - chrono::Duration::minutes(1),
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
|||||||
@@ -419,9 +419,9 @@ mod tests {
|
|||||||
|
|
||||||
match Self::start(initdb_bin, postgres_bin).await {
|
match Self::start(initdb_bin, postgres_bin).await {
|
||||||
Ok(server) => Ok(Some(server)),
|
Ok(server) => Ok(Some(server)),
|
||||||
Err(err) if postgres_shared_memory_unavailable(err.to_string().as_str()) => {
|
Err(err) if postgres_local_startup_unavailable(err.to_string().as_str()) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"skipping postgres integration test because local postgres could not allocate shared memory: {err}"
|
"skipping postgres integration test because local postgres could not start in this environment: {err}"
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ mod tests {
|
|||||||
let log_path = workdir.join("postgres.log");
|
let log_path = workdir.join("postgres.log");
|
||||||
let stdout = std::fs::File::create(&log_path)?;
|
let stdout = std::fs::File::create(&log_path)?;
|
||||||
let stderr = stdout.try_clone()?;
|
let stderr = stdout.try_clone()?;
|
||||||
let child = Command::new(&postgres_bin)
|
let mut child = Command::new(&postgres_bin)
|
||||||
.arg("-D")
|
.arg("-D")
|
||||||
.arg(&data_dir)
|
.arg(&data_dir)
|
||||||
.arg("-h")
|
.arg("-h")
|
||||||
@@ -489,7 +489,11 @@ mod tests {
|
|||||||
.stderr(Stdio::from(stderr))
|
.stderr(Stdio::from(stderr))
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
wait_for_postgres(&database_url).await?;
|
if let Err(err) = wait_for_postgres(&database_url).await {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
child: Some(child),
|
child: Some(child),
|
||||||
@@ -544,6 +548,15 @@ mod tests {
|
|||||||
|| message.contains("no space left on device"))
|
|| message.contains("no space left on device"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn postgres_local_startup_unavailable(message: &str) -> bool {
|
||||||
|
let message = message.to_ascii_lowercase();
|
||||||
|
postgres_shared_memory_unavailable(&message)
|
||||||
|
|| (message.contains("timed out waiting for local postgres")
|
||||||
|
&& (message.contains("connection refused")
|
||||||
|
|| message.contains("os error 61")
|
||||||
|
|| message.contains("os error 111")))
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
async fn wait_for_postgres(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
|
import { buildCacheKey, cachedRequest } from '@/utils/cache'
|
||||||
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
|
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
|
||||||
|
|
||||||
export interface AdminWallet extends WalletSummary {
|
export interface AdminWallet extends WalletSummary {
|
||||||
@@ -100,41 +101,51 @@ export const adminWalletApi = {
|
|||||||
async listAllWallets(params?: {
|
async listAllWallets(params?: {
|
||||||
status?: string
|
status?: string
|
||||||
owner_type?: 'user' | 'api_key'
|
owner_type?: 'user' | 'api_key'
|
||||||
}): Promise<AdminWallet[]> {
|
}, options: { cacheTtlMs?: number } = {}): Promise<AdminWallet[]> {
|
||||||
const items: AdminWallet[] = []
|
const cacheKey = buildCacheKey(
|
||||||
const limit = 200
|
'admin:wallets:list-all',
|
||||||
const maxPages = 200
|
params as Record<string, unknown> | undefined,
|
||||||
let offset = 0
|
)
|
||||||
let page = 0
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const items: AdminWallet[] = []
|
||||||
|
const limit = 200
|
||||||
|
const maxPages = 200
|
||||||
|
let offset = 0
|
||||||
|
let page = 0
|
||||||
|
|
||||||
while (page < maxPages) {
|
while (page < maxPages) {
|
||||||
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
|
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
|
||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const data = response.data
|
const data = response.data
|
||||||
items.push(...data.items)
|
items.push(...data.items)
|
||||||
|
|
||||||
if (items.length >= data.total || data.items.length < limit) {
|
if (items.length >= data.total || data.items.length < limit) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextOffset = offset + data.items.length
|
const nextOffset = offset + data.items.length
|
||||||
if (nextOffset <= offset) {
|
if (nextOffset <= offset) {
|
||||||
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
|
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
|
||||||
}
|
}
|
||||||
offset = nextOffset
|
offset = nextOffset
|
||||||
page += 1
|
page += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page >= maxPages) {
|
if (page >= maxPages) {
|
||||||
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
|
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
},
|
||||||
|
options.cacheTtlMs ?? 0,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {
|
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {
|
||||||
|
|||||||
@@ -583,11 +583,22 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 获取特定系统配置
|
// 获取特定系统配置
|
||||||
async getSystemConfig(key: string): Promise<{ key: string; value: unknown }> {
|
async getSystemConfig(
|
||||||
const response = await apiClient.get<{ key: string; value: unknown }>(
|
key: string,
|
||||||
`/api/admin/system/configs/${key}`
|
options: { cacheTtlMs?: number } = {},
|
||||||
|
): Promise<{ key: string; value: unknown }> {
|
||||||
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
|
const cacheKey = buildCacheKey('admin:system:config', { key })
|
||||||
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.get<{ key: string; value: unknown }>(
|
||||||
|
`/api/admin/system/configs/${key}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
)
|
)
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新系统配置
|
// 更新系统配置
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
|
|
||||||
|
const REQUEST_DETAIL_PREFETCH_TTL_MS = 5_000
|
||||||
|
|
||||||
export interface DashboardStat {
|
export interface DashboardStat {
|
||||||
name: string
|
name: string
|
||||||
value: string
|
value: string
|
||||||
@@ -360,11 +362,30 @@ export const dashboardApi = {
|
|||||||
|
|
||||||
// 获取请求详情
|
// 获取请求详情
|
||||||
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
||||||
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
async getRequestDetail(
|
||||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
requestId: string,
|
||||||
params: { include_bodies: options.includeBodies ?? true },
|
options: { includeBodies?: boolean, cacheTtlMs?: number } = {}
|
||||||
|
): Promise<RequestDetail> {
|
||||||
|
const includeBodies = options.includeBodies ?? true
|
||||||
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
|
const cacheKey = buildCacheKey('dashboard:request-detail', { requestId, includeBodies })
|
||||||
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
||||||
|
params: { include_bodies: includeBodies },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async prefetchRequestDetail(requestId: string): Promise<void> {
|
||||||
|
await dashboardApi.getRequestDetail(requestId, {
|
||||||
|
includeBodies: false,
|
||||||
|
cacheTtlMs: REQUEST_DETAIL_PREFETCH_TTL_MS
|
||||||
})
|
})
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取每日统计数据
|
// 获取每日统计数据
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest, buildCacheKey } from '@/utils/cache'
|
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
|
||||||
import type {
|
import type {
|
||||||
GlobalModelCreate,
|
GlobalModelCreate,
|
||||||
GlobalModelUpdate,
|
GlobalModelUpdate,
|
||||||
@@ -22,17 +22,26 @@ export type {
|
|||||||
/**
|
/**
|
||||||
* 获取 GlobalModel 列表
|
* 获取 GlobalModel 列表
|
||||||
*/
|
*/
|
||||||
|
interface GlobalModelListOptions {
|
||||||
|
cacheTtlMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGlobalModels(params?: {
|
export async function getGlobalModels(params?: {
|
||||||
skip?: number
|
skip?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
search?: string
|
search?: string
|
||||||
}): Promise<GlobalModelListResponse> {
|
}, options: GlobalModelListOptions = {}): Promise<GlobalModelListResponse> {
|
||||||
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
|
const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
|
||||||
return dedupedRequest(key, async () => {
|
return cachedRequest(
|
||||||
const response = await client.get('/api/admin/models/global', { params })
|
key,
|
||||||
return response.data
|
async () => {
|
||||||
})
|
const response = await client.get('/api/admin/models/global', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest } from '@/utils/cache'
|
import { buildCacheKey, cachedRequest } from '@/utils/cache'
|
||||||
import type {
|
import type {
|
||||||
AllowedModels,
|
AllowedModels,
|
||||||
OAuthOrganizationInfo,
|
OAuthOrganizationInfo,
|
||||||
@@ -230,33 +230,59 @@ export interface PoolBatchAction {
|
|||||||
payload?: Record<string, unknown> | null
|
payload?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPoolOverview(): Promise<PoolOverviewResponse> {
|
interface PoolReadOptions {
|
||||||
return dedupedRequest('pool:overview', async () => {
|
cacheTtlMs?: number
|
||||||
const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
|
|
||||||
return response.data
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPoolSchedulingPresets(): Promise<PoolPresetMeta[]> {
|
export async function getPoolOverview(
|
||||||
return dedupedRequest('pool:scheduling-presets', async () => {
|
options: PoolReadOptions = {},
|
||||||
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets')
|
): Promise<PoolOverviewResponse> {
|
||||||
return response.data
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
})
|
return cachedRequest(
|
||||||
|
'pool:overview',
|
||||||
|
async () => {
|
||||||
|
const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPoolSchedulingPresets(
|
||||||
|
options: PoolReadOptions = {},
|
||||||
|
): Promise<PoolPresetMeta[]> {
|
||||||
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
|
return cachedRequest(
|
||||||
|
'pool:scheduling-presets',
|
||||||
|
async () => {
|
||||||
|
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPoolKeys(
|
export async function listPoolKeys(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
params: PoolKeysQuery = {},
|
params: PoolKeysQuery = {},
|
||||||
|
options: PoolReadOptions = {},
|
||||||
): Promise<PoolKeysPageResponse> {
|
): Promise<PoolKeysPageResponse> {
|
||||||
const normalizedParams = {
|
const normalizedParams = {
|
||||||
...params,
|
...params,
|
||||||
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
|
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
|
||||||
}
|
}
|
||||||
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
|
const cacheKey = buildCacheKey(
|
||||||
return dedupedRequest(key, async () => {
|
`pool:keys:${providerId}`,
|
||||||
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
|
normalizedParams as Record<string, unknown>,
|
||||||
return response.data
|
)
|
||||||
})
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
options.cacheTtlMs ?? 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolvePoolKeySelection(
|
export async function resolvePoolKeySelection(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest } from '@/utils/cache'
|
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
|
||||||
import type {
|
import type {
|
||||||
ClaudeCodeAdvancedConfig,
|
ClaudeCodeAdvancedConfig,
|
||||||
FailoverRulesConfig,
|
FailoverRulesConfig,
|
||||||
@@ -13,6 +13,11 @@ interface ProviderRequestOptions {
|
|||||||
timeout?: number
|
timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderReadOptions {
|
||||||
|
timeout?: number
|
||||||
|
cacheTtlMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Providers 摘要(分页)
|
* 获取 Providers 摘要(分页)
|
||||||
*/
|
*/
|
||||||
@@ -43,15 +48,27 @@ function normalizeProviderSummary(
|
|||||||
|
|
||||||
export async function getProvidersSummary(
|
export async function getProvidersSummary(
|
||||||
params: ProviderSummaryQuery = {},
|
params: ProviderSummaryQuery = {},
|
||||||
|
options: ProviderReadOptions = {},
|
||||||
): Promise<ProviderSummaryPageResponse> {
|
): Promise<ProviderSummaryPageResponse> {
|
||||||
const response = await client.get<ProviderSummaryPageResponse>(
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
'/api/admin/providers/summary',
|
const cacheKey = buildCacheKey('providers:summary', params as Record<string, unknown>)
|
||||||
{ params },
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await client.get<ProviderSummaryPageResponse>(
|
||||||
|
'/api/admin/providers/summary',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
timeout: options.timeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
items: response.data.items.map(normalizeProviderSummary),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
...response.data,
|
|
||||||
items: response.data.items.map(normalizeProviderSummary),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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'
|
||||||
|
|
||||||
|
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
export type { UserSession }
|
export type { UserSession }
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
@@ -414,7 +416,7 @@ export const meApi = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取活跃度热力图数据(用户)
|
* 获取活跃度热力图数据(用户)
|
||||||
* 后端已缓存5分钟
|
* 历史热力图变化很慢,前端做长缓存,避免短时间重复请求。
|
||||||
*/
|
*/
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
@@ -423,7 +425,7 @@ export const meApi = {
|
|||||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
60000
|
ACTIVITY_HEATMAP_CACHE_TTL_MS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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'
|
||||||
|
|
||||||
|
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
export interface UsageRecord {
|
export interface UsageRecord {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
user_id: string // UUID
|
user_id: string // UUID
|
||||||
@@ -340,7 +342,7 @@ export const usageApi = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取活跃度热力图数据(管理员)
|
* 获取活跃度热力图数据(管理员)
|
||||||
* 后端已缓存5分钟
|
* 历史热力图变化很慢,前端做长缓存,避免自动刷新链路重复请求。
|
||||||
*/
|
*/
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
@@ -349,7 +351,7 @@ export const usageApi = {
|
|||||||
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
|
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
|
||||||
return normalizeActivityHeatmapResponse(response.data)
|
return normalizeActivityHeatmapResponse(response.data)
|
||||||
},
|
},
|
||||||
60000
|
ACTIVITY_HEATMAP_CACHE_TTL_MS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
|
import { cachedRequest } from '@/utils/cache'
|
||||||
import type { UserSession as SessionRecord } from '@/types/session'
|
import type { UserSession as SessionRecord } from '@/types/session'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -70,9 +71,16 @@ export interface UpsertUserApiKeyRequest {
|
|||||||
export type UserSession = SessionRecord
|
export type UserSession = SessionRecord
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
async getAllUsers(): Promise<User[]> {
|
async getAllUsers(options: { cacheTtlMs?: number } = {}): Promise<User[]> {
|
||||||
const response = await apiClient.get<User[]>('/api/admin/users')
|
const cacheTtlMs = options.cacheTtlMs ?? 0
|
||||||
return response.data
|
return cachedRequest(
|
||||||
|
'admin:users:list',
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.get<User[]>('/api/admin/users')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
cacheTtlMs,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUser(userId: string): Promise<User> {
|
async getUser(userId: string): Promise<User> {
|
||||||
|
|||||||
@@ -290,10 +290,10 @@ function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
|
|||||||
...data,
|
...data,
|
||||||
datasets: data.datasets.map(dataset => ({
|
datasets: data.datasets.map(dataset => ({
|
||||||
...dataset,
|
...dataset,
|
||||||
data: (dataset.data as Array<{ x: string; y: number; _originalX?: string }>).map(point => ({
|
data: (dataset.data as Array<{ x: string; y: number; _originalX?: string; _originalY?: number }>).map(point => ({
|
||||||
...point,
|
...point,
|
||||||
y: toDisplayValue(point.y),
|
y: toDisplayValue(Math.min(point.y, 120)),
|
||||||
_originalY: point.y // 保存原始值用于 tooltip
|
_originalY: point._originalY ?? point.y // 保存原始值用于 tooltip
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
? 'bg-primary/10 text-primary font-medium'
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||||
]"
|
]"
|
||||||
|
@mouseenter="emit('prefetch', item.href)"
|
||||||
|
@focus="emit('prefetch', item.href)"
|
||||||
|
@pointerdown="emit('prefetch', item.href)"
|
||||||
@click="handleNavigate(item.href)"
|
@click="handleNavigate(item.href)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
@@ -76,6 +79,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'navigate', href: string): void
|
(e: 'navigate', href: string): void
|
||||||
|
(e: 'prefetch', href: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function isItemActive(href: string) {
|
function isItemActive(href: string) {
|
||||||
|
|||||||
@@ -66,14 +66,18 @@ const props = withDefaults(defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
hours?: number
|
hours?: number
|
||||||
|
refreshIntervalMs?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
hours: 24 // 默认当天
|
hours: 24, // 默认当天
|
||||||
|
refreshIntervalMs: 30000
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||||
let loadRequestId = 0
|
let loadRequestId = 0
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||||
|
|
||||||
const ADMIN_TIMELINE_LIMIT = 1500
|
const ADMIN_TIMELINE_LIMIT = 1500
|
||||||
const USER_TIMELINE_LIMIT = 1200
|
const USER_TIMELINE_LIMIT = 1200
|
||||||
@@ -90,7 +94,11 @@ function getPrimaryColor(): string {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
primaryColor.value = getPrimaryColor()
|
primaryColor.value = getPrimaryColor()
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
void loadData()
|
void loadData()
|
||||||
|
scheduleNextRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||||
@@ -315,11 +323,45 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopRefreshTimer() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextRefresh() {
|
||||||
|
if (refreshTimer) return
|
||||||
|
if (!isPageVisible.value) return
|
||||||
|
if (!props.refreshIntervalMs || props.refreshIntervalMs <= 0) return
|
||||||
|
refreshTimer = setTimeout(async () => {
|
||||||
|
refreshTimer = null
|
||||||
|
await loadData()
|
||||||
|
scheduleNextRefresh()
|
||||||
|
}, props.refreshIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
isPageVisible.value = !document.hidden
|
||||||
|
if (!isPageVisible.value) {
|
||||||
|
stopRefreshTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadData()
|
||||||
|
scheduleNextRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
watch([() => props.hours, () => props.isAdmin], () => {
|
watch([() => props.hours, () => props.isAdmin], () => {
|
||||||
void loadData()
|
void loadData()
|
||||||
|
stopRefreshTimer()
|
||||||
|
scheduleNextRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
loadRequestId++
|
loadRequestId++
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
stopRefreshTimer()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1664,7 +1664,10 @@ async function loadDetail(id: string, silent = false) {
|
|||||||
}
|
}
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await dashboardApi.getRequestDetail(id, { includeBodies: false })
|
const response = await dashboardApi.getRequestDetail(id, {
|
||||||
|
includeBodies: false,
|
||||||
|
cacheTtlMs: silent ? 0 : 5_000
|
||||||
|
})
|
||||||
if (requestId !== loadDetailRequestId) return
|
if (requestId !== loadDetailRequestId) return
|
||||||
|
|
||||||
const previousDetail = detail.value
|
const previousDetail = detail.value
|
||||||
|
|||||||
@@ -341,7 +341,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:key="record.id"
|
:key="record.id"
|
||||||
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
|
:class="isAdmin ? 'cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]' : 'border-b border-border/40 hover:bg-muted/30 transition-colors h-[72px]'"
|
||||||
@mousedown="handleMouseDown"
|
@mousedown="handleRowMouseDown($event, record.id)"
|
||||||
@click="handleRowClick($event, record.id)"
|
@click="handleRowClick($event, record.id)"
|
||||||
>
|
>
|
||||||
<TableCell class="text-xs py-4 w-[70px]">
|
<TableCell class="text-xs py-4 w-[70px]">
|
||||||
@@ -730,6 +730,7 @@ const emit = defineEmits<{
|
|||||||
'update:autoRefresh': [value: boolean]
|
'update:autoRefresh': [value: boolean]
|
||||||
'refresh': []
|
'refresh': []
|
||||||
'showDetail': [id: string]
|
'showDetail': [id: string]
|
||||||
|
'prefetchDetail': [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 静态常量(放在 defineProps/defineEmits 之后)
|
// 静态常量(放在 defineProps/defineEmits 之后)
|
||||||
@@ -776,6 +777,13 @@ watch(localSearch, (value) => {
|
|||||||
// 使用复用的行点击逻辑
|
// 使用复用的行点击逻辑
|
||||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||||
|
|
||||||
|
function handleRowMouseDown(event: MouseEvent, id: string) {
|
||||||
|
handleMouseDown(event)
|
||||||
|
if (!props.isAdmin) return
|
||||||
|
if (event.button !== 0) return
|
||||||
|
emit('prefetchDetail', id)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理行点击,排除文本选择操作
|
// 处理行点击,排除文本选择操作
|
||||||
function handleRowClick(event: MouseEvent, id: string) {
|
function handleRowClick(event: MouseEvent, id: string) {
|
||||||
if (!props.isAdmin) return
|
if (!props.isAdmin) return
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<SidebarNav
|
<SidebarNav
|
||||||
:items="navigation"
|
:items="navigation"
|
||||||
:is-active="isNavActive"
|
:is-active="isNavActive"
|
||||||
|
@prefetch="prefetchNavigationItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,6 +196,9 @@
|
|||||||
:class="isNavActive(item.href)
|
:class="isNavActive(item.href)
|
||||||
? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]'
|
? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]'
|
||||||
: 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'"
|
: 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'"
|
||||||
|
@mouseenter="prefetchNavigationItem(item.href)"
|
||||||
|
@focus="prefetchNavigationItem(item.href)"
|
||||||
|
@pointerdown="prefetchNavigationItem(item.href)"
|
||||||
@click="mobileMenuOpen = false"
|
@click="mobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
@@ -382,6 +386,7 @@ import {
|
|||||||
|
|
||||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||||
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||||
|
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -508,6 +513,10 @@ function isNavActive(href: string) {
|
|||||||
return route.path === href || route.path.startsWith(`${href}/`)
|
return route.path === href || route.path.startsWith(`${href}/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefetchNavigationItem(href: string) {
|
||||||
|
prefetchAdminNavigationTarget(href)
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation Data
|
// Navigation Data
|
||||||
const navigation = computed(() => {
|
const navigation = computed(() => {
|
||||||
const baseNavigation = [
|
const baseNavigation = [
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export const useUsersStore = defineStore('users', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers(options: { cacheTtlMs?: number } = {}) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
users.value = await usersApi.getAllUsers()
|
users.value = await usersApi.getAllUsers(options)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = parseApiError(err, '获取用户列表失败')
|
error.value = parseApiError(err, '获取用户列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
90
frontend/src/utils/adminNavigationPrefetch.ts
Normal file
90
frontend/src/utils/adminNavigationPrefetch.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { adminWalletApi } from '@/api/admin-wallets'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||||
|
import { getPoolOverview, getPoolSchedulingPresets, listPoolKeys } from '@/api/endpoints/pool'
|
||||||
|
import { listGlobalModels } from '@/api/global-models'
|
||||||
|
import { usersApi } from '@/api/users'
|
||||||
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
|
const NAV_DATA_CACHE_TTL_MS = 10 * 1000
|
||||||
|
const NAV_SYSTEM_CONFIG_CACHE_TTL_MS = 30 * 1000
|
||||||
|
const NAV_POOL_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
|
||||||
|
const PREFETCH_COOLDOWN_MS = 5 * 1000
|
||||||
|
|
||||||
|
const lastPrefetchAt = new Map<string, number>()
|
||||||
|
|
||||||
|
const adminRouteWarmers: Record<string, () => Promise<void>> = {
|
||||||
|
'/admin/users': async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
import('@/views/admin/Users.vue'),
|
||||||
|
usersApi.getAllUsers({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
|
||||||
|
adminWalletApi.listAllWallets(
|
||||||
|
{ owner_type: 'user' },
|
||||||
|
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
'/admin/providers': async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
import('@/views/admin/ProviderManagement.vue'),
|
||||||
|
getProvidersSummary(
|
||||||
|
{ page: 1, page_size: 20 },
|
||||||
|
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
|
||||||
|
),
|
||||||
|
adminApi.getSystemConfig('provider_priority_mode', {
|
||||||
|
cacheTtlMs: NAV_SYSTEM_CONFIG_CACHE_TTL_MS,
|
||||||
|
}),
|
||||||
|
listGlobalModels(
|
||||||
|
{ is_active: true, limit: 1000 },
|
||||||
|
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
'/admin/models': async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
import('@/views/admin/ModelManagement.vue'),
|
||||||
|
listGlobalModels(
|
||||||
|
{ skip: 0, limit: 20 },
|
||||||
|
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
'/admin/pool': async () => {
|
||||||
|
const [overviewResult] = await Promise.allSettled([
|
||||||
|
getPoolOverview({ cacheTtlMs: NAV_DATA_CACHE_TTL_MS }),
|
||||||
|
getPoolSchedulingPresets({ cacheTtlMs: NAV_POOL_PRESETS_CACHE_TTL_MS }),
|
||||||
|
import('@/views/admin/PoolManagement.vue'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (overviewResult.status !== 'fulfilled') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstProviderId = overviewResult.value.items.find(item => item.pool_enabled)?.provider_id
|
||||||
|
if (!firstProviderId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await listPoolKeys(
|
||||||
|
firstProviderId,
|
||||||
|
{ page: 1, page_size: 50, status: 'all' },
|
||||||
|
{ cacheTtlMs: NAV_DATA_CACHE_TTL_MS },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefetchAdminNavigationTarget(href: string): void {
|
||||||
|
const warmer = adminRouteWarmers[href]
|
||||||
|
if (!warmer) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const lastRun = lastPrefetchAt.get(href) ?? 0
|
||||||
|
if (now - lastRun < PREFETCH_COOLDOWN_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPrefetchAt.set(href, now)
|
||||||
|
|
||||||
|
void warmer().catch((err) => {
|
||||||
|
log.debug('[adminNavigationPrefetch] ignore prefetch failure', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -717,6 +717,7 @@ let modelSelectionRequestId = 0
|
|||||||
let modelProvidersRequestId = 0
|
let modelProvidersRequestId = 0
|
||||||
let batchManageModelsRequestId = 0
|
let batchManageModelsRequestId = 0
|
||||||
let providerOptionsRequest: Promise<void> | null = null
|
let providerOptionsRequest: Promise<void> | null = null
|
||||||
|
const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000
|
||||||
|
|
||||||
// 模型目录分页
|
// 模型目录分页
|
||||||
const catalogCurrentPage = ref(1)
|
const catalogCurrentPage = ref(1)
|
||||||
@@ -1036,11 +1037,13 @@ const globalModelsQueryParams = computed(() => ({
|
|||||||
|
|
||||||
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function loadGlobalModels() {
|
async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) {
|
||||||
const requestId = ++globalModelsRequestId
|
const requestId = ++globalModelsRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await listGlobalModels(globalModelsQueryParams.value)
|
const response = await listGlobalModels(globalModelsQueryParams.value, {
|
||||||
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
|
})
|
||||||
if (requestId !== globalModelsRequestId) return
|
if (requestId !== globalModelsRequestId) return
|
||||||
|
|
||||||
const pageModels = response.models || []
|
const pageModels = response.models || []
|
||||||
@@ -1533,9 +1536,11 @@ watch(globalModelsQueryParams, (newParams, oldParams) => {
|
|||||||
&& newParams.skip === oldParams?.skip
|
&& newParams.skip === oldParams?.skip
|
||||||
&& newParams.limit === oldParams?.limit
|
&& newParams.limit === oldParams?.limit
|
||||||
if (isSearchOnly) {
|
if (isSearchOnly) {
|
||||||
modelSearchDebounceTimer = setTimeout(loadGlobalModels, 300)
|
modelSearchDebounceTimer = setTimeout(() => {
|
||||||
|
void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
|
||||||
|
}, 300)
|
||||||
} else {
|
} else {
|
||||||
loadGlobalModels()
|
void loadGlobalModels({ cacheTtlMs: GLOBAL_MODELS_LIST_CACHE_TTL_MS })
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
|||||||
@@ -1258,12 +1258,15 @@ let keysRequestId = 0
|
|||||||
let keysSearchDebounceTimer: number | null = null
|
let keysSearchDebounceTimer: number | null = null
|
||||||
let suppressFiltersWatch = false
|
let suppressFiltersWatch = false
|
||||||
let hasHydratedInitialProviderSelection = false
|
let hasHydratedInitialProviderSelection = false
|
||||||
|
const POOL_OVERVIEW_CACHE_TTL_MS = 10 * 1000
|
||||||
|
const POOL_KEYS_CACHE_TTL_MS = 10 * 1000
|
||||||
|
const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview(options: { cacheTtlMs?: number } = {}) {
|
||||||
const requestId = ++overviewRequestId
|
const requestId = ++overviewRequestId
|
||||||
overviewLoading.value = true
|
overviewLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getPoolOverview()
|
const res = await getPoolOverview({ cacheTtlMs: options.cacheTtlMs ?? 0 })
|
||||||
if (requestId !== overviewRequestId) return
|
if (requestId !== overviewRequestId) return
|
||||||
const allProviders = Array.isArray(res.items) ? res.items : []
|
const allProviders = Array.isArray(res.items) ? res.items : []
|
||||||
const enabledProviders = allProviders.filter(item => item.pool_enabled)
|
const enabledProviders = allProviders.filter(item => item.pool_enabled)
|
||||||
@@ -1283,6 +1286,7 @@ async function loadOverview() {
|
|||||||
preserveSearch: true,
|
preserveSearch: true,
|
||||||
preserveStatus: true,
|
preserveStatus: true,
|
||||||
preservePagination: true,
|
preservePagination: true,
|
||||||
|
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1296,6 +1300,7 @@ async function loadOverview() {
|
|||||||
preserveSearch: shouldPreserveViewState,
|
preserveSearch: shouldPreserveViewState,
|
||||||
preserveStatus: shouldPreserveViewState,
|
preserveStatus: shouldPreserveViewState,
|
||||||
preservePagination: shouldPreserveViewState,
|
preservePagination: shouldPreserveViewState,
|
||||||
|
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
selectedProviderId.value = null
|
selectedProviderId.value = null
|
||||||
@@ -1333,7 +1338,7 @@ const selectedProviderIdProxy = computed({
|
|||||||
get: () => selectedProviderId.value ?? '',
|
get: () => selectedProviderId.value ?? '',
|
||||||
set: (val: string) => {
|
set: (val: string) => {
|
||||||
if (val && val !== selectedProviderId.value) {
|
if (val && val !== selectedProviderId.value) {
|
||||||
selectProvider(val)
|
void selectProvider(val, { cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1363,9 +1368,9 @@ function normalizePresetName(value: unknown): string {
|
|||||||
return String(value ?? '').trim().toLowerCase()
|
return String(value ?? '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSchedulingPresetMetas(): Promise<void> {
|
async function loadSchedulingPresetMetas(options: { cacheTtlMs?: number } = {}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metas = await getPoolSchedulingPresets()
|
const metas = await getPoolSchedulingPresets({ cacheTtlMs: options.cacheTtlMs ?? 0 })
|
||||||
const next: Record<string, string> = {}
|
const next: Record<string, string> = {}
|
||||||
for (const meta of metas as PoolPresetMeta[]) {
|
for (const meta of metas as PoolPresetMeta[]) {
|
||||||
const name = normalizePresetName(meta.name)
|
const name = normalizePresetName(meta.name)
|
||||||
@@ -1492,6 +1497,7 @@ async function selectProvider(
|
|||||||
preserveSearch?: boolean
|
preserveSearch?: boolean
|
||||||
preserveStatus?: boolean
|
preserveStatus?: boolean
|
||||||
preservePagination?: boolean
|
preservePagination?: boolean
|
||||||
|
cacheTtlMs?: number
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const requestId = ++selectProviderRequestId
|
const requestId = ++selectProviderRequestId
|
||||||
@@ -1522,7 +1528,7 @@ async function selectProvider(
|
|||||||
keysSearchDebounceTimer = null
|
keysSearchDebounceTimer = null
|
||||||
}
|
}
|
||||||
resetKeyPage(currentPage.value, pageSize.value)
|
resetKeyPage(currentPage.value, pageSize.value)
|
||||||
const keysTask = loadKeys()
|
const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
|
||||||
// Provider summary is non-blocking for key list rendering.
|
// Provider summary is non-blocking for key list rendering.
|
||||||
void loadProviderData(id)
|
void loadProviderData(id)
|
||||||
await keysTask
|
await keysTask
|
||||||
@@ -1620,6 +1626,7 @@ watch(
|
|||||||
preserveSearch: true,
|
preserveSearch: true,
|
||||||
preserveStatus: true,
|
preserveStatus: true,
|
||||||
preservePagination: true,
|
preservePagination: true,
|
||||||
|
cacheTtlMs: POOL_KEYS_CACHE_TTL_MS,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -1790,7 +1797,7 @@ async function refreshCurrentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys(options: { cacheTtlMs?: number } = {}) {
|
||||||
if (!selectedProviderId.value) return
|
if (!selectedProviderId.value) return
|
||||||
const requestId = ++keysRequestId
|
const requestId = ++keysRequestId
|
||||||
const providerId = selectedProviderId.value
|
const providerId = selectedProviderId.value
|
||||||
@@ -1805,6 +1812,8 @@ async function loadKeys() {
|
|||||||
page_size: pageSizeValue,
|
page_size: pageSizeValue,
|
||||||
search,
|
search,
|
||||||
status,
|
status,
|
||||||
|
}, {
|
||||||
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
})
|
})
|
||||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||||
const resolvedPage = resolvePoolManagementPageAfterLoad({
|
const resolvedPage = resolvePoolManagementPageAfterLoad({
|
||||||
@@ -1829,13 +1838,13 @@ async function loadKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([currentPage, pageSize], () => {
|
watch([currentPage, pageSize], () => {
|
||||||
void loadKeys()
|
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(statusFilter, () => {
|
watch(statusFilter, () => {
|
||||||
if (suppressFiltersWatch) return
|
if (suppressFiltersWatch) return
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
void loadKeys()
|
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(searchQuery, () => {
|
watch(searchQuery, () => {
|
||||||
@@ -1846,7 +1855,7 @@ watch(searchQuery, () => {
|
|||||||
}
|
}
|
||||||
keysSearchDebounceTimer = window.setTimeout(() => {
|
keysSearchDebounceTimer = window.setTimeout(() => {
|
||||||
keysSearchDebounceTimer = null
|
keysSearchDebounceTimer = null
|
||||||
void loadKeys()
|
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
|
||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3035,8 +3044,8 @@ function formatRelativeTime(isoStr: string): string {
|
|||||||
// --- Init ---
|
// --- Init ---
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startCountdownTimer()
|
startCountdownTimer()
|
||||||
void loadSchedulingPresetMetas()
|
void loadSchedulingPresetMetas({ cacheTtlMs: POOL_SCHEDULING_PRESETS_CACHE_TTL_MS })
|
||||||
void loadOverview()
|
void loadOverview({ cacheTtlMs: POOL_OVERVIEW_CACHE_TTL_MS })
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ let deletePollAbort: AbortController | null = null
|
|||||||
const DELETE_POLL_INTERVAL_MS = 2000
|
const DELETE_POLL_INTERVAL_MS = 2000
|
||||||
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
|
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
|
||||||
const DELETE_POLL_MAX_FAILURES = 3
|
const DELETE_POLL_MAX_FAILURES = 3
|
||||||
|
const PROVIDER_SUMMARY_CACHE_TTL_MS = 10 * 1000
|
||||||
|
const PROVIDER_PRIORITY_MODE_CACHE_TTL_MS = 30 * 1000
|
||||||
|
const PROVIDER_MODEL_FILTER_CACHE_TTL_MS = 10 * 1000
|
||||||
|
|
||||||
async function pollProviderDeleteTask(providerId: string, taskId: string) {
|
async function pollProviderDeleteTask(providerId: string, taskId: string) {
|
||||||
deletePollAbort?.abort()
|
deletePollAbort?.abort()
|
||||||
@@ -533,9 +536,11 @@ const maxProviderPriority = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 加载优先级模式
|
// 加载优先级模式
|
||||||
async function loadPriorityMode() {
|
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.getSystemConfig('provider_priority_mode')
|
const response = await adminApi.getSystemConfig('provider_priority_mode', {
|
||||||
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
|
})
|
||||||
if (response.value) {
|
if (response.value) {
|
||||||
priorityMode.value = response.value as 'provider' | 'global_key'
|
priorityMode.value = response.value as 'provider' | 'global_key'
|
||||||
}
|
}
|
||||||
@@ -545,9 +550,12 @@ async function loadPriorityMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 加载全局模型列表(用于模型筛选下拉)
|
// 加载全局模型列表(用于模型筛选下拉)
|
||||||
async function loadGlobalModelList() {
|
async function loadGlobalModelList(options: { cacheTtlMs?: number } = {}) {
|
||||||
try {
|
try {
|
||||||
const response = await getGlobalModels({ is_active: true, limit: 1000 })
|
const response = await getGlobalModels(
|
||||||
|
{ is_active: true, limit: 1000 },
|
||||||
|
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
|
||||||
|
)
|
||||||
globalModels.value = response.models.map(m => ({ id: m.id, name: m.name }))
|
globalModels.value = response.models.map(m => ({ id: m.id, name: m.name }))
|
||||||
} catch {
|
} catch {
|
||||||
globalModels.value = []
|
globalModels.value = []
|
||||||
@@ -555,11 +563,13 @@ async function loadGlobalModelList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 加载提供商列表(服务端分页)
|
// 加载提供商列表(服务端分页)
|
||||||
async function loadProviders() {
|
async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
||||||
const requestId = ++providersRequestId
|
const requestId = ++providersRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getProvidersSummary(queryParams.value)
|
const response = await getProvidersSummary(queryParams.value, {
|
||||||
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
|
})
|
||||||
if (requestId !== providersRequestId) return
|
if (requestId !== providersRequestId) return
|
||||||
providers.value = response.items
|
providers.value = response.items
|
||||||
total.value = response.total
|
total.value = response.total
|
||||||
@@ -587,9 +597,11 @@ watch(queryParams, (newParams, oldParams) => {
|
|||||||
newParams.api_format === oldParams?.api_format &&
|
newParams.api_format === oldParams?.api_format &&
|
||||||
newParams.model_id === oldParams?.model_id
|
newParams.model_id === oldParams?.model_id
|
||||||
if (isSearchOnly) {
|
if (isSearchOnly) {
|
||||||
debounceTimer = setTimeout(loadProviders, 300)
|
debounceTimer = setTimeout(() => {
|
||||||
|
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
|
||||||
|
}, 300)
|
||||||
} else {
|
} else {
|
||||||
loadProviders()
|
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
@@ -658,7 +670,7 @@ function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
|
|||||||
// 扩展操作配置保存回调
|
// 扩展操作配置保存回调
|
||||||
function handleOpsConfigSaved() {
|
function handleOpsConfigSaved() {
|
||||||
opsConfigDialogOpen.value = false
|
opsConfigDialogOpen.value = false
|
||||||
loadProviders()
|
void loadProviders()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理提供商编辑完成
|
// 处理提供商编辑完成
|
||||||
@@ -680,7 +692,7 @@ async function handlePrioritySaved() {
|
|||||||
|
|
||||||
// 处理提供商添加
|
// 处理提供商添加
|
||||||
function handleProviderAdded() {
|
function handleProviderAdded() {
|
||||||
loadProviders()
|
void loadProviders()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除提供商
|
// 删除提供商
|
||||||
@@ -716,7 +728,7 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
|||||||
|
|
||||||
showSuccess('提供商已删除')
|
showSuccess('提供商已删除')
|
||||||
providerDeleteProgress.value = null
|
providerDeleteProgress.value = null
|
||||||
loadProviders()
|
void loadProviders()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
providerDeleteProgress.value = null
|
providerDeleteProgress.value = null
|
||||||
showError(parseApiError(err, '删除提供商失败'), '错误')
|
showError(parseApiError(err, '删除提供商失败'), '错误')
|
||||||
@@ -753,10 +765,10 @@ function handleGlobalClick(event: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProviders()
|
void loadProviders({ cacheTtlMs: PROVIDER_SUMMARY_CACHE_TTL_MS })
|
||||||
loadPriorityMode()
|
void loadPriorityMode({ cacheTtlMs: PROVIDER_PRIORITY_MODE_CACHE_TTL_MS })
|
||||||
loadGlobalModelList()
|
void loadGlobalModelList({ cacheTtlMs: PROVIDER_MODEL_FILTER_CACHE_TTL_MS })
|
||||||
loadArchitectureSchemas()
|
void loadArchitectureSchemas()
|
||||||
document.addEventListener('click', handleGlobalClick, true)
|
document.addEventListener('click', handleGlobalClick, true)
|
||||||
// 每秒更新一次倒计时
|
// 每秒更新一次倒计时
|
||||||
startTick()
|
startTick()
|
||||||
|
|||||||
@@ -1128,6 +1128,9 @@ const filterStatus = ref('all')
|
|||||||
|
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
const USERS_PAGE_CACHE_TTL_MS = 10 * 1000
|
||||||
|
const USER_WALLETS_CACHE_TTL_MS = 10 * 1000
|
||||||
|
let userWalletsRequestId = 0
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
let filtered = [...usersStore.users]
|
let filtered = [...usersStore.users]
|
||||||
@@ -1173,31 +1176,38 @@ watch([searchQuery, filterRole, filterStatus], () => {
|
|||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
await refreshUsers()
|
void refreshUsers({ preferCache: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshUsers() {
|
async function refreshUsers(options: { preferCache?: boolean } = {}) {
|
||||||
await Promise.all([
|
const cacheTtlMs = options.preferCache ? USERS_PAGE_CACHE_TTL_MS : 0
|
||||||
usersStore.fetchUsers(),
|
await usersStore.fetchUsers({ cacheTtlMs })
|
||||||
loadUserWallets()
|
void loadUserWallets({
|
||||||
])
|
cacheTtlMs: options.preferCache ? USER_WALLETS_CACHE_TTL_MS : 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserWallets() {
|
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
|
||||||
|
const requestId = ++userWalletsRequestId
|
||||||
try {
|
try {
|
||||||
const wallets = await adminWalletApi.listAllWallets()
|
const wallets = await adminWalletApi.listAllWallets(
|
||||||
|
{ owner_type: 'user' },
|
||||||
|
{ cacheTtlMs: options.cacheTtlMs ?? 0 },
|
||||||
|
)
|
||||||
|
if (requestId !== userWalletsRequestId) return
|
||||||
userWalletMap.value = wallets
|
userWalletMap.value = wallets
|
||||||
.filter((wallet) => wallet.owner_type === 'user' && !!wallet.user_id)
|
.filter((wallet) => !!wallet.user_id)
|
||||||
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
||||||
acc[wallet.user_id as string] = wallet
|
acc[wallet.user_id as string] = wallet
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== userWalletsRequestId) return
|
||||||
log.error('加载用户钱包失败:', err)
|
log.error('加载用户钱包失败:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,10 @@
|
|||||||
:has-error="heatmapError"
|
:has-error="heatmapError"
|
||||||
/>
|
/>
|
||||||
<IntervalTimelineCard
|
<IntervalTimelineCard
|
||||||
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
|
:title="intervalTimelineTitle"
|
||||||
:is-admin="isAdminPage"
|
:is-admin="isAdminPage"
|
||||||
:hours="24"
|
:hours="intervalTimelineHours"
|
||||||
|
:refresh-interval-ms="30000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
@update:auto-refresh="handleAutoRefreshChange"
|
@update:auto-refresh="handleAutoRefreshChange"
|
||||||
@refresh="refreshData"
|
@refresh="refreshData"
|
||||||
|
@prefetch-detail="prefetchRequestDetail"
|
||||||
@show-detail="showRequestDetail"
|
@show-detail="showRequestDetail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { usageApi } from '@/api/usage'
|
import { usageApi } from '@/api/usage'
|
||||||
import { usersApi } from '@/api/users'
|
import { usersApi } from '@/api/users'
|
||||||
import { meApi } from '@/api/me'
|
import { meApi } from '@/api/me'
|
||||||
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
|
import { PanelTopClose, PanelTopOpen } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
UsageModelTable,
|
UsageModelTable,
|
||||||
@@ -171,6 +174,44 @@ const currentPage = ref(1)
|
|||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const pageSizeOptions = [10, 20, 50, 100]
|
const pageSizeOptions = [10, 20, 50, 100]
|
||||||
|
|
||||||
|
function clampIntervalTimelineHours(hours: number): number {
|
||||||
|
return Math.min(720, Math.max(1, Math.ceil(hours)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervalTimelineHours(dateRange: DateRangeParams): number {
|
||||||
|
switch (dateRange.preset) {
|
||||||
|
case 'yesterday':
|
||||||
|
return 48
|
||||||
|
case 'last7days':
|
||||||
|
return 24 * 7
|
||||||
|
case 'last30days':
|
||||||
|
return 24 * 30
|
||||||
|
case 'last90days':
|
||||||
|
return 24 * 30
|
||||||
|
case 'today':
|
||||||
|
return 24
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange.start_date && dateRange.end_date) {
|
||||||
|
const start = new Date(`${dateRange.start_date}T00:00:00`)
|
||||||
|
const end = new Date(`${dateRange.end_date}T23:59:59`)
|
||||||
|
const diffMs = end.getTime() - start.getTime()
|
||||||
|
if (!Number.isNaN(diffMs) && diffMs >= 0) {
|
||||||
|
return clampIntervalTimelineHours(diffMs / (1000 * 60 * 60))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 24
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIntervalTimelineWindow(hours: number): string {
|
||||||
|
if (hours === 24) return '最近24小时'
|
||||||
|
if (hours % 24 === 0) return `最近${hours / 24}天`
|
||||||
|
return `最近${hours}小时`
|
||||||
|
}
|
||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
const filterSearch = ref('')
|
const filterSearch = ref('')
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
@@ -200,6 +241,11 @@ const {
|
|||||||
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
const activityHeatmapData = ref<ActivityHeatmap | null>(null)
|
||||||
const isLoadingHeatmap = ref(false)
|
const isLoadingHeatmap = ref(false)
|
||||||
const heatmapError = ref(false)
|
const heatmapError = ref(false)
|
||||||
|
const intervalTimelineHours = computed(() => getIntervalTimelineHours(timeRange.value))
|
||||||
|
const intervalTimelineTitle = computed(() => {
|
||||||
|
const baseTitle = isAdminPage.value ? '请求间隔时间线' : '我的请求间隔'
|
||||||
|
return `${baseTitle}(${formatIntervalTimelineWindow(intervalTimelineHours.value)})`
|
||||||
|
})
|
||||||
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
|
const ADMIN_ANALYTICS_REFRESH_INTERVAL = 60000
|
||||||
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
|
let adminAnalyticsRefreshInFlight: Promise<void> | null = null
|
||||||
let lastAdminAnalyticsRefreshAt = 0
|
let lastAdminAnalyticsRefreshAt = 0
|
||||||
@@ -254,13 +300,6 @@ async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
|
|||||||
warning('统计数据加载失败,请刷新重试')
|
warning('统计数据加载失败,请刷新重试')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await loadHeatmapData()
|
|
||||||
hasSuccessfulRefresh = true
|
|
||||||
} catch (error) {
|
|
||||||
log.error('加载热力图数据失败:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSuccessfulRefresh) {
|
if (hasSuccessfulRefresh) {
|
||||||
lastAdminAnalyticsRefreshAt = Date.now()
|
lastAdminAnalyticsRefreshAt = Date.now()
|
||||||
}
|
}
|
||||||
@@ -632,6 +671,7 @@ onMounted(async () => {
|
|||||||
)
|
)
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await refreshAdminAnalytics({ force: true })
|
await refreshAdminAnalytics({ force: true })
|
||||||
|
await loadHeatmapData()
|
||||||
await loadAdminUsers()
|
await loadAdminUsers()
|
||||||
})()
|
})()
|
||||||
} else {
|
} else {
|
||||||
@@ -769,6 +809,7 @@ async function refreshData() {
|
|||||||
getCurrentFilters(),
|
getCurrentFilters(),
|
||||||
timeRange.value
|
timeRange.value
|
||||||
)
|
)
|
||||||
|
// 热力图反映长期活跃分布,不跟随自动刷新链路一起重载。
|
||||||
void refreshAdminAnalytics()
|
void refreshAdminAnalytics()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -791,6 +832,13 @@ function showRequestDetail(id: string) {
|
|||||||
detailModalOpen.value = true
|
detailModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefetchRequestDetail(id: string) {
|
||||||
|
if (!isAdminPage.value) return
|
||||||
|
void dashboardApi.prefetchRequestDetail(id).catch(error => {
|
||||||
|
log.debug('预取请求详情失败', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user