mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge remote-tracking branch 'origin/pr/587'
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user