diff --git a/crates/logs-service/src/main.rs b/crates/logs-service/src/main.rs index 788adb3..a5dd33e 100644 --- a/crates/logs-service/src/main.rs +++ b/crates/logs-service/src/main.rs @@ -6,9 +6,33 @@ use sea_orm::sea_query::Expr; use sea_orm_migration::MigratorTrait; use serde::Serialize; use std::net::SocketAddr; +use std::collections::BTreeMap; use tracing::{info, warn}; use uuid::Uuid; +#[derive(Serialize)] +struct JobLinks { + logs: String, +} + +#[derive(Serialize)] +struct JobSummary { + request_id: Uuid, + runs_on: Option, + state: String, + updated_at: chrono::DateTime, + links: JobLinks, +} + +#[derive(Serialize)] +struct JobGroup { + repo_url: String, + commit_sha: String, + last_updated: chrono::DateTime, + total_jobs: usize, + jobs: Vec, +} + #[derive(Parser, Debug)] #[command(name = "solstice-logs", version, about = "Solstice CI — Logs Service")] struct Opts { @@ -39,6 +63,7 @@ async fn main() -> Result<()> { let router = Router::new() .route("/jobs/{request_id}/logs", get(list_logs)) .route("/jobs/{request_id}/logs/{category}", get(get_logs_by_category)) + .route("/jobs", get(list_jobs_grouped)) .with_state(state); let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR"); @@ -73,6 +98,25 @@ mod job_logs { impl ActiveModelBehavior for ActiveModel {} } +mod jobs { + use super::*; + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "jobs")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub request_id: Uuid, + pub repo_url: String, + pub commit_sha: String, + pub runs_on: Option, + pub state: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + } + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + impl ActiveModelBehavior for ActiveModel {} +} + #[derive(Serialize, sea_orm::FromQueryResult)] struct LogCategorySummary { category: String, @@ -162,3 +206,57 @@ async fn get_logs_by_category(Path((request_id, category)): Path<(String, String Err(e) => { warn!(error = %e, request_id = %id, "failed to read logs"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } + + +async fn list_jobs_grouped(axum::extract::State(state): axum::extract::State) -> Response { + // Fetch all jobs ordered by most recently updated first + let rows_res: miette::Result> = jobs::Entity::find() + .order_by_desc(jobs::Column::UpdatedAt) + .all(&state.db) + .await + .into_diagnostic(); + + match rows_res { + Ok(rows) => { + // Group by (repo_url, commit_sha) + let mut groups: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for j in rows { + groups + .entry((j.repo_url.clone(), j.commit_sha.clone())) + .or_default() + .push(j); + } + // Build output + let mut out: Vec = Vec::with_capacity(groups.len()); + for ((repo_url, commit_sha), mut items) in groups.into_iter() { + // Ensure items are sorted by updated_at desc + items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let last_updated = items.first().map(|j| j.updated_at).unwrap_or_else(|| chrono::Utc::now()); + let jobs: Vec = items + .into_iter() + .map(|j| JobSummary { + request_id: j.request_id, + runs_on: j.runs_on, + state: j.state, + updated_at: j.updated_at, + links: JobLinks { logs: format!("/jobs/{}/logs", j.request_id) }, + }) + .collect(); + out.push(JobGroup { + repo_url, + commit_sha, + last_updated, + total_jobs: jobs.len(), + jobs, + }); + } + // Sort groups by last_updated desc for convenience + out.sort_by(|a, b| b.last_updated.cmp(&a.last_updated)); + Json(out).into_response() + } + Err(e) => { + warn!(error = %e, "failed to list jobs"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +}