Add grouped job listing endpoint to logs-service and define related models

- Introduce new `/jobs` endpoint for listing jobs grouped by `(repo_url, commit_sha)`, ordered by update timestamp.
- Add models `JobGroup`, `JobSummary`, and `JobLinks` to structure grouped job details.
- Implement grouping logic using `BTreeMap` for structured output.
- Extend router with the new endpoint and integrate ORM-backed query for fetching job data.

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2025-11-18 15:39:22 +01:00
parent 08eb82d7f7
commit 0a9d46a455
No known key found for this signature in database

View file

@ -6,9 +6,33 @@ use sea_orm::sea_query::Expr;
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use serde::Serialize; use serde::Serialize;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::collections::BTreeMap;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
#[derive(Serialize)]
struct JobLinks {
logs: String,
}
#[derive(Serialize)]
struct JobSummary {
request_id: Uuid,
runs_on: Option<String>,
state: String,
updated_at: chrono::DateTime<chrono::Utc>,
links: JobLinks,
}
#[derive(Serialize)]
struct JobGroup {
repo_url: String,
commit_sha: String,
last_updated: chrono::DateTime<chrono::Utc>,
total_jobs: usize,
jobs: Vec<JobSummary>,
}
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "solstice-logs", version, about = "Solstice CI — Logs Service")] #[command(name = "solstice-logs", version, about = "Solstice CI — Logs Service")]
struct Opts { struct Opts {
@ -39,6 +63,7 @@ async fn main() -> Result<()> {
let router = Router::new() let router = Router::new()
.route("/jobs/{request_id}/logs", get(list_logs)) .route("/jobs/{request_id}/logs", get(list_logs))
.route("/jobs/{request_id}/logs/{category}", get(get_logs_by_category)) .route("/jobs/{request_id}/logs/{category}", get(get_logs_by_category))
.route("/jobs", get(list_jobs_grouped))
.with_state(state); .with_state(state);
let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR"); let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR");
@ -73,6 +98,25 @@ mod job_logs {
impl ActiveModelBehavior for ActiveModel {} 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<String>,
pub state: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
#[derive(Serialize, sea_orm::FromQueryResult)] #[derive(Serialize, sea_orm::FromQueryResult)]
struct LogCategorySummary { struct LogCategorySummary {
category: String, 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() } 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<AppState>) -> Response {
// Fetch all jobs ordered by most recently updated first
let rows_res: miette::Result<Vec<jobs::Model>> = 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<jobs::Model>> = 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<JobGroup> = 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<JobSummary> = 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()
}
}
}