use axum::{
Extension, Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::auth::{AuthContext, Scope, check_scope};
use crate::models::ApiError;
use crate::server::AppState;
use perfgate_domain::{TrendConfig, metric_value};
use perfgate_stats::trend::{TrendAnalysis, analyze_trend, spark_chart};
use perfgate_types::Metric;
#[derive(Debug, Clone, Deserialize)]
pub struct TrendQuery {
pub metric: String,
#[serde(default = "default_window")]
pub window: u32,
#[serde(default = "default_threshold")]
pub threshold: f64,
#[serde(default = "default_critical_window")]
pub critical_window: u32,
}
fn default_window() -> u32 {
30
}
fn default_threshold() -> f64 {
0.20
}
fn default_critical_window() -> u32 {
10
}
#[derive(Debug, Clone, Serialize)]
pub struct TrendResponse {
pub project: String,
pub benchmark: String,
pub analysis: Option<TrendAnalysis>,
pub values: Vec<f64>,
pub spark: String,
pub data_points: usize,
}
pub async fn get_trend(
Path((project, benchmark)): Path<(String, String)>,
Extension(auth_ctx): Extension<AuthContext>,
State(state): State<AppState>,
Query(query): Query<TrendQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<ApiError>)> {
check_scope(Some(&auth_ctx), &project, Some(&benchmark), Scope::Read)?;
let store = &state.store;
let metric = Metric::parse_key(&query.metric).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(ApiError::validation(&format!(
"Unknown metric: {}",
query.metric
))),
)
})?;
let list_query = crate::models::ListBaselinesQuery {
benchmark: Some(benchmark.clone()),
include_receipt: true,
limit: query.window,
..Default::default()
};
let baselines = match store.list(&project, &list_query).await {
Ok(response) => response.baselines,
Err(e) => {
error!(error = %e, "Failed to list baselines for trend analysis");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::internal_error(&e.to_string())),
));
}
};
let mut entries: Vec<(chrono::DateTime<chrono::Utc>, f64)> = baselines
.iter()
.filter_map(|b| {
let receipt = b.receipt.as_ref()?;
let value = metric_value(&receipt.stats, metric)?;
Some((b.created_at, value))
})
.collect();
entries.sort_by_key(|(ts, _)| *ts);
let values: Vec<f64> = entries.iter().map(|(_, v)| *v).collect();
let data_points = values.len();
let spark = spark_chart(&values);
let direction = metric.default_direction();
let lower_is_better = direction == perfgate_types::Direction::Lower;
let analysis = if values.len() >= 2 {
let baseline_value = values[0];
let absolute_threshold = if lower_is_better {
baseline_value * (1.0 + query.threshold)
} else {
baseline_value * (1.0 - query.threshold)
};
let config = TrendConfig {
critical_window: query.critical_window,
..TrendConfig::default()
};
analyze_trend(
&values,
metric.as_str(),
absolute_threshold,
lower_is_better,
&config,
)
} else {
None
};
Ok(Json(TrendResponse {
project,
benchmark,
analysis,
values,
spark,
data_points,
}))
}