Merge remote-tracking branch 'origin/pr/587'

This commit is contained in:
fawney19
2026-05-28 13:56:04 +08:00
24 changed files with 1914 additions and 17 deletions
@@ -20,6 +20,7 @@ pub(crate) fn mount_public_support_routes(router: Router<AppState>) -> Router<Ap
.route("/api/public/stats", get(proxy_request)) .route("/api/public/stats", get(proxy_request))
.route("/api/public/global-models", get(proxy_request)) .route("/api/public/global-models", get(proxy_request))
.route("/api/public/health/api-formats", get(proxy_request)) .route("/api/public/health/api-formats", get(proxy_request))
.route("/api/public/health/models", get(proxy_request))
.route("/api/modules/auth-status", get(proxy_request)) .route("/api/modules/auth-status", get(proxy_request))
.route("/api/capabilities", get(proxy_request)) .route("/api/capabilities", get(proxy_request))
.route("/api/capabilities/user-configurable", get(proxy_request)) .route("/api/capabilities/user-configurable", get(proxy_request))
+1
View File
@@ -93,6 +93,7 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[
"/api/public/stats", "/api/public/stats",
"/api/public/global-models", "/api/public/global-models",
"/api/public/health/api-formats", "/api/public/health/api-formats",
"/api/public/health/models",
"/api/oauth/providers", "/api/oauth/providers",
"/api/oauth/{provider_type}/authorize", "/api/oauth/{provider_type}/authorize",
"/api/oauth/{provider_type}/callback", "/api/oauth/{provider_type}/callback",
@@ -62,6 +62,25 @@ pub(super) fn classify_admin_endpoints_family_route(
"admin:endpoints_health", "admin:endpoints_health",
false, false,
)) ))
} else if method == http::Method::GET && normalized_path == "/api/admin/endpoints/health/models"
{
Some(classified(
"admin_proxy",
"endpoints_health",
"health_models",
"admin:endpoints_health",
false,
))
} else if method == http::Method::GET
&& normalized_path == "/api/admin/endpoints/health/providers"
{
Some(classified(
"admin_proxy",
"endpoints_health",
"health_providers",
"admin:endpoints_health",
false,
))
} else if method == http::Method::GET } else if method == http::Method::GET
&& normalized_path.starts_with("/api/admin/endpoints/rpm/key/") && normalized_path.starts_with("/api/admin/endpoints/rpm/key/")
{ {
@@ -156,6 +156,7 @@ pub(super) fn classify_public_support_route(
| "/api/public/stats" | "/api/public/stats"
| "/api/public/global-models" | "/api/public/global-models"
| "/api/public/health/api-formats" | "/api/public/health/api-formats"
| "/api/public/health/models"
) )
{ {
let route_kind = match normalized_path { let route_kind = match normalized_path {
@@ -166,6 +167,7 @@ pub(super) fn classify_public_support_route(
"/api/public/stats" => "stats", "/api/public/stats" => "stats",
"/api/public/global-models" => "global_models", "/api/public/global-models" => "global_models",
"/api/public/health/api-formats" => "health_api_formats", "/api/public/health/api-formats" => "health_api_formats",
"/api/public/health/models" => "health_models",
_ => "site_info", _ => "site_info",
}; };
Some(classified( Some(classified(
@@ -102,6 +102,44 @@ fn classifies_admin_endpoint_health_status_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate()); assert!(!decision.is_execution_runtime_candidate());
} }
#[test]
fn classifies_admin_endpoint_health_models_as_admin_proxy_route() {
let headers = headers(&[]);
let uri: Uri = "/api/admin/endpoints/health/models?lookback_hours=12"
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("endpoints_health"));
assert_eq!(decision.route_kind.as_deref(), Some("health_models"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("admin:endpoints_health")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_endpoint_health_providers_as_admin_proxy_route() {
let headers = headers(&[]);
let uri: Uri = "/api/admin/endpoints/health/providers?lookback_hours=12"
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("endpoints_health"));
assert_eq!(decision.route_kind.as_deref(), Some("health_providers"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("admin:endpoints_health")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test] #[test]
fn classifies_admin_endpoint_key_rpm_as_admin_proxy_route() { fn classifies_admin_endpoint_key_rpm_as_admin_proxy_route() {
let headers = headers(&[]); let headers = headers(&[]);
@@ -721,6 +721,25 @@ fn classifies_public_catalog_health_api_formats_as_public_support_route() {
assert!(!decision.is_execution_runtime_candidate()); assert!(!decision.is_execution_runtime_candidate());
} }
#[test]
fn classifies_public_catalog_health_models_as_public_support_route() {
let headers = headers(&[]);
let uri: Uri = "/api/public/health/models?lookback_hours=12"
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("public_catalog"));
assert_eq!(decision.route_kind.as_deref(), Some("health_models"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("public:catalog")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test] #[test]
fn classifies_auth_registration_settings_as_public_support_route() { fn classifies_auth_registration_settings_as_public_support_route() {
let headers = headers(&[]); let headers = headers(&[]);
@@ -1,7 +1,7 @@
use super::extractors::{admin_health_key_id, admin_recover_key_id}; use super::extractors::{admin_health_key_id, admin_recover_key_id};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value; use crate::handlers::admin::shared::query_param_value;
use crate::handlers::public::ApiFormatHealthMonitorOptions; use crate::handlers::public::{ApiFormatHealthMonitorOptions, ModelHealthMonitorOptions};
use crate::GatewayError; use crate::GatewayError;
use axum::{ use axum::{
body::Body, body::Body,
@@ -157,6 +157,80 @@ pub(super) async fn maybe_build_local_admin_endpoints_health_response(
return Ok(Some(Json(payload).into_response())); return Ok(Some(Json(payload).into_response()));
} }
if decision.route_family.as_deref() == Some("endpoints_health")
&& decision.route_kind.as_deref() == Some("health_models")
&& request_context.path() == "/api/admin/endpoints/health/models"
{
if !state.has_usage_data_reader() {
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
}
let lookback_hours = query_param_value(request_context.query_string(), "lookback_hours")
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| (1..=72).contains(value))
.unwrap_or(6);
let model_limit = query_param_value(request_context.query_string(), "model_limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=50).contains(value))
.unwrap_or(12);
let per_model_limit = query_param_value(request_context.query_string(), "per_model_limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (10..=200).contains(value))
.unwrap_or(60);
let Some(payload) = state
.build_model_health_monitor_payload(
lookback_hours,
model_limit,
per_model_limit,
ModelHealthMonitorOptions {
include_provider_count: true,
},
)
.await
else {
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
};
return Ok(Some(Json(payload).into_response()));
}
if decision.route_family.as_deref() == Some("endpoints_health")
&& decision.route_kind.as_deref() == Some("health_providers")
&& request_context.path() == "/api/admin/endpoints/health/providers"
{
if !state.has_provider_catalog_data_reader() || !state.has_usage_data_reader() {
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
}
let lookback_hours = query_param_value(request_context.query_string(), "lookback_hours")
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| (1..=72).contains(value))
.unwrap_or(6);
let provider_limit = query_param_value(request_context.query_string(), "provider_limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=100).contains(value))
.unwrap_or(50);
let per_provider_model_limit =
query_param_value(request_context.query_string(), "per_provider_model_limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=50).contains(value))
.unwrap_or(12);
let per_model_event_limit =
query_param_value(request_context.query_string(), "per_model_limit")
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (10..=200).contains(value))
.unwrap_or(100);
let Some(payload) = state
.build_provider_health_monitor_payload(
lookback_hours,
provider_limit,
per_provider_model_limit,
per_model_event_limit,
)
.await
else {
return Ok(Some(build_admin_endpoint_health_data_unavailable_response()));
};
return Ok(Some(Json(payload).into_response()));
}
if decision.route_family.as_deref() == Some("endpoints_health") if decision.route_family.as_deref() == Some("endpoints_health")
&& decision.route_kind.as_deref() == Some("health_status") && decision.route_kind.as_deref() == Some("health_status")
&& request_context.path() == "/api/admin/endpoints/health/status" && request_context.path() == "/api/admin/endpoints/health/status"
@@ -242,6 +242,40 @@ impl<'a> AdminAppState<'a> {
.await .await
} }
pub(crate) async fn build_model_health_monitor_payload(
&self,
lookback_hours: u64,
model_limit: usize,
per_model_limit: usize,
options: crate::handlers::public::ModelHealthMonitorOptions,
) -> Option<serde_json::Value> {
crate::handlers::public::build_model_health_monitor_payload(
self.app,
lookback_hours,
model_limit,
per_model_limit,
options,
)
.await
}
pub(crate) async fn build_provider_health_monitor_payload(
&self,
lookback_hours: u64,
provider_limit: usize,
per_provider_model_limit: usize,
per_model_event_limit: usize,
) -> Option<serde_json::Value> {
crate::handlers::public::build_provider_health_monitor_payload(
self.app,
lookback_hours,
provider_limit,
per_provider_model_limit,
per_model_event_limit,
)
.await
}
pub(crate) async fn execute_execution_runtime_sync_plan( pub(crate) async fn execute_execution_runtime_sync_plan(
&self, &self,
trace_id: Option<&str>, trace_id: Option<&str>,
@@ -12,7 +12,13 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::global_models::{ use aether_data_contracts::repository::global_models::{
PublicCatalogModelListQuery, PublicCatalogModelSearchQuery, StoredPublicCatalogModel, PublicCatalogModelListQuery, PublicCatalogModelSearchQuery, StoredPublicCatalogModel,
}; };
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data_contracts::repository::usage::{
StoredRequestUsageAudit, StoredUsageBreakdownSummaryRow, UsageAuditListQuery,
UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
};
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};
@@ -91,6 +97,13 @@ pub(crate) struct ApiFormatHealthMonitorOptions {
pub(crate) include_key_count: bool, pub(crate) include_key_count: bool,
} }
#[derive(Clone, Copy)]
pub(crate) struct ModelHealthMonitorOptions {
pub(crate) include_provider_count: bool,
}
const MODEL_HEALTH_TIMELINE_SEGMENTS: u32 = 60;
pub(crate) fn provider_key_api_formats(key: &StoredProviderCatalogKey) -> Vec<String> { pub(crate) fn provider_key_api_formats(key: &StoredProviderCatalogKey) -> Vec<String> {
provider_key_configured_api_formats(key) provider_key_configured_api_formats(key)
} }
@@ -540,6 +553,507 @@ pub(crate) async fn build_api_format_health_monitor_payload(
})) }))
} }
pub(crate) async fn build_model_health_monitor_payload(
state: &AppState,
lookback_hours: u64,
model_limit: usize,
per_model_limit: usize,
options: ModelHealthMonitorOptions,
) -> Option<serde_json::Value> {
if !state.has_usage_data_reader() {
return None;
}
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let since_unix_secs = now_unix_secs.saturating_sub(lookback_hours * 3600);
let breakdown = state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs: since_unix_secs,
created_until_unix_secs: now_unix_secs,
user_id: None,
provider_name: None,
group_by: UsageBreakdownGroupBy::Model,
})
.await
.ok()
.unwrap_or_default();
let selected_models = breakdown
.into_iter()
.filter(|row| !row.group_key.trim().is_empty())
.take(model_limit)
.collect::<Vec<_>>();
let mut models = Vec::with_capacity(selected_models.len());
for row in selected_models {
let events = state
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(since_unix_secs),
created_until_unix_secs: Some(now_unix_secs),
model: Some(row.group_key.clone()),
limit: Some(per_model_limit),
newest_first: true,
..UsageAuditListQuery::default()
})
.await
.ok()
.unwrap_or_default();
let (timeline, time_range_start, time_range_end) = build_model_health_timeline(
&events,
since_unix_secs,
now_unix_secs,
MODEL_HEALTH_TIMELINE_SEGMENTS,
);
let provider_count = model_health_provider_count(&events);
let first_byte_average = model_health_average_first_byte_ms(&events);
let last_event_at = events
.first()
.and_then(|item| unix_secs_to_rfc3339(item.created_at_unix_ms));
let event_payload = events
.iter()
.rev()
.map(model_health_event_payload)
.collect::<Vec<_>>();
let total_attempts = row.request_count;
let success_count = row.success_count.min(total_attempts);
let failed_count = total_attempts.saturating_sub(success_count);
let success_rate = if total_attempts > 0 {
success_count as f64 / total_attempts as f64
} else {
1.0
};
let avg_latency_ms = model_health_average_latency_ms(&row);
let model_name = row.group_key.clone();
let mut model_payload = json!({
"model": model_name,
"display_name": model_health_display_name(&row.group_key),
"total_attempts": total_attempts,
"success_count": success_count,
"failed_count": failed_count,
"success_rate": success_rate,
"avg_latency_ms": avg_latency_ms,
"avg_first_byte_ms": first_byte_average,
"last_event_at": last_event_at,
"events": event_payload,
"timeline": timeline,
"time_range_start": unix_secs_to_rfc3339(time_range_start),
"time_range_end": unix_secs_to_rfc3339(time_range_end),
});
if options.include_provider_count {
model_payload["provider_count"] = json!(provider_count);
}
models.push(model_payload);
}
Some(json!({
"generated_at": unix_secs_to_rfc3339(now_unix_secs),
"models": models,
}))
}
pub(crate) async fn build_provider_health_monitor_payload(
state: &AppState,
lookback_hours: u64,
provider_limit: usize,
per_provider_model_limit: usize,
per_model_event_limit: usize,
) -> Option<serde_json::Value> {
if !state.has_provider_catalog_data_reader() || !state.has_usage_data_reader() {
return None;
}
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let since_unix_secs = now_unix_secs.saturating_sub(lookback_hours * 3600);
let providers = state
.list_provider_catalog_providers(true)
.await
.ok()
.unwrap_or_default()
.into_iter()
.filter(|provider| provider.is_active)
.take(provider_limit)
.collect::<Vec<_>>();
let provider_breakdown = state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs: since_unix_secs,
created_until_unix_secs: now_unix_secs,
user_id: None,
provider_name: None,
group_by: UsageBreakdownGroupBy::Provider,
})
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(|row| (row.group_key.clone(), row))
.collect::<BTreeMap<_, _>>();
let mut payload = Vec::with_capacity(providers.len());
for provider in providers {
let provider_stats = provider_breakdown.get(&provider.name);
payload.push(
build_provider_health_payload(
state,
provider,
provider_stats,
since_unix_secs,
now_unix_secs,
per_provider_model_limit,
per_model_event_limit,
)
.await,
);
}
payload.sort_by(|left, right| {
let left_rank = provider_health_sort_rank(left);
let right_rank = provider_health_sort_rank(right);
left_rank.cmp(&right_rank).then_with(|| {
left.get("provider_name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.cmp(
right
.get("provider_name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default(),
)
})
});
Some(json!({
"generated_at": unix_secs_to_rfc3339(now_unix_secs),
"providers": payload,
}))
}
async fn build_provider_health_payload(
state: &AppState,
provider: StoredProviderCatalogProvider,
provider_stats: Option<&StoredUsageBreakdownSummaryRow>,
since_unix_secs: u64,
now_unix_secs: u64,
per_provider_model_limit: usize,
per_model_event_limit: usize,
) -> serde_json::Value {
let model_breakdown = state
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
created_from_unix_secs: since_unix_secs,
created_until_unix_secs: now_unix_secs,
user_id: None,
provider_name: Some(provider.name.clone()),
group_by: UsageBreakdownGroupBy::Model,
})
.await
.ok()
.unwrap_or_default();
let provider_event_limit = per_model_event_limit
.saturating_mul(per_provider_model_limit.max(1))
.max(per_model_event_limit);
let provider_events = state
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(since_unix_secs),
created_until_unix_secs: Some(now_unix_secs),
provider_name: Some(provider.name.clone()),
limit: Some(provider_event_limit),
newest_first: true,
..UsageAuditListQuery::default()
})
.await
.ok()
.unwrap_or_default();
let mut models = Vec::new();
for row in model_breakdown
.iter()
.filter(|row| !row.group_key.trim().is_empty())
.take(per_provider_model_limit)
{
let events = state
.list_usage_audits(&UsageAuditListQuery {
created_from_unix_secs: Some(since_unix_secs),
created_until_unix_secs: Some(now_unix_secs),
provider_name: Some(provider.name.clone()),
model: Some(row.group_key.clone()),
limit: Some(per_model_event_limit),
newest_first: true,
..UsageAuditListQuery::default()
})
.await
.ok()
.unwrap_or_default();
models.push(model_health_payload_from_row(
row,
&events,
since_unix_secs,
now_unix_secs,
None,
));
}
let (total_attempts, success_count, failed_count, success_rate, avg_latency_ms) =
if let Some(row) = provider_stats {
let total_attempts = row.request_count;
let success_count = row.success_count.min(total_attempts);
let failed_count = total_attempts.saturating_sub(success_count);
let success_rate = if total_attempts > 0 {
success_count as f64 / total_attempts as f64
} else {
1.0
};
(
total_attempts,
success_count,
failed_count,
success_rate,
model_health_average_latency_ms(row),
)
} else {
(0, 0, 0, 1.0, None)
};
let (timeline, time_range_start, time_range_end) = build_model_health_timeline(
&provider_events,
since_unix_secs,
now_unix_secs,
MODEL_HEALTH_TIMELINE_SEGMENTS,
);
let last_event_at = provider_events
.iter()
.max_by_key(|event| event.created_at_unix_ms)
.and_then(|event| unix_secs_to_rfc3339(event.created_at_unix_ms));
json!({
"provider_id": provider.id,
"provider_name": provider.name,
"provider_type": provider.provider_type,
"is_active": provider.is_active,
"total_attempts": total_attempts,
"success_count": success_count,
"failed_count": failed_count,
"success_rate": success_rate,
"avg_latency_ms": avg_latency_ms,
"avg_first_byte_ms": model_health_average_first_byte_ms(&provider_events),
"model_count": model_breakdown.len(),
"last_event_at": last_event_at,
"timeline": timeline,
"time_range_start": unix_secs_to_rfc3339(time_range_start),
"time_range_end": unix_secs_to_rfc3339(time_range_end),
"models": models,
})
}
fn provider_health_sort_rank(provider: &serde_json::Value) -> u8 {
let total_attempts = provider
.get("total_attempts")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
if total_attempts == 0 {
return 3;
}
let success_rate = provider
.get("success_rate")
.and_then(serde_json::Value::as_f64)
.unwrap_or(1.0);
if success_rate < 0.8 {
0
} else if success_rate < 0.95 {
1
} else {
2
}
}
fn model_health_payload_from_row(
row: &StoredUsageBreakdownSummaryRow,
events: &[StoredRequestUsageAudit],
since_unix_secs: u64,
now_unix_secs: u64,
provider_count: Option<usize>,
) -> serde_json::Value {
let (timeline, time_range_start, time_range_end) = build_model_health_timeline(
events,
since_unix_secs,
now_unix_secs,
MODEL_HEALTH_TIMELINE_SEGMENTS,
);
let last_event_at = events
.first()
.and_then(|item| unix_secs_to_rfc3339(item.created_at_unix_ms));
let event_payload = events
.iter()
.rev()
.map(model_health_event_payload)
.collect::<Vec<_>>();
let total_attempts = row.request_count;
let success_count = row.success_count.min(total_attempts);
let failed_count = total_attempts.saturating_sub(success_count);
let success_rate = if total_attempts > 0 {
success_count as f64 / total_attempts as f64
} else {
1.0
};
let mut model_payload = json!({
"model": row.group_key.clone(),
"display_name": model_health_display_name(&row.group_key),
"total_attempts": total_attempts,
"success_count": success_count,
"failed_count": failed_count,
"success_rate": success_rate,
"avg_latency_ms": model_health_average_latency_ms(row),
"avg_first_byte_ms": model_health_average_first_byte_ms(events),
"last_event_at": last_event_at,
"events": event_payload,
"timeline": timeline,
"time_range_start": unix_secs_to_rfc3339(time_range_start),
"time_range_end": unix_secs_to_rfc3339(time_range_end),
});
if let Some(provider_count) = provider_count {
model_payload["provider_count"] = json!(provider_count);
}
model_payload
}
fn model_health_average_latency_ms(row: &StoredUsageBreakdownSummaryRow) -> Option<f64> {
if row.overall_response_time_samples > 0 {
return Some(row.overall_response_time_sum_ms / row.overall_response_time_samples as f64);
}
if row.response_time_samples > 0 {
return Some(row.response_time_sum_ms / row.response_time_samples as f64);
}
None
}
fn model_health_average_first_byte_ms(events: &[StoredRequestUsageAudit]) -> Option<f64> {
let mut sum = 0u64;
let mut count = 0u64;
for event in events {
if let Some(first_byte_time_ms) = event.first_byte_time_ms {
sum = sum.saturating_add(first_byte_time_ms);
count = count.saturating_add(1);
}
}
if count == 0 {
None
} else {
Some(sum as f64 / count as f64)
}
}
fn model_health_provider_count(events: &[StoredRequestUsageAudit]) -> usize {
let mut providers = BTreeSet::new();
for event in events {
if let Some(provider_id) = event.provider_id.as_deref() {
providers.insert(provider_id.to_string());
} else if !event.provider_name.trim().is_empty() {
providers.insert(event.provider_name.clone());
}
}
providers.len()
}
fn model_health_event_payload(event: &StoredRequestUsageAudit) -> serde_json::Value {
json!({
"timestamp": unix_secs_to_rfc3339(event.created_at_unix_ms),
"status": model_health_event_status(event),
"status_code": event.status_code,
"latency_ms": event.response_time_ms,
"first_byte_time_ms": event.first_byte_time_ms,
"error_type": event.error_category,
})
}
fn model_health_event_status(event: &StoredRequestUsageAudit) -> &'static str {
if model_health_event_success(event) {
"success"
} else {
"failed"
}
}
fn model_health_event_success(event: &StoredRequestUsageAudit) -> bool {
!event.status.eq_ignore_ascii_case("failed")
&& event.status_code.is_none_or(|status| status < 400)
&& event
.error_message
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
}
fn build_model_health_timeline(
events: &[StoredRequestUsageAudit],
since_unix_secs: u64,
until_unix_secs: u64,
segments: u32,
) -> (Vec<&'static str>, u64, u64) {
#[derive(Default)]
struct Bucket {
success_count: u64,
failed_count: u64,
}
let safe_range = until_unix_secs.saturating_sub(since_unix_secs).max(1);
let mut buckets = (0..segments).map(|_| Bucket::default()).collect::<Vec<_>>();
for event in events {
let timestamp = event.created_at_unix_ms;
if timestamp < since_unix_secs || timestamp > until_unix_secs {
continue;
}
let offset = timestamp.saturating_sub(since_unix_secs);
let mut segment_idx = ((offset as u128 * segments as u128) / safe_range as u128) as usize;
if segment_idx >= segments as usize {
segment_idx = segments.saturating_sub(1) as usize;
}
let bucket = &mut buckets[segment_idx];
if model_health_event_success(event) {
bucket.success_count = bucket.success_count.saturating_add(1);
} else {
bucket.failed_count = bucket.failed_count.saturating_add(1);
}
}
let timeline = buckets
.into_iter()
.map(|bucket| {
let total = bucket.success_count.saturating_add(bucket.failed_count);
if total == 0 {
return "unknown";
}
let success_rate = bucket.success_count as f64 / total as f64;
if success_rate >= 0.95 {
"healthy"
} else if success_rate >= 0.7 {
"warning"
} else {
"unhealthy"
}
})
.collect::<Vec<_>>();
(timeline, since_unix_secs, until_unix_secs)
}
fn model_health_display_name(model: &str) -> String {
model.trim().to_string()
}
pub(crate) fn build_public_health_timeline( pub(crate) fn build_public_health_timeline(
buckets_by_segment: &BTreeMap<u32, PublicHealthTimelineBucket>, buckets_by_segment: &BTreeMap<u32, PublicHealthTimelineBucket>,
segments: u32, segments: u32,
@@ -8,10 +8,12 @@ pub(crate) use self::ai_public::{
}; };
pub(crate) use self::catalog_helpers::{ pub(crate) use self::catalog_helpers::{
admin_requested_force_stream, api_format_display_name, build_api_format_health_monitor_payload, admin_requested_force_stream, api_format_display_name, build_api_format_health_monitor_payload,
build_model_health_monitor_payload, build_provider_health_monitor_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload, build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url, build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label, provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions, sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions,
ModelHealthMonitorOptions,
}; };
pub(crate) use self::system_modules_helpers::{ pub(crate) use self::system_modules_helpers::{
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload, build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,
@@ -1,9 +1,10 @@
use super::{ use super::{
build_api_format_health_monitor_payload, build_public_auth_modules_status_payload, build_api_format_health_monitor_payload, build_model_health_monitor_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload, build_public_auth_modules_status_payload, build_public_catalog_models_payload,
build_public_providers_payload, capability_detail_by_name, ldap_module_config_is_valid, build_public_catalog_search_models_payload, build_public_providers_payload,
sanitize_public_model_config_for_user, serialize_public_capability, supported_capability_names, capability_detail_by_name, ldap_module_config_is_valid, sanitize_public_model_config_for_user,
ApiFormatHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS, serialize_public_capability, supported_capability_names, ApiFormatHealthMonitorOptions,
ModelHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS,
}; };
use crate::control::GatewayPublicRequestContext; use crate::control::GatewayPublicRequestContext;
use crate::handlers::shared::{ use crate::handlers::shared::{
@@ -426,6 +427,43 @@ pub(crate) async fn maybe_build_local_public_support_response(
.await?; .await?;
return Some(Json(payload).into_response()); return Some(Json(payload).into_response());
} }
if decision.route_kind.as_deref() == Some("health_models")
&& request_context.request_path == "/api/public/health/models"
{
let lookback_hours = query_param_value(
request_context.request_query_string.as_deref(),
"lookback_hours",
)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| (1..=168).contains(value))
.unwrap_or(6);
let model_limit = query_param_value(
request_context.request_query_string.as_deref(),
"model_limit",
)
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (1..=50).contains(value))
.unwrap_or(12);
let per_model_limit = query_param_value(
request_context.request_query_string.as_deref(),
"per_model_limit",
)
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| (10..=500).contains(value))
.unwrap_or(100);
let payload = build_model_health_monitor_payload(
state,
lookback_hours,
model_limit,
per_model_limit,
ModelHealthMonitorOptions {
include_provider_count: false,
},
)
.await?;
return Some(Json(payload).into_response());
}
} }
if decision.route_family.as_deref() == Some("capabilities") { if decision.route_family.as_deref() == Some("capabilities") {
@@ -847,6 +847,7 @@ pub(super) async fn handle_users_me_usage_get(
created_from_unix_secs, created_from_unix_secs,
created_until_unix_secs, created_until_unix_secs,
user_id: Some(auth.user.id.clone()), user_id: Some(auth.user.id.clone()),
provider_name: None,
group_by: UsageBreakdownGroupBy::Model, group_by: UsageBreakdownGroupBy::Model,
}) })
.await .await
@@ -866,6 +867,7 @@ pub(super) async fn handle_users_me_usage_get(
created_from_unix_secs, created_from_unix_secs,
created_until_unix_secs, created_until_unix_secs,
user_id: Some(auth.user.id.clone()), user_id: Some(auth.user.id.clone()),
provider_name: None,
group_by: UsageBreakdownGroupBy::Provider, group_by: UsageBreakdownGroupBy::Provider,
}) })
.await .await
@@ -885,6 +887,7 @@ pub(super) async fn handle_users_me_usage_get(
created_from_unix_secs, created_from_unix_secs,
created_until_unix_secs, created_until_unix_secs,
user_id: Some(auth.user.id.clone()), user_id: Some(auth.user.id.clone()),
provider_name: None,
group_by: UsageBreakdownGroupBy::ApiFormat, group_by: UsageBreakdownGroupBy::ApiFormat,
}) })
.await .await
@@ -113,6 +113,9 @@ async fn gateway_exposes_frontdoor_manifest_without_proxying_upstream() {
assert!(owned_routes assert!(owned_routes
.iter() .iter()
.any(|value| value == "/api/public/health/api-formats")); .any(|value| value == "/api/public/health/api-formats"));
assert!(owned_routes
.iter()
.any(|value| value == "/api/public/health/models"));
assert!(owned_routes assert!(owned_routes
.iter() .iter()
.any(|value| value == "/api/modules/auth-status")); .any(|value| value == "/api/modules/auth-status"));
@@ -977,6 +977,7 @@ pub struct UsageBreakdownSummaryQuery {
pub created_from_unix_secs: u64, pub created_from_unix_secs: u64,
pub created_until_unix_secs: u64, pub created_until_unix_secs: u64,
pub user_id: Option<String>, pub user_id: Option<String>,
pub provider_name: Option<String>,
pub group_by: UsageBreakdownGroupBy, pub group_by: UsageBreakdownGroupBy,
} }
@@ -528,6 +528,11 @@ fn usage_matches_breakdown_summary_query(
return false; return false;
} }
} }
if let Some(provider_name) = query.provider_name.as_deref() {
if item.provider_name != provider_name {
return false;
}
}
match query.group_by { match query.group_by {
UsageBreakdownGroupBy::Model | UsageBreakdownGroupBy::Provider => true, UsageBreakdownGroupBy::Model | UsageBreakdownGroupBy::Provider => true,
UsageBreakdownGroupBy::ApiFormat => item.api_format.is_some(), UsageBreakdownGroupBy::ApiFormat => item.api_format.is_some(),
@@ -4822,6 +4822,12 @@ WITH filtered_usage AS (
.push("\"usage\".user_id = ") .push("\"usage\".user_id = ")
.push_bind(user_id.to_string()); .push_bind(user_id.to_string());
} }
if let Some(provider_name) = query.provider_name.as_deref() {
builder.push(if has_where { " AND " } else { " WHERE " });
builder
.push("\"usage\".provider_name = ")
.push_bind(provider_name.to_string());
}
builder.push(filtered_extra_where); builder.push(filtered_extra_where);
builder.push( builder.push(
r#" r#"
@@ -4914,6 +4920,9 @@ ORDER BY request_count DESC, group_key ASC
&self, &self,
query: &UsageBreakdownSummaryQuery, query: &UsageBreakdownSummaryQuery,
) -> Result<Vec<StoredUsageBreakdownSummaryRow>, DataLayerError> { ) -> Result<Vec<StoredUsageBreakdownSummaryRow>, DataLayerError> {
if query.provider_name.is_some() {
return self.summarize_usage_breakdown_raw(query).await;
}
let Some(user_id) = query.user_id.as_deref() else { let Some(user_id) = query.user_id.as_deref() else {
return self.summarize_usage_breakdown_raw(query).await; return self.summarize_usage_breakdown_raw(query).await;
}; };
@@ -4935,6 +4944,7 @@ ORDER BY request_count DESC, group_key ASC
created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start), created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start),
created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end), created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end),
user_id: Some(user_id.to_string()), user_id: Some(user_id.to_string()),
provider_name: None,
group_by: query.group_by, group_by: query.group_by,
}) })
.await?; .await?;
@@ -4957,6 +4967,7 @@ ORDER BY request_count DESC, group_key ASC
created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start), created_from_unix_secs: dashboard_utc_to_unix_secs(raw_start),
created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end), created_until_unix_secs: dashboard_utc_to_unix_secs(raw_end),
user_id: Some(user_id.to_string()), user_id: Some(user_id.to_string()),
provider_name: None,
group_by: query.group_by, group_by: query.group_by,
}) })
.await?; .await?;
@@ -2459,6 +2459,12 @@ FROM "usage"
"user_id", "user_id",
query.user_id.as_deref(), query.user_id.as_deref(),
); );
push_sqlite_usage_optional_text_filter(
&mut builder,
&mut has_where,
"provider_name",
query.provider_name.as_deref(),
);
if matches!(query.group_by, UsageBreakdownGroupBy::ApiFormat) { if matches!(query.group_by, UsageBreakdownGroupBy::ApiFormat) {
push_sqlite_usage_where(&mut builder, &mut has_where); push_sqlite_usage_where(&mut builder, &mut has_where);
builder.push("api_format IS NOT NULL"); builder.push("api_format IS NOT NULL");
+46 -1
View File
@@ -3,7 +3,9 @@ import type {
HealthStatus, HealthStatus,
HealthSummary, HealthSummary,
EndpointStatusMonitorResponse, EndpointStatusMonitorResponse,
PublicEndpointStatusMonitorResponse PublicEndpointStatusMonitorResponse,
ModelStatusMonitorResponse,
ProviderStatusMonitorResponse
} from './types' } from './types'
/** /**
@@ -91,3 +93,46 @@ export async function getPublicEndpointStatusMonitor(params?: {
}) })
return response.data return response.data
} }
/**
* 获取按模型聚合的健康监控时间线(管理员版,含 provider 数量)
*/
export async function getModelStatusMonitor(params?: {
lookback_hours?: number
model_limit?: number
per_model_limit?: number
}): Promise<ModelStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/models', {
params
})
return response.data
}
/**
* 获取按模型聚合的健康监控时间线(公开版,不含 provider 信息)
*/
export async function getPublicModelStatusMonitor(params?: {
lookback_hours?: number
model_limit?: number
per_model_limit?: number
}): Promise<ModelStatusMonitorResponse> {
const response = await client.get('/api/public/health/models', {
params
})
return response.data
}
/**
* 获取按提供商聚合的健康监控(管理员版,含提供商下模型明细)
*/
export async function getProviderStatusMonitor(params?: {
lookback_hours?: number
provider_limit?: number
per_provider_model_limit?: number
per_model_limit?: number
}): Promise<ProviderStatusMonitorResponse> {
const response = await client.get('/api/admin/endpoints/health/providers', {
params
})
return response.data
}
@@ -613,6 +613,61 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[] formats: PublicEndpointStatusMonitor[]
} }
export interface ModelHealthEvent {
timestamp: string
status: 'success' | 'failed'
status_code?: number | null
latency_ms?: number | null
first_byte_time_ms?: number | null
error_type?: string | null
}
export interface ModelStatusMonitor {
model: string
display_name?: string | null
total_attempts: number
success_count: number
failed_count: number
success_rate: number
avg_latency_ms?: number | null
avg_first_byte_ms?: number | null
provider_count?: number
last_event_at?: string | null
events: ModelHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface ModelStatusMonitorResponse {
generated_at: string
models: ModelStatusMonitor[]
}
export interface ProviderStatusMonitor {
provider_id: string
provider_name: string
provider_type?: string | null
is_active: boolean
total_attempts: number
success_count: number
failed_count: number
success_rate: number
avg_latency_ms?: number | null
avg_first_byte_ms?: number | null
model_count: number
last_event_at?: string | null
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
models: ModelStatusMonitor[]
}
export interface ProviderStatusMonitorResponse {
generated_at: string
providers: ProviderStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai' export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig { export interface ClaudeCodeAdvancedConfig {
@@ -6,9 +6,14 @@
<!-- 标题和筛选器 --> <!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold"> <div>
{{ title }} <h3 class="text-base font-semibold">
</h3> {{ title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
基于真实请求统计端点可用率、请求成功率与健康历史
</p>
</div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间:</Label> <Label class="text-xs text-muted-foreground">回溯时间:</Label>
<Select <Select
@@ -0,0 +1,349 @@
<template>
<Card
variant="default"
class="overflow-hidden"
>
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold">
{{ title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
基于真实请求统计模型可用率、响应延迟与首包延迟
</p>
</div>
<div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
<Select v-model="lookbackHours">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
1 小时
</SelectItem>
<SelectItem value="6">
6 小时
</SelectItem>
<SelectItem value="12">
12 小时
</SelectItem>
<SelectItem value="24">
24 小时
</SelectItem>
<SelectItem value="48">
48 小时
</SelectItem>
</SelectContent>
</Select>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
<div class="p-6">
<div
v-if="loadingMonitors"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-muted-foreground">加载中...</span>
</div>
<div
v-else-if="monitors.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Bot class="w-12 h-12 mb-3 opacity-30" />
<p>暂无模型健康监控数据</p>
<p class="text-xs mt-1">
模型尚未产生请求记录
</p>
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
>
<div
v-for="monitor in monitors"
:key="monitor.model"
class="relative overflow-hidden rounded-xl border border-border/60 bg-card/60 p-4 transition-colors hover:border-primary/50"
>
<div class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/50">
<Bot class="h-5 w-5 text-muted-foreground" />
</div>
<h4 class="min-w-0 truncate text-sm font-semibold">
{{ monitor.display_name || monitor.model }}
</h4>
</div>
<Badge
:variant="getHealthBadgeVariant(monitor)"
class="shrink-0"
>
{{ getHealthLabel(monitor) }}
</Badge>
</div>
<div class="mt-4 grid grid-cols-3 gap-2">
<div class="rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Gauge class="h-3.5 w-3.5" />
延迟
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatMs(monitor.avg_latency_ms) }}
</div>
</div>
<div class="rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Radio class="h-3.5 w-3.5" />
Ping
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatMs(monitor.avg_first_byte_ms) }}
</div>
</div>
<div class="rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Activity class="h-3.5 w-3.5" />
可用率
</div>
<div
class="mt-1 text-sm font-semibold tabular-nums"
:class="getSuccessRateClass(monitor.success_rate)"
>
{{ formatPercent(monitor.success_rate) }}
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-3 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>History (60pts)</span>
<span class="truncate normal-case tracking-normal">
{{ getModelMetaText(monitor) }}
</span>
</div>
<TooltipProvider :delay-duration="100">
<div class="mt-2 flex h-7 w-full items-center gap-px">
<Tooltip
v-for="(segment, index) in timelineSegments(monitor)"
:key="`${monitor.model}-${index}`"
>
<TooltipTrigger as-child>
<div
class="h-full flex-1 rounded-[2px] transition-all duration-150 hover:scale-y-110 hover:brightness-110"
:class="getTimelineColor(segment)"
/>
</TooltipTrigger>
<TooltipContent
side="top"
:side-offset="8"
class="max-w-xs"
>
<div class="text-xs whitespace-pre-line">
{{ buildTimelineTooltip(monitor, segment, index) }}
</div>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div class="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
<span>{{ formatTimestamp(monitor.time_range_start) }}</span>
<span>{{ formatTimestamp(monitor.time_range_end || generatedAt) }}</span>
</div>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Activity, Bot, Gauge, Loader2, Radio } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getModelStatusMonitor, getPublicModelStatusMonitor } from '@/api/endpoints/health'
import type { ModelStatusMonitor } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
const props = withDefaults(defineProps<{
title?: string
isAdmin?: boolean
showProviderInfo?: boolean
}>(), {
title: '模型健康监控',
isAdmin: false,
showProviderInfo: false
})
const { error: showError } = useToast()
const loading = ref(false)
const loadingMonitors = ref(false)
const monitors = ref<ModelStatusMonitor[]>([])
const generatedAt = ref<string | null>(null)
const lookbackHours = ref('6')
async function loadMonitors() {
loadingMonitors.value = true
try {
const params = {
lookback_hours: parseInt(lookbackHours.value),
model_limit: 12,
per_model_limit: 100
}
const data = props.isAdmin
? await getModelStatusMonitor(params)
: await getPublicModelStatusMonitor(params)
monitors.value = data.models || []
generatedAt.value = data.generated_at || null
} catch (err: unknown) {
showError(parseApiError(err, '加载模型健康监控数据失败'), '错误')
} finally {
loadingMonitors.value = false
}
}
async function refreshData() {
loading.value = true
try {
await loadMonitors()
} finally {
loading.value = false
}
}
function getHealthLabel(monitor: ModelStatusMonitor) {
if (monitor.total_attempts <= 0) return '未知'
if (monitor.success_rate >= 0.95) return '正常'
if (monitor.success_rate >= 0.8) return '波动'
return '异常'
}
function getHealthBadgeVariant(monitor: ModelStatusMonitor): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
if (monitor.total_attempts <= 0) return 'outline'
if (monitor.success_rate >= 0.95) return 'success'
if (monitor.success_rate >= 0.8) return 'warning'
return 'destructive'
}
function getSuccessRateClass(rate: number) {
if (rate >= 0.95) return 'text-green-600 dark:text-green-400'
if (rate >= 0.8) return 'text-amber-600 dark:text-amber-400'
return 'text-red-600 dark:text-red-400'
}
function getModelMetaText(monitor: ModelStatusMonitor) {
const attempts = `${formatCompactNumber(monitor.total_attempts)} 次请求`
if (props.showProviderInfo && typeof monitor.provider_count === 'number') {
return `${monitor.provider_count} 个提供商 / ${attempts}`
}
return attempts
}
function formatMs(value?: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
const absValue = Math.abs(value)
if (absValue < 1000) return `${Math.round(value)} ms`
if (absValue < 60_000) return `${formatDurationNumber(value / 1000)} s`
return `${formatDurationNumber(value / 60_000)} min`
}
function formatDurationNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: Math.abs(value) < 10 ? 2 : 1
}).format(value)
}
function formatPercent(value: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
return `${(value * 100).toFixed(2)}%`
}
function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
}
function timelineSegments(monitor: ModelStatusMonitor) {
const timeline = Array.isArray(monitor.timeline) ? monitor.timeline : []
if (timeline.length > 0) return timeline
return Array.from({ length: 60 }, () => 'unknown')
}
function getTimelineColor(status: string) {
switch (status) {
case 'healthy':
return 'bg-green-500/85 dark:bg-green-400/90'
case 'warning':
return 'bg-amber-400/85 dark:bg-amber-300/85'
case 'unhealthy':
return 'bg-red-500/85 dark:bg-red-400/90'
default:
return 'bg-gray-300 dark:bg-gray-600'
}
}
function getTimelineLabel(status: string) {
switch (status) {
case 'healthy':
return '健康'
case 'warning':
return '波动'
case 'unhealthy':
return '异常'
default:
return '无请求'
}
}
function buildTimelineTooltip(monitor: ModelStatusMonitor, status: string, index: number) {
const segmentCount = timelineSegments(monitor).length
const startMs = monitor.time_range_start ? new Date(monitor.time_range_start).getTime() : Date.now() - parseInt(lookbackHours.value) * 60 * 60 * 1000
const endMs = monitor.time_range_end ? new Date(monitor.time_range_end).getTime() : Date.now()
const interval = Math.max(endMs - startMs, 1) / segmentCount
const cellStart = new Date(startMs + index * interval).toISOString()
const cellEnd = new Date(startMs + (index + 1) * interval).toISOString()
return `${formatTimestamp(cellStart)} - ${formatTimestamp(cellEnd)}\n模型:${monitor.model}\n状态:${getTimelineLabel(status)}`
}
function formatTimestamp(timestamp?: string | null) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '未知时间'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
watch(lookbackHours, () => {
loadMonitors()
})
onMounted(() => {
refreshData()
})
</script>
@@ -0,0 +1,425 @@
<template>
<Card
variant="default"
class="overflow-hidden"
>
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold">
{{ title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
仅展示活跃提供商,展开后查看该提供商下的模型健康明细
</p>
</div>
<div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
<Select v-model="lookbackHours">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
1 小时
</SelectItem>
<SelectItem value="6">
6 小时
</SelectItem>
<SelectItem value="12">
12 小时
</SelectItem>
<SelectItem value="24">
24 小时
</SelectItem>
<SelectItem value="48">
48 小时
</SelectItem>
</SelectContent>
</Select>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
<div class="p-6">
<div
v-if="loadingMonitors"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-muted-foreground">加载中...</span>
</div>
<div
v-else-if="providers.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Server class="w-12 h-12 mb-3 opacity-30" />
<p>暂无活跃提供商健康数据</p>
<p class="text-xs mt-1">
当前没有活跃提供商或尚未产生请求记录
</p>
</div>
<div
v-else
class="space-y-3"
>
<Collapsible
v-for="provider in providers"
:key="provider.provider_id"
v-model:open="expandedProviders[provider.provider_id]"
class="overflow-hidden rounded-xl border border-border/60 bg-card/60"
>
<CollapsibleTrigger as-child>
<button
type="button"
class="flex w-full flex-col gap-4 p-4 text-left transition-colors hover:bg-muted/30 lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/50">
<Server class="h-5 w-5 text-muted-foreground" />
</div>
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<ChevronRight
class="h-4 w-4 text-muted-foreground transition-transform"
:class="{ 'rotate-90': expandedProviders[provider.provider_id] }"
/>
<h4 class="truncate text-sm font-semibold">
{{ provider.provider_name }}
</h4>
<Badge
variant="outline"
class="font-mono text-[11px]"
>
{{ provider.provider_type || 'custom' }}
</Badge>
<Badge :variant="getHealthBadgeVariant(provider)">
{{ getHealthLabel(provider) }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{ getProviderMetaText(provider) }}
</p>
</div>
</div>
<div class="grid w-full grid-cols-3 gap-2 lg:max-w-xl">
<MetricBox
label="延迟"
:value="formatMs(provider.avg_latency_ms)"
/>
<MetricBox
label="Ping"
:value="formatMs(provider.avg_first_byte_ms)"
/>
<MetricBox
label="可用率"
:value="formatPercent(provider.success_rate)"
:value-class="getSuccessRateClass(provider.success_rate)"
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent class="border-t border-border/50 px-4 pb-4 pt-4">
<div
v-if="provider.models.length === 0"
class="rounded-lg border border-dashed border-border/60 py-8 text-center text-sm text-muted-foreground"
>
该提供商在当前时间范围内暂无模型请求
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
>
<div
v-for="model in provider.models"
:key="`${provider.provider_id}-${model.model}`"
class="relative overflow-hidden rounded-xl border border-border/60 bg-card/80 p-4 transition-colors hover:border-primary/50"
>
<div class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/50">
<Bot class="h-5 w-5 text-muted-foreground" />
</div>
<h4 class="min-w-0 truncate text-sm font-semibold">
{{ model.display_name || model.model }}
</h4>
</div>
<Badge
:variant="getHealthBadgeVariant(model)"
class="shrink-0"
>
{{ getHealthLabel(model) }}
</Badge>
</div>
<div class="mt-4 grid grid-cols-3 gap-2">
<MetricBox
label="延迟"
:value="formatMs(model.avg_latency_ms)"
/>
<MetricBox
label="Ping"
:value="formatMs(model.avg_first_byte_ms)"
/>
<MetricBox
label="可用率"
:value="formatPercent(model.success_rate)"
:value-class="getSuccessRateClass(model.success_rate)"
/>
</div>
<div class="mt-4 flex items-center justify-between gap-3 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>History (60pts)</span>
<span class="truncate normal-case tracking-normal">
{{ formatCompactNumber(model.total_attempts) }} 次请求
</span>
</div>
<TooltipProvider :delay-duration="100">
<div class="mt-2 flex h-7 w-full items-center gap-px">
<Tooltip
v-for="(segment, index) in timelineSegments(model)"
:key="`${provider.provider_id}-${model.model}-${index}`"
>
<TooltipTrigger as-child>
<div
class="h-full flex-1 rounded-[2px] transition-all duration-150 hover:scale-y-110 hover:brightness-110"
:class="getTimelineColor(segment)"
/>
</TooltipTrigger>
<TooltipContent
side="top"
:side-offset="8"
class="max-w-xs"
>
<div class="text-xs whitespace-pre-line">
{{ buildTimelineTooltip(model, segment, index) }}
</div>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div class="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
<span>{{ formatTimestamp(model.time_range_start) }}</span>
<span>{{ formatTimestamp(model.time_range_end || generatedAt) }}</span>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { defineComponent, h, ref, onMounted, watch } from 'vue'
import { Bot, ChevronRight, Loader2, Server } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import Collapsible from '@/components/ui/collapsible.vue'
import CollapsibleTrigger from '@/components/ui/collapsible-trigger.vue'
import CollapsibleContent from '@/components/ui/collapsible-content.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getProviderStatusMonitor } from '@/api/endpoints/health'
import type { ModelStatusMonitor, ProviderStatusMonitor } from '@/api/endpoints/types'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
type HealthMonitorItem = Pick<ProviderStatusMonitor | ModelStatusMonitor, 'total_attempts' | 'success_rate'>
const MetricBox = defineComponent({
props: {
label: { type: String, required: true },
value: { type: String, required: true },
valueClass: { type: String, default: '' }
},
setup(props) {
return () => h('div', { class: 'rounded-lg border border-border/40 bg-muted/20 px-3 py-2' }, [
h('div', { class: 'text-[11px] text-muted-foreground' }, props.label),
h('div', { class: ['mt-1 text-sm font-semibold tabular-nums', props.valueClass] }, props.value)
])
}
})
const props = withDefaults(defineProps<{
title?: string
}>(), {
title: '提供商健康监控'
})
const { error: showError } = useToast()
const loading = ref(false)
const loadingMonitors = ref(false)
const providers = ref<ProviderStatusMonitor[]>([])
const generatedAt = ref<string | null>(null)
const lookbackHours = ref('6')
const expandedProviders = ref<Record<string, boolean>>({})
async function loadMonitors() {
loadingMonitors.value = true
try {
const data = await getProviderStatusMonitor({
lookback_hours: parseInt(lookbackHours.value),
provider_limit: 50,
per_provider_model_limit: 12,
per_model_limit: 100
})
providers.value = data.providers || []
generatedAt.value = data.generated_at || null
ensureExpandedProviderState()
} catch (err: unknown) {
showError(parseApiError(err, '加载提供商健康监控数据失败'), '错误')
} finally {
loadingMonitors.value = false
}
}
async function refreshData() {
loading.value = true
try {
await loadMonitors()
} finally {
loading.value = false
}
}
function ensureExpandedProviderState() {
const next = { ...expandedProviders.value }
for (const provider of providers.value) {
if (!(provider.provider_id in next)) {
next[provider.provider_id] = false
}
}
expandedProviders.value = next
}
function getHealthLabel(item: HealthMonitorItem) {
if (item.total_attempts <= 0) return '暂无请求'
if (item.success_rate >= 0.95) return '正常'
if (item.success_rate >= 0.8) return '波动'
return '异常'
}
function getHealthBadgeVariant(item: HealthMonitorItem): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' {
if (item.total_attempts <= 0) return 'outline'
if (item.success_rate >= 0.95) return 'success'
if (item.success_rate >= 0.8) return 'warning'
return 'destructive'
}
function getSuccessRateClass(rate: number) {
if (rate >= 0.95) return 'text-green-600 dark:text-green-400'
if (rate >= 0.8) return 'text-amber-600 dark:text-amber-400'
return 'text-red-600 dark:text-red-400'
}
function getProviderMetaText(provider: ProviderStatusMonitor) {
const attempts = `${formatCompactNumber(provider.total_attempts)} 次请求`
return `${provider.model_count} 个模型 / ${attempts}`
}
function formatMs(value?: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
const absValue = Math.abs(value)
if (absValue < 1000) return `${Math.round(value)} ms`
if (absValue < 60_000) return `${formatDurationNumber(value / 1000)} s`
return `${formatDurationNumber(value / 60_000)} min`
}
function formatDurationNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: Math.abs(value) < 10 ? 2 : 1
}).format(value)
}
function formatPercent(value: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
return `${(value * 100).toFixed(2)}%`
}
function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
}
function timelineSegments(item: ModelStatusMonitor | ProviderStatusMonitor) {
const timeline = Array.isArray(item.timeline) ? item.timeline : []
if (timeline.length > 0) return timeline
return Array.from({ length: 60 }, () => 'unknown')
}
function getTimelineColor(status: string) {
switch (status) {
case 'healthy':
return 'bg-green-500/85 dark:bg-green-400/90'
case 'warning':
return 'bg-amber-400/85 dark:bg-amber-300/85'
case 'unhealthy':
return 'bg-red-500/85 dark:bg-red-400/90'
default:
return 'bg-gray-300 dark:bg-gray-600'
}
}
function getTimelineLabel(status: string) {
switch (status) {
case 'healthy':
return '健康'
case 'warning':
return '波动'
case 'unhealthy':
return '异常'
default:
return '无请求'
}
}
function buildTimelineTooltip(model: ModelStatusMonitor, status: string, index: number) {
const segmentCount = timelineSegments(model).length
const startMs = model.time_range_start ? new Date(model.time_range_start).getTime() : Date.now() - parseInt(lookbackHours.value) * 60 * 60 * 1000
const endMs = model.time_range_end ? new Date(model.time_range_end).getTime() : Date.now()
const interval = Math.max(endMs - startMs, 1) / segmentCount
const cellStart = new Date(startMs + index * interval).toISOString()
const cellEnd = new Date(startMs + (index + 1) * interval).toISOString()
return `${formatTimestamp(cellStart)} - ${formatTimestamp(cellEnd)}\n模型:${model.model}\n状态:${getTimelineLabel(status)}`
}
function formatTimestamp(timestamp?: string | null) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '未知时间'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
watch(lookbackHours, () => {
loadMonitors()
})
onMounted(() => {
refreshData()
})
</script>
+196
View File
@@ -139,6 +139,20 @@ function generateHealthEvents(
return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
} }
function generateHealthTimeline(
healthyRate: number,
warningRate: number,
segments = 60
) {
return Array.from({ length: segments }, () => {
const rand = Math.random()
if (rand < healthyRate) return 'healthy'
if (rand < healthyRate + warningRate) return 'warning'
if (rand < 0.96) return 'unknown'
return 'unhealthy'
})
}
// Mock 端点健康数据 // Mock 端点健康数据
// 注意:success_rate 使用 0-1 之间的小数,前端会乘以 100 显示为百分比 // 注意:success_rate 使用 0-1 之间的小数,前端会乘以 100 显示为百分比
// 事件的成功/失败/跳过比例必须与 success_rate 保持一致 // 事件的成功/失败/跳过比例必须与 success_rate 保持一致
@@ -246,6 +260,154 @@ const MOCK_ENDPOINT_STATUS = {
] ]
} }
const MOCK_MODEL_STATUS = {
generated_at: new Date().toISOString(),
models: [
{
model: 'gpt-5.5',
display_name: 'gpt-5.5',
total_attempts: 2021,
success_count: 2000,
failed_count: 21,
success_rate: 0.9896,
avg_latency_ms: 1736,
avg_first_byte_ms: 176,
provider_count: 3,
last_event_at: new Date().toISOString(),
events: generateHealthEvents(60, 0.989, 0.008, 0.003, 1600, 460),
timeline: generateHealthTimeline(0.9, 0.05),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString()
},
{
model: 'claude-sonnet-4-5-20250929',
display_name: 'Claude Sonnet 4.5',
total_attempts: 1684,
success_count: 1642,
failed_count: 42,
success_rate: 0.9751,
avg_latency_ms: 1280,
avg_first_byte_ms: 221,
provider_count: 2,
last_event_at: new Date().toISOString(),
events: generateHealthEvents(60, 0.975, 0.02, 0.005, 1200, 520),
timeline: generateHealthTimeline(0.84, 0.09),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString()
},
{
model: 'gemini-3-pro-preview',
display_name: 'Gemini 3 Pro Preview',
total_attempts: 932,
success_count: 887,
failed_count: 45,
success_rate: 0.9517,
avg_latency_ms: 940,
avg_first_byte_ms: 184,
provider_count: 2,
last_event_at: new Date().toISOString(),
events: generateHealthEvents(55, 0.952, 0.04, 0.008, 860, 300),
timeline: generateHealthTimeline(0.78, 0.14),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString()
},
{
model: 'gpt-5.1-codex-mini',
display_name: 'gpt-5.1-codex-mini',
total_attempts: 418,
success_count: 349,
failed_count: 69,
success_rate: 0.835,
avg_latency_ms: 2310,
avg_first_byte_ms: 420,
provider_count: 1,
last_event_at: new Date().toISOString(),
events: generateHealthEvents(45, 0.835, 0.145, 0.02, 2200, 780),
timeline: generateHealthTimeline(0.58, 0.24),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString()
}
]
}
const MOCK_PROVIDER_HEALTH_STATUS = {
generated_at: new Date().toISOString(),
providers: [
{
provider_id: 'provider-001',
provider_name: 'OpenAI Official',
provider_type: 'codex',
is_active: true,
total_attempts: 2021,
success_count: 2000,
failed_count: 21,
success_rate: 0.9896,
avg_latency_ms: 1736,
avg_first_byte_ms: 176,
model_count: 2,
last_event_at: new Date().toISOString(),
timeline: generateHealthTimeline(0.9, 0.05),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString(),
models: [MOCK_MODEL_STATUS.models[0], MOCK_MODEL_STATUS.models[3]]
},
{
provider_id: 'provider-002',
provider_name: 'Anthropic Official',
provider_type: 'claude_code',
is_active: true,
total_attempts: 1684,
success_count: 1642,
failed_count: 42,
success_rate: 0.9751,
avg_latency_ms: 1280,
avg_first_byte_ms: 221,
model_count: 1,
last_event_at: new Date().toISOString(),
timeline: generateHealthTimeline(0.84, 0.09),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString(),
models: [MOCK_MODEL_STATUS.models[1]]
},
{
provider_id: 'provider-003',
provider_name: 'Google AI',
provider_type: 'gemini_cli',
is_active: true,
total_attempts: 932,
success_count: 887,
failed_count: 45,
success_rate: 0.9517,
avg_latency_ms: 940,
avg_first_byte_ms: 184,
model_count: 1,
last_event_at: new Date().toISOString(),
timeline: generateHealthTimeline(0.78, 0.14),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString(),
models: [MOCK_MODEL_STATUS.models[2]]
},
{
provider_id: 'provider-004',
provider_name: 'AWS Bedrock',
provider_type: 'custom',
is_active: true,
total_attempts: 0,
success_count: 0,
failed_count: 0,
success_rate: 1,
avg_latency_ms: null,
avg_first_byte_ms: null,
model_count: 0,
last_event_at: null,
timeline: Array.from({ length: 60 }, () => 'unknown'),
time_range_start: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
time_range_end: new Date().toISOString(),
models: []
}
]
}
// 生成活跃热力图数据(最近365天) // 生成活跃热力图数据(最近365天)
function generateActivityHeatmap() { function generateActivityHeatmap() {
const days: Array<{ const days: Array<{
@@ -1026,6 +1188,18 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
return createMockResponse(MOCK_ENDPOINT_STATUS) return createMockResponse(MOCK_ENDPOINT_STATUS)
}, },
'GET /api/admin/endpoints/health/models': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_MODEL_STATUS)
},
'GET /api/admin/endpoints/health/providers': async () => {
await delay()
requireAdmin()
return createMockResponse(MOCK_PROVIDER_HEALTH_STATUS)
},
'GET /api/admin/endpoints/keys': async () => { 'GET /api/admin/endpoints/keys': async () => {
await delay() await delay()
requireAdmin() requireAdmin()
@@ -1387,6 +1561,28 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
events: f.events.slice(0, 10) events: f.events.slice(0, 10)
})) }))
}) })
},
'GET /api/public/health/models': async () => {
await delay()
return createMockResponse({
generated_at: new Date().toISOString(),
models: MOCK_MODEL_STATUS.models.map(model => ({
model: model.model,
display_name: model.display_name,
total_attempts: model.total_attempts,
success_count: model.success_count,
failed_count: model.failed_count,
success_rate: model.success_rate,
avg_latency_ms: model.avg_latency_ms,
avg_first_byte_ms: model.avg_first_byte_ms,
last_event_at: model.last_event_at,
events: model.events.slice(0, 10),
timeline: model.timeline,
time_range_start: model.time_range_start,
time_range_end: model.time_range_end
}))
})
} }
} }
+57 -6
View File
@@ -1,18 +1,69 @@
<template> <template>
<div class="space-y-6 pb-8"> <div class="space-y-6 pb-8">
<HealthMonitorCard <Tabs v-model="activeTab">
title="健康监控" <TabsList
:is-admin="isAdminPage" class="tabs-button-list grid w-full"
:show-provider-info="isAdminPage" :class="isAdminPage ? 'max-w-2xl grid-cols-3' : 'max-w-md grid-cols-2'"
/> >
<TabsTrigger value="endpoint">
端点健康监控
</TabsTrigger>
<TabsTrigger value="model">
模型健康监控
</TabsTrigger>
<TabsTrigger
v-if="isAdminPage"
value="provider"
>
提供商健康监控
</TabsTrigger>
</TabsList>
<TabsContent
value="endpoint"
class="mt-4"
>
<HealthMonitorCard
title="端点健康监控"
:is-admin="isAdminPage"
:show-provider-info="isAdminPage"
/>
</TabsContent>
<TabsContent
value="model"
class="mt-4"
>
<ModelHealthMonitorCard
title="模型健康监控"
:is-admin="isAdminPage"
:show-provider-info="isAdminPage"
/>
</TabsContent>
<TabsContent
v-if="isAdminPage"
value="provider"
class="mt-4"
>
<ProviderHealthMonitorCard title="提供商健康监控" />
</TabsContent>
</Tabs>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import TabsList from '@/components/ui/tabs-list.vue'
import TabsTrigger from '@/components/ui/tabs-trigger.vue'
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue' import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
import ModelHealthMonitorCard from '@/features/providers/components/ModelHealthMonitorCard.vue'
import ProviderHealthMonitorCard from '@/features/providers/components/ProviderHealthMonitorCard.vue'
const route = useRoute() const route = useRoute()
const isAdminPage = computed(() => route.path.startsWith('/admin')) const isAdminPage = computed(() => route.path.startsWith('/admin'))
const activeTab = ref('endpoint')
</script> </script>