use std::collections::HashMap;
use std::fmt;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{RunFilter, RunStatus};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RunStats {
pub total_runs: u64,
pub completed_runs: u64,
pub failed_runs: u64,
pub cancelled_runs: u64,
pub active_runs: u64,
#[serde(default)]
pub awaiting_approval_runs: u64,
pub total_cost_usd: Decimal,
pub total_duration_ms: u64,
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum HistoryPeriod {
#[serde(rename = "24h")]
TwentyFourHours,
#[default]
#[serde(rename = "7d")]
SevenDays,
#[serde(rename = "30d")]
ThirtyDays,
#[serde(rename = "90d")]
NinetyDays,
}
impl fmt::Display for HistoryPeriod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TwentyFourHours => write!(f, "24h"),
Self::SevenDays => write!(f, "7d"),
Self::ThirtyDays => write!(f, "30d"),
Self::NinetyDays => write!(f, "90d"),
}
}
}
impl HistoryPeriod {
pub fn default_granularity(&self) -> HistoryGranularity {
match self {
Self::TwentyFourHours => HistoryGranularity::OneHour,
Self::SevenDays | Self::ThirtyDays => HistoryGranularity::OneDay,
Self::NinetyDays => HistoryGranularity::OneWeek,
}
}
pub fn hours(&self) -> i64 {
match self {
Self::TwentyFourHours => 24,
Self::SevenDays => 7 * 24,
Self::ThirtyDays => 30 * 24,
Self::NinetyDays => 90 * 24,
}
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HistoryGranularity {
#[serde(rename = "1h")]
OneHour,
#[serde(rename = "1d")]
OneDay,
#[serde(rename = "1w")]
OneWeek,
}
impl fmt::Display for HistoryGranularity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OneHour => write!(f, "1h"),
Self::OneDay => write!(f, "1d"),
Self::OneWeek => write!(f, "1w"),
}
}
}
impl HistoryGranularity {
pub fn pg_interval(&self) -> &'static str {
match self {
Self::OneHour => "hour",
Self::OneDay => "day",
Self::OneWeek => "week",
}
}
pub fn seconds(&self) -> i64 {
match self {
Self::OneHour => 3600,
Self::OneDay => 86400,
Self::OneWeek => 604800,
}
}
pub fn bucket_start(&self, time: DateTime<Utc>) -> DateTime<Utc> {
let secs = time.timestamp();
let step = self.seconds();
let start = match self {
Self::OneHour | Self::OneDay => secs.div_euclid(step) * step,
Self::OneWeek => {
let offset = 4 * 86_400;
(secs - offset).div_euclid(step) * step + offset
}
};
DateTime::from_timestamp(start, 0).unwrap_or(time)
}
}
#[derive(Debug, Clone)]
pub struct StatsHistoryFilter {
pub workflow_name: Option<String>,
pub status: Option<RunStatus>,
pub has_steps: Option<bool>,
pub labels: Option<HashMap<String, String>>,
pub created_by_user_id: Option<Uuid>,
pub period: HistoryPeriod,
pub granularity: HistoryGranularity,
}
impl Default for StatsHistoryFilter {
fn default() -> Self {
let period = HistoryPeriod::default();
Self {
workflow_name: None,
status: None,
has_steps: None,
labels: None,
created_by_user_id: None,
period,
granularity: period.default_granularity(),
}
}
}
impl StatsHistoryFilter {
pub fn to_run_filter(&self) -> RunFilter {
RunFilter {
workflow_name: self.workflow_name.clone(),
status: self.status,
created_after: None,
created_before: None,
has_steps: self.has_steps,
labels: self.labels.clone(),
created_by_user_id: self.created_by_user_id,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StatsHistoryBucket {
pub time: DateTime<Utc>,
pub completed: u64,
pub warning: u64,
pub failed: u64,
pub cancelled: u64,
pub pending: u64,
pub running: u64,
pub retrying: u64,
pub awaiting_approval: u64,
pub sleeping: u64,
pub avg_duration_ms: u64,
pub p95_duration_ms: u64,
pub total_cost_usd: Decimal,
}
impl StatsHistoryBucket {
pub fn success_rate_percent(&self) -> Option<f64> {
let successes = self.completed + self.warning;
let denominator = successes + self.failed;
if denominator == 0 {
return None;
}
Some(successes as f64 / denominator as f64 * 100.0)
}
}
#[cfg(test)]
mod tests {
use chrono::{Datelike, Duration, Timelike, Weekday};
use super::*;
#[test]
fn default_is_zeros() {
let stats = RunStats::default();
assert_eq!(stats.total_runs, 0);
assert_eq!(stats.completed_runs, 0);
assert_eq!(stats.failed_runs, 0);
assert_eq!(stats.cancelled_runs, 0);
assert_eq!(stats.active_runs, 0);
assert_eq!(stats.awaiting_approval_runs, 0);
assert_eq!(stats.total_cost_usd, Decimal::ZERO);
assert_eq!(stats.total_duration_ms, 0);
}
#[test]
fn serde_roundtrip() {
let stats = RunStats {
total_runs: 100,
completed_runs: 80,
failed_runs: 15,
cancelled_runs: 5,
active_runs: 4,
awaiting_approval_runs: 2,
total_cost_usd: Decimal::new(4250, 2),
total_duration_ms: 3600000,
};
let json = serde_json::to_string(&stats).expect("serialize");
let back: RunStats = serde_json::from_str(&json).expect("deserialize");
assert_eq!(stats.total_runs, back.total_runs);
assert_eq!(stats.completed_runs, back.completed_runs);
assert_eq!(stats.failed_runs, back.failed_runs);
assert_eq!(stats.cancelled_runs, back.cancelled_runs);
assert_eq!(stats.active_runs, back.active_runs);
assert_eq!(stats.awaiting_approval_runs, back.awaiting_approval_runs);
assert_eq!(stats.total_cost_usd, back.total_cost_usd);
assert_eq!(stats.total_duration_ms, back.total_duration_ms);
}
#[test]
fn run_stats_awaiting_approval_defaults_when_missing() {
let json = r#"{"total_runs":1,"completed_runs":0,"failed_runs":0,"cancelled_runs":0,"active_runs":1,"total_cost_usd":"0","total_duration_ms":0}"#;
let stats: RunStats = serde_json::from_str(json).expect("deserialize");
assert_eq!(stats.awaiting_approval_runs, 0);
}
fn utc(s: &str) -> DateTime<Utc> {
s.parse().expect("valid timestamp")
}
#[test]
fn bucket_start_hour() {
assert_eq!(
HistoryGranularity::OneHour.bucket_start(utc("2026-09-24T13:45:12Z")),
utc("2026-09-24T13:00:00Z")
);
assert_eq!(
HistoryGranularity::OneHour.bucket_start(utc("2026-09-24T13:00:00Z")),
utc("2026-09-24T13:00:00Z")
);
}
#[test]
fn bucket_start_day() {
assert_eq!(
HistoryGranularity::OneDay.bucket_start(utc("2026-09-24T23:59:59Z")),
utc("2026-09-24T00:00:00Z")
);
}
#[test]
fn bucket_start_week_thursday_maps_to_previous_monday() {
assert_eq!(
HistoryGranularity::OneWeek.bucket_start(utc("2026-09-24T13:45:00Z")),
utc("2026-09-21T00:00:00Z")
);
}
#[test]
fn bucket_start_week_sunday_maps_to_previous_monday() {
assert_eq!(
HistoryGranularity::OneWeek.bucket_start(utc("2026-09-27T23:59:59Z")),
utc("2026-09-21T00:00:00Z")
);
}
#[test]
fn bucket_start_week_monday_midnight_maps_to_itself() {
assert_eq!(
HistoryGranularity::OneWeek.bucket_start(utc("2026-09-21T00:00:00Z")),
utc("2026-09-21T00:00:00Z")
);
}
#[test]
fn bucket_start_week_is_always_monday_midnight() {
let mut time = utc("2026-01-01T07:30:00Z");
for _ in 0..30 {
let start = HistoryGranularity::OneWeek.bucket_start(time);
assert_eq!(start.weekday(), Weekday::Mon);
assert_eq!(start.hour(), 0);
assert_eq!(start.minute(), 0);
assert!(start <= time);
time += Duration::hours(29);
}
}
#[test]
fn bucket_start_pre_epoch() {
assert_eq!(
HistoryGranularity::OneHour.bucket_start(utc("1969-12-31T23:30:00Z")),
utc("1969-12-31T23:00:00Z")
);
assert_eq!(
HistoryGranularity::OneDay.bucket_start(utc("1969-12-31T12:00:00Z")),
utc("1969-12-31T00:00:00Z")
);
assert_eq!(
HistoryGranularity::OneWeek.bucket_start(utc("1969-12-31T12:00:00Z")),
utc("1969-12-29T00:00:00Z")
);
}
#[test]
fn success_rate_none_on_empty_bucket() {
assert_eq!(StatsHistoryBucket::default().success_rate_percent(), None);
}
#[test]
fn success_rate_none_with_only_running_and_cancelled() {
let bucket = StatsHistoryBucket {
running: 3,
cancelled: 2,
pending: 1,
..StatsHistoryBucket::default()
};
assert_eq!(bucket.success_rate_percent(), None);
}
#[test]
fn success_rate_counts_warning_as_success() {
let bucket = StatsHistoryBucket {
warning: 2,
..StatsHistoryBucket::default()
};
assert_eq!(bucket.success_rate_percent(), Some(100.0));
}
#[test]
fn success_rate_half() {
let bucket = StatsHistoryBucket {
completed: 1,
failed: 1,
..StatsHistoryBucket::default()
};
assert_eq!(bucket.success_rate_percent(), Some(50.0));
}
#[test]
fn stats_history_filter_default() {
let filter = StatsHistoryFilter::default();
assert_eq!(filter.period, HistoryPeriod::SevenDays);
assert_eq!(filter.granularity, HistoryGranularity::OneDay);
assert!(filter.workflow_name.is_none());
assert!(filter.status.is_none());
assert!(filter.has_steps.is_none());
assert!(filter.labels.is_none());
assert!(filter.created_by_user_id.is_none());
}
#[test]
fn stats_history_filter_to_run_filter_copies_filters() {
let user_id = Uuid::now_v7();
let labels = HashMap::from([("env".to_string(), "prod".to_string())]);
let filter = StatsHistoryFilter {
workflow_name: Some("deploy".to_string()),
status: Some(RunStatus::Running),
has_steps: Some(true),
labels: Some(labels.clone()),
created_by_user_id: Some(user_id),
period: HistoryPeriod::NinetyDays,
granularity: HistoryGranularity::OneWeek,
};
let run_filter = filter.to_run_filter();
assert_eq!(run_filter.workflow_name.as_deref(), Some("deploy"));
assert_eq!(run_filter.status, Some(RunStatus::Running));
assert_eq!(run_filter.has_steps, Some(true));
assert_eq!(run_filter.labels, Some(labels));
assert_eq!(run_filter.created_by_user_id, Some(user_id));
assert!(run_filter.created_after.is_none());
assert!(run_filter.created_before.is_none());
}
}