use std::sync::Arc;
use axum::{Json, extract::State, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tracing::warn;
use crate::daemon::error::DaemonError;
use crate::daemon::managed_routes::{
DeliverableStatusCounts, MilestoneStatusCounts, ProjectStatusResponse, SessionStateCounts,
aggregate_project_status,
};
use crate::daemon::state::DaemonState;
use crate::deliverable::{Deliverable, Milestone};
use crate::project::Project;
use crate::session_manager::SessionRecord;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortfolioTotals {
pub sessions: SessionStateCounts,
pub deliverables: DeliverableStatusCounts,
pub milestones: MilestoneStatusCounts,
pub last_activity_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortfolioStatusResponse {
pub project_count: usize,
pub totals: PortfolioTotals,
pub projects: Vec<ProjectStatusResponse>,
}
pub fn aggregate_portfolio_status(
projects: &[Project],
all_sessions: &[SessionRecord],
all_deliverables: &[Deliverable],
all_milestones: &[Milestone],
) -> PortfolioStatusResponse {
let mut ordered: Vec<&Project> = projects.iter().collect();
ordered.sort_by(|a, b| a.name.cmp(&b.name));
let per_project: Vec<ProjectStatusResponse> = ordered
.into_iter()
.map(|p| aggregate_project_status(p, all_sessions, all_deliverables, all_milestones))
.collect();
rollup_of(per_project)
}
pub(crate) fn rollup_of(per_project: Vec<ProjectStatusResponse>) -> PortfolioStatusResponse {
let totals = fold_totals(&per_project);
PortfolioStatusResponse {
project_count: per_project.len(),
totals,
projects: per_project,
}
}
fn fold_totals(per_project: &[ProjectStatusResponse]) -> PortfolioTotals {
let mut sessions = SessionStateCounts {
provisioning: 0,
active: 0,
stopped: 0,
errored: 0,
decommissioned: 0,
total: 0,
};
let mut deliverables = DeliverableStatusCounts {
proposed: 0,
in_progress: 0,
blocked: 0,
complete: 0,
delivered: 0,
shipped: 0,
total: 0,
};
let mut milestones = MilestoneStatusCounts {
proposed: 0,
in_progress: 0,
complete: 0,
shipped: 0,
total: 0,
dangling_deliverable_refs: 0,
};
let mut last_activity_at: Option<DateTime<Utc>> = None;
for p in per_project {
sessions.provisioning += p.sessions.provisioning;
sessions.active += p.sessions.active;
sessions.stopped += p.sessions.stopped;
sessions.errored += p.sessions.errored;
sessions.decommissioned += p.sessions.decommissioned;
sessions.total += p.sessions.total;
deliverables.proposed += p.deliverables.proposed;
deliverables.in_progress += p.deliverables.in_progress;
deliverables.blocked += p.deliverables.blocked;
deliverables.complete += p.deliverables.complete;
deliverables.delivered += p.deliverables.delivered;
deliverables.shipped += p.deliverables.shipped;
deliverables.total += p.deliverables.total;
milestones.proposed += p.milestones.proposed;
milestones.in_progress += p.milestones.in_progress;
milestones.complete += p.milestones.complete;
milestones.shipped += p.milestones.shipped;
milestones.total += p.milestones.total;
milestones.dangling_deliverable_refs += p.milestones.dangling_deliverable_refs;
if let Some(ts) = p.last_activity_at {
last_activity_at = Some(match last_activity_at {
Some(current) => current.max(ts),
None => ts,
});
}
}
PortfolioTotals {
sessions,
deliverables,
milestones,
last_activity_at,
}
}
pub(crate) async fn load_portfolio_status(
state: &DaemonState,
) -> Result<PortfolioStatusResponse, DaemonError> {
let registry = state.project_registry().await;
let projects = registry.list().await.map_err(|e| {
warn!(error = %e, "manager status: project registry read failed");
DaemonError::Internal("project registry read failed".to_string())
})?;
let sessions = state.session_manager().await.list().await;
let deliverable_mgr = state.deliverable_manager().await;
let deliverables = deliverable_mgr.all_deliverables().await.map_err(|e| {
warn!(error = %e, "manager status: deliverable store read failed");
DaemonError::Internal("deliverable store read failed".to_string())
})?;
let milestones = deliverable_mgr.all_milestones().await.map_err(|e| {
warn!(error = %e, "manager status: milestone store read failed");
DaemonError::Internal("milestone store read failed".to_string())
})?;
Ok(aggregate_portfolio_status(
&projects,
&sessions,
&deliverables,
&milestones,
))
}
pub async fn manager_status_route(State(state): State<Arc<DaemonState>>) -> impl IntoResponse {
match load_portfolio_status(&state).await {
Ok(rollup) => Json(rollup).into_response(),
Err(e) => (e.status(), e.detail()).into_response(),
}
}
#[cfg(test)]
#[path = "status_tests.rs"]
mod tests;