fix(gateway): stabilize status snapshot output

This commit is contained in:
xiaoxixi 2026-07-24 18:27:37 +08:00
parent b14edc40d3
commit 87cf5004b9

View File

@ -169,6 +169,14 @@ impl ApiError {
message: error.to_string(), message: error.to_string(),
} }
} }
fn internal_with_message(error: impl std::fmt::Display, message: impl Into<String>) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
} }
pub async fn upload_file( pub async fn upload_file(
@ -714,6 +722,30 @@ fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
}) })
} }
fn scheduler_status_error(error: impl std::fmt::Display) -> ApiError {
ApiError::internal_with_message(error, "failed to load scheduler status")
}
fn channel_snapshot(mut channels: Vec<(String, bool)>) -> Vec<Value> {
channels.sort_by(|left, right| left.0.cmp(&right.0));
channels
.into_iter()
.map(|(name, running)| {
json!({
"name": name,
"status": if running { "connected" } else { "stopped" },
})
})
.collect()
}
fn sorted_providers(
mut providers: Vec<crate::observability::metrics::ProviderSnapshot>,
) -> Vec<crate::observability::metrics::ProviderSnapshot> {
providers.sort_by(|left, right| left.name.cmp(&right.name));
providers
}
pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> { pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let reload = state.reload.status(); let reload = state.reload.status();
let metrics = crate::observability::metrics::global_metrics().snapshot(); let metrics = crate::observability::metrics::global_metrics().snapshot();
@ -728,24 +760,22 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
let sessions_total = state.session_manager.session_count().await; let sessions_total = state.session_manager.session_count().await;
let active_turns = state.session_manager.active_turn_count().await; let active_turns = state.session_manager.active_turn_count().await;
let mut channels = Vec::new(); let mut channel_states = Vec::new();
for name in state.channel_manager.list_channel_names().await { for name in state.channel_manager.list_channel_names().await {
let running = state let running = state
.channel_manager .channel_manager
.get_channel(&name) .get_channel(&name)
.await .await
.is_some_and(|channel| channel.is_running()); .is_some_and(|channel| channel.is_running());
channels.push(json!({ channel_states.push((name, running));
"name": name,
"status": if running { "connected" } else { "stopped" },
}));
} }
let channels = channel_snapshot(channel_states);
let jobs = state let jobs = state
.storage .storage
.list_scheduled_jobs() .list_scheduled_jobs()
.await .await
.map_err(ApiError::internal)?; .map_err(scheduler_status_error)?;
let scheduler = scheduler_snapshot(&jobs); let scheduler = scheduler_snapshot(&jobs);
let mcp = crate::mcp::get_mcp_status() let mcp = crate::mcp::get_mcp_status()
.into_iter() .into_iter()
@ -783,7 +813,7 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
"control": { "depth": depths.control_depth, "cap": depths.control_cap }, "control": { "depth": depths.control_depth, "cap": depths.control_cap },
"active_lanes": active_lanes, "active_lanes": active_lanes,
}, },
"providers": metrics.providers, "providers": sorted_providers(metrics.providers),
"channels": channels, "channels": channels,
"scheduler": scheduler, "scheduler": scheduler,
"mcp": mcp, "mcp": mcp,
@ -943,6 +973,54 @@ mod tests {
); );
} }
#[test]
fn channel_snapshot_is_sorted_and_maps_running_state() {
let channels = channel_snapshot(vec![
("zeta".to_string(), false),
("alpha".to_string(), true),
]);
assert_eq!(
channels,
vec![
json!({ "name": "alpha", "status": "connected" }),
json!({ "name": "zeta", "status": "stopped" }),
]
);
}
#[test]
fn provider_snapshot_is_sorted_by_name() {
let provider = |name: &str| crate::observability::metrics::ProviderSnapshot {
name: name.to_string(),
model: String::new(),
status: "ok".to_string(),
latency_ms: 0,
latencies: Vec::new(),
tokens_in: 0,
tokens_out: 0,
cost: 0.0,
};
let providers = sorted_providers(vec![provider("zeta"), provider("alpha")]);
assert_eq!(
providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "zeta"]
);
}
#[test]
fn scheduler_status_error_hides_internal_details() {
let error = scheduler_status_error("database contained private payload");
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.message, "failed to load scheduler status");
assert!(!error.message.contains("private payload"));
}
#[test] #[test]
fn secrets_are_redacted_and_restored() { fn secrets_are_redacted_and_restored() {
let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"}); let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});