diff --git a/crates/ciadm/Cargo.toml b/crates/ciadm/Cargo.toml index dea67f3..ea16dbb 100644 --- a/crates/ciadm/Cargo.toml +++ b/crates/ciadm/Cargo.toml @@ -15,3 +15,4 @@ crossterm = { version = "0.28", features = ["event-stream"] } futures-util = "0.3" chrono = "0.4" uuid = { version = "1", features = ["v4"] } +kdl = "6" diff --git a/crates/ciadm/src/main.rs b/crates/ciadm/src/main.rs index ff13e2f..4798f6e 100644 --- a/crates/ciadm/src/main.rs +++ b/crates/ciadm/src/main.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::io::{self, Stdout}; +use std::path::PathBuf; use std::process::Command; use std::time::Duration; @@ -44,6 +45,11 @@ enum Commands { #[arg(long)] job_id: String, }, + /// Configure local CLI settings + Config { + #[command(subcommand)] + command: ConfigCommand, + }, /// List recent jobs from the logs service Jobs { /// Logs service base URL (e.g., https://logs.prod.example.com) @@ -76,6 +82,16 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ConfigCommand { + /// Persist the logs-service base URL in a local config file + SetBaseUrl { + /// Logs service base URL (e.g., https://logs.prod.example.com) + #[arg(long)] + url: String, + }, +} + #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { let _t = common::init_tracing("ciadm")?; @@ -95,6 +111,12 @@ async fn main() -> Result<()> { // TODO: Query orchestrator for job status println!("Job {job_id} status: PENDING (stub)"); } + Commands::Config { command } => match command { + ConfigCommand::SetBaseUrl { url } => { + save_logs_base_url(&url)?; + println!("Saved LOGS_BASE_URL to {}", config_path()?.display()); + } + }, Commands::Jobs { logs_base_url, repo, @@ -121,7 +143,15 @@ async fn main() -> Result<()> { } fn resolve_logs_base_url(arg: Option) -> Result { - arg.ok_or_else(|| miette::miette!("LOGS_BASE_URL is required (set via env or --logs-base-url)")) + if let Some(arg) = arg { + return Ok(arg); + } + if let Some(saved) = load_logs_base_url()? { + return Ok(saved); + } + Err(miette::miette!( + "LOGS_BASE_URL is required (set via env, --logs-base-url, or ciadm config set-base-url)" + )) } fn resolve_repo_url(arg: Option) -> Option { @@ -147,9 +177,57 @@ fn detect_git_remote() -> Option { } } +fn config_path() -> Result { + let base = if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(dir) + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".config") + } else if let Ok(home) = std::env::var("USERPROFILE") { + PathBuf::from(home).join(".config") + } else { + return Err(miette::miette!("Unable to determine home directory for config storage")); + }; + Ok(base.join("solstice").join("ciadm.conf")) +} + +fn save_logs_base_url(url: &str) -> Result<()> { + let path = config_path()?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).into_diagnostic()?; + } + let mut doc = kdl::KdlDocument::new(); + let mut node = kdl::KdlNode::new("logs_base_url"); + node.entries_mut().push(kdl::KdlEntry::new(url)); + doc.nodes_mut().push(node); + std::fs::write(&path, doc.to_string()).into_diagnostic()?; + Ok(()) +} + +fn load_logs_base_url() -> Result> { + let path = config_path()?; + let Ok(contents) = std::fs::read_to_string(&path) else { + return Ok(None); + }; + let doc: kdl::KdlDocument = contents.parse().into_diagnostic()?; + let Some(node) = doc.get("logs_base_url") else { + return Ok(None); + }; + let Some(value) = node.entries().first().and_then(|entry| entry.value().as_string()) else { + return Ok(None); + }; + if value.trim().is_empty() { + return Ok(None); + } + return Ok(Some(value.to_string())); +} + async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> { - let client = LogsClient::new(base_url)?; - let groups = client.list_jobs().await?; + let client = LogsClient::new(base_url) + .map_err(|err| miette::Report::msg(err.to_string()))?; + let groups = client + .list_jobs() + .await + .map_err(|err| miette::Report::msg(err.to_string()))?; let groups = filter_groups_by_repo(groups, repo); if groups.is_empty() { println!("No jobs found."); @@ -176,12 +254,20 @@ async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> { } async fn cmd_logs(base_url: &str, job_id: &str, category: Option<&str>) -> Result<()> { - let client = LogsClient::new(base_url)?; + let client = LogsClient::new(base_url) + .map_err(|err| miette::Report::msg(err.to_string()))?; let request_id = uuid::Uuid::parse_str(job_id).into_diagnostic()?; let text = if let Some(cat) = category { - client.get_logs_by_category(request_id, cat).await? + client + .get_logs_by_category(request_id, cat) + .await + .map_err(|err| miette::Report::msg(err.to_string()))? } else { - client.get_default_logs(request_id).await?.0 + client + .get_default_logs(request_id) + .await + .map_err(|err| miette::Report::msg(err.to_string()))? + .0 }; print!("{text}"); Ok(()) @@ -207,7 +293,7 @@ async fn run_tui_loop( let mut tick = tokio::time::interval(Duration::from_millis(200)); loop { - terminal.draw(|frame| app.draw(frame))?; + terminal.draw(|frame| app.draw(frame)).into_diagnostic()?; tokio::select! { _ = tick.tick() => {} maybe_event = events.next() => { @@ -222,6 +308,7 @@ async fn run_tui_loop( warn!(error = %e, "failed to read terminal event"); } None => break, + _ => todo!(), } } } @@ -303,6 +390,8 @@ struct TuiApp { selected_job: usize, logs_text: String, logs_category: Option, + logs_categories: Vec, + selected_category: usize, logs_lines: usize, logs_scroll: u16, status: String, @@ -320,7 +409,8 @@ struct JobEntry { impl TuiApp { fn new(base_url: &str, repo_hint: Option) -> Result { Ok(Self { - client: LogsClient::new(base_url)?, + client: LogsClient::new(base_url) + .map_err(|err| miette::Report::msg(err.to_string()))?, repo_hint, repos: Vec::new(), selected_repo: 0, @@ -328,6 +418,8 @@ impl TuiApp { selected_job: 0, logs_text: String::new(), logs_category: None, + logs_categories: Vec::new(), + selected_category: 0, logs_lines: 0, logs_scroll: 0, status: String::new(), @@ -335,7 +427,11 @@ impl TuiApp { } async fn refresh_jobs(&mut self) -> Result<()> { - let groups = self.client.list_jobs().await?; + let groups = self + .client + .list_jobs() + .await + .map_err(|err| miette::Report::msg(err.to_string()))?; let mut repos_map: BTreeMap> = BTreeMap::new(); for group in groups { let entries = group @@ -384,14 +480,40 @@ impl TuiApp { let Some(job) = self.jobs.get(self.selected_job) else { self.logs_text = "No job selected.".to_string(); self.logs_category = None; + self.logs_categories.clear(); + self.selected_category = 0; self.logs_lines = self.logs_text.lines().count(); self.logs_scroll = 0; return Ok(()); }; - match self.client.get_default_logs(job.request_id).await { - Ok((text, category)) => { - self.logs_text = text; - self.logs_category = category; + match self.client.list_log_categories(job.request_id).await { + Ok(categories) => { + self.logs_categories = categories + .into_iter() + .map(|c| c.category) + .collect(); + if self.logs_categories.is_empty() { + self.logs_text = "No logs available.".to_string(); + self.logs_category = None; + self.selected_category = 0; + } else { + if let Some(idx) = self + .logs_categories + .iter() + .position(|c| c == "default") + { + self.selected_category = idx; + } else if self.selected_category >= self.logs_categories.len() { + self.selected_category = 0; + } + let category = self.logs_categories[self.selected_category].clone(); + let text = self + .client + .get_logs_by_category(job.request_id, &category) + .await?; + self.logs_text = text; + self.logs_category = Some(category); + } self.logs_lines = self.logs_text.lines().count(); self.logs_scroll = 0; self.status.clear(); @@ -400,6 +522,8 @@ impl TuiApp { let report = miette::Report::new(err); self.logs_text = format!("{report}"); self.logs_category = None; + self.logs_categories.clear(); + self.selected_category = 0; self.logs_lines = self.logs_text.lines().count(); self.logs_scroll = 0; self.status = "Failed to load logs.".to_string(); @@ -417,8 +541,7 @@ impl TuiApp { (KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('r'), _) => { if let Err(err) = self.refresh_jobs().await { - let report = miette::Report::new(err); - self.status = format!("{report}"); + self.status = format!("{err}"); } else { self.status = "Refreshed jobs.".to_string(); } @@ -440,6 +563,7 @@ impl TuiApp { if self.selected_repo > 0 { self.selected_repo -= 1; self.selected_job = 0; + self.selected_category = 0; self.refresh_jobs().await?; self.refresh_logs().await?; } @@ -448,10 +572,41 @@ impl TuiApp { if self.selected_repo + 1 < self.repos.len() { self.selected_repo += 1; self.selected_job = 0; + self.selected_category = 0; self.refresh_jobs().await?; self.refresh_logs().await?; } } + (KeyCode::Tab, _) => { + if !self.logs_categories.is_empty() { + self.selected_category = + (self.selected_category + 1) % self.logs_categories.len(); + if let Some(job) = self.jobs.get(self.selected_job) { + let category = self.logs_categories[self.selected_category].clone(); + match self + .client + .get_logs_by_category(job.request_id, &category) + .await + { + Ok(text) => { + self.logs_text = text; + self.logs_category = Some(category); + self.logs_lines = self.logs_text.lines().count(); + self.logs_scroll = 0; + self.status.clear(); + } + Err(err) => { + let report = miette::Report::new(err); + self.logs_text = format!("{report}"); + self.logs_category = None; + self.logs_lines = self.logs_text.lines().count(); + self.logs_scroll = 0; + self.status = "Failed to load logs.".to_string(); + } + } + } + } + } (KeyCode::PageUp, _) => { self.logs_scroll = self.logs_scroll.saturating_sub(5); } @@ -520,8 +675,15 @@ impl TuiApp { .iter() .map(|job| { let runs_on = job.runs_on.clone().unwrap_or_else(|| "-".to_string()); + let request = format!("{}", job.request_id); + let request_short = if request.len() > 8 { + &request[..8] + } else { + request.as_str() + }; let line = format!( - "{} {:<10} {:<8} {}", + "{} {:<8} {:<10} {:<8} {}", + request_short, short_sha(&job.commit_sha), job.state, runs_on, @@ -562,7 +724,7 @@ impl TuiApp { } fn draw_footer(&self, frame: &mut Frame, area: Rect) { - let help = "Up/Down: jobs Left/Right: repo PgUp/PgDn: scroll r: refresh q: quit"; + let help = "Up/Down: jobs Left/Right: repo Tab: category PgUp/PgDn: scroll r: refresh q: quit"; let mut line = vec![Span::raw(help)]; if !self.status.is_empty() { line.push(Span::raw(" ")); diff --git a/crates/logs-client/src/lib.rs b/crates/logs-client/src/lib.rs index 2852578..ec164ab 100644 --- a/crates/logs-client/src/lib.rs +++ b/crates/logs-client/src/lib.rs @@ -42,13 +42,13 @@ pub struct LogCategorySummary { pub enum LogsClientError { #[error("invalid logs base URL")] #[diagnostic( - code(logs_client.invalid_base_url), + code("logs_client.invalid_base_url"), help("Set LOGS_BASE_URL to the logs-service base URL, e.g. https://logs.prod.example.com") )] InvalidBaseUrl(#[source] url::ParseError), #[error("request failed for {url}")] #[diagnostic( - code(logs_client.request_failed), + code("logs_client.request_failed"), help("Check network connectivity and TLS configuration for the logs service.") )] Request { @@ -58,7 +58,7 @@ pub enum LogsClientError { }, #[error("logs service returned {status} for {url}")] #[diagnostic( - code(logs_client.http_status), + code("logs_client.http_status"), help("Inspect the response body for details and verify the logs-service is reachable.") )] HttpStatus { @@ -68,7 +68,7 @@ pub enum LogsClientError { }, #[error("failed to parse JSON from {url}")] #[diagnostic( - code(logs_client.json_decode), + code("logs_client.json_decode"), help("Confirm the logs-service response matches the expected schema.") )] Json {