Add configuration support and enhance logs handling in TUI

- Introduce `Config` command to manage local `ciadm` settings, including `set-base-url` for persisting logs-service URLs.
- Improve TUI with log category selection and navigation using the Tab key.
- Refactor logs retrieval to support category-based display and enhance error handling.
- Add local configuration file utilities for storing and loading settings.
- Update dependencies to include the `kdl` crate for configuration management.

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2026-01-25 22:54:35 +01:00
parent 9306de0acf
commit e76f4c0278
No known key found for this signature in database
3 changed files with 184 additions and 21 deletions

View file

@ -15,3 +15,4 @@ crossterm = { version = "0.28", features = ["event-stream"] }
futures-util = "0.3" futures-util = "0.3"
chrono = "0.4" chrono = "0.4"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
kdl = "6"

View file

@ -1,5 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io::{self, Stdout}; use std::io::{self, Stdout};
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::time::Duration; use std::time::Duration;
@ -44,6 +45,11 @@ enum Commands {
#[arg(long)] #[arg(long)]
job_id: String, job_id: String,
}, },
/// Configure local CLI settings
Config {
#[command(subcommand)]
command: ConfigCommand,
},
/// List recent jobs from the logs service /// List recent jobs from the logs service
Jobs { Jobs {
/// Logs service base URL (e.g., https://logs.prod.example.com) /// 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")] #[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let _t = common::init_tracing("ciadm")?; let _t = common::init_tracing("ciadm")?;
@ -95,6 +111,12 @@ async fn main() -> Result<()> {
// TODO: Query orchestrator for job status // TODO: Query orchestrator for job status
println!("Job {job_id} status: PENDING (stub)"); 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 { Commands::Jobs {
logs_base_url, logs_base_url,
repo, repo,
@ -121,7 +143,15 @@ async fn main() -> Result<()> {
} }
fn resolve_logs_base_url(arg: Option<String>) -> Result<String> { fn resolve_logs_base_url(arg: Option<String>) -> Result<String> {
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<String>) -> Option<String> { fn resolve_repo_url(arg: Option<String>) -> Option<String> {
@ -147,9 +177,57 @@ fn detect_git_remote() -> Option<String> {
} }
} }
fn config_path() -> Result<PathBuf> {
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<Option<String>> {
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<()> { async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> {
let client = LogsClient::new(base_url)?; let client = LogsClient::new(base_url)
let groups = client.list_jobs().await?; .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); let groups = filter_groups_by_repo(groups, repo);
if groups.is_empty() { if groups.is_empty() {
println!("No jobs found."); 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<()> { 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 request_id = uuid::Uuid::parse_str(job_id).into_diagnostic()?;
let text = if let Some(cat) = category { 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 { } 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}"); print!("{text}");
Ok(()) Ok(())
@ -207,7 +293,7 @@ async fn run_tui_loop(
let mut tick = tokio::time::interval(Duration::from_millis(200)); let mut tick = tokio::time::interval(Duration::from_millis(200));
loop { loop {
terminal.draw(|frame| app.draw(frame))?; terminal.draw(|frame| app.draw(frame)).into_diagnostic()?;
tokio::select! { tokio::select! {
_ = tick.tick() => {} _ = tick.tick() => {}
maybe_event = events.next() => { maybe_event = events.next() => {
@ -222,6 +308,7 @@ async fn run_tui_loop(
warn!(error = %e, "failed to read terminal event"); warn!(error = %e, "failed to read terminal event");
} }
None => break, None => break,
_ => todo!(),
} }
} }
} }
@ -303,6 +390,8 @@ struct TuiApp {
selected_job: usize, selected_job: usize,
logs_text: String, logs_text: String,
logs_category: Option<String>, logs_category: Option<String>,
logs_categories: Vec<String>,
selected_category: usize,
logs_lines: usize, logs_lines: usize,
logs_scroll: u16, logs_scroll: u16,
status: String, status: String,
@ -320,7 +409,8 @@ struct JobEntry {
impl TuiApp { impl TuiApp {
fn new(base_url: &str, repo_hint: Option<String>) -> Result<Self> { fn new(base_url: &str, repo_hint: Option<String>) -> Result<Self> {
Ok(Self { Ok(Self {
client: LogsClient::new(base_url)?, client: LogsClient::new(base_url)
.map_err(|err| miette::Report::msg(err.to_string()))?,
repo_hint, repo_hint,
repos: Vec::new(), repos: Vec::new(),
selected_repo: 0, selected_repo: 0,
@ -328,6 +418,8 @@ impl TuiApp {
selected_job: 0, selected_job: 0,
logs_text: String::new(), logs_text: String::new(),
logs_category: None, logs_category: None,
logs_categories: Vec::new(),
selected_category: 0,
logs_lines: 0, logs_lines: 0,
logs_scroll: 0, logs_scroll: 0,
status: String::new(), status: String::new(),
@ -335,7 +427,11 @@ impl TuiApp {
} }
async fn refresh_jobs(&mut self) -> Result<()> { 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<String, Vec<JobEntry>> = BTreeMap::new(); let mut repos_map: BTreeMap<String, Vec<JobEntry>> = BTreeMap::new();
for group in groups { for group in groups {
let entries = group let entries = group
@ -384,14 +480,40 @@ impl TuiApp {
let Some(job) = self.jobs.get(self.selected_job) else { let Some(job) = self.jobs.get(self.selected_job) else {
self.logs_text = "No job selected.".to_string(); self.logs_text = "No job selected.".to_string();
self.logs_category = None; self.logs_category = None;
self.logs_categories.clear();
self.selected_category = 0;
self.logs_lines = self.logs_text.lines().count(); self.logs_lines = self.logs_text.lines().count();
self.logs_scroll = 0; self.logs_scroll = 0;
return Ok(()); return Ok(());
}; };
match self.client.get_default_logs(job.request_id).await { match self.client.list_log_categories(job.request_id).await {
Ok((text, category)) => { 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_text = text;
self.logs_category = category; self.logs_category = Some(category);
}
self.logs_lines = self.logs_text.lines().count(); self.logs_lines = self.logs_text.lines().count();
self.logs_scroll = 0; self.logs_scroll = 0;
self.status.clear(); self.status.clear();
@ -400,6 +522,8 @@ impl TuiApp {
let report = miette::Report::new(err); let report = miette::Report::new(err);
self.logs_text = format!("{report}"); self.logs_text = format!("{report}");
self.logs_category = None; self.logs_category = None;
self.logs_categories.clear();
self.selected_category = 0;
self.logs_lines = self.logs_text.lines().count(); self.logs_lines = self.logs_text.lines().count();
self.logs_scroll = 0; self.logs_scroll = 0;
self.status = "Failed to load logs.".to_string(); self.status = "Failed to load logs.".to_string();
@ -417,8 +541,7 @@ impl TuiApp {
(KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('q'), _) => return Ok(true),
(KeyCode::Char('r'), _) => { (KeyCode::Char('r'), _) => {
if let Err(err) = self.refresh_jobs().await { if let Err(err) = self.refresh_jobs().await {
let report = miette::Report::new(err); self.status = format!("{err}");
self.status = format!("{report}");
} else { } else {
self.status = "Refreshed jobs.".to_string(); self.status = "Refreshed jobs.".to_string();
} }
@ -440,6 +563,7 @@ impl TuiApp {
if self.selected_repo > 0 { if self.selected_repo > 0 {
self.selected_repo -= 1; self.selected_repo -= 1;
self.selected_job = 0; self.selected_job = 0;
self.selected_category = 0;
self.refresh_jobs().await?; self.refresh_jobs().await?;
self.refresh_logs().await?; self.refresh_logs().await?;
} }
@ -448,10 +572,41 @@ impl TuiApp {
if self.selected_repo + 1 < self.repos.len() { if self.selected_repo + 1 < self.repos.len() {
self.selected_repo += 1; self.selected_repo += 1;
self.selected_job = 0; self.selected_job = 0;
self.selected_category = 0;
self.refresh_jobs().await?; self.refresh_jobs().await?;
self.refresh_logs().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, _) => { (KeyCode::PageUp, _) => {
self.logs_scroll = self.logs_scroll.saturating_sub(5); self.logs_scroll = self.logs_scroll.saturating_sub(5);
} }
@ -520,8 +675,15 @@ impl TuiApp {
.iter() .iter()
.map(|job| { .map(|job| {
let runs_on = job.runs_on.clone().unwrap_or_else(|| "-".to_string()); 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!( let line = format!(
"{} {:<10} {:<8} {}", "{} {:<8} {:<10} {:<8} {}",
request_short,
short_sha(&job.commit_sha), short_sha(&job.commit_sha),
job.state, job.state,
runs_on, runs_on,
@ -562,7 +724,7 @@ impl TuiApp {
} }
fn draw_footer(&self, frame: &mut Frame, area: Rect) { 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)]; let mut line = vec![Span::raw(help)];
if !self.status.is_empty() { if !self.status.is_empty() {
line.push(Span::raw(" ")); line.push(Span::raw(" "));

View file

@ -42,13 +42,13 @@ pub struct LogCategorySummary {
pub enum LogsClientError { pub enum LogsClientError {
#[error("invalid logs base URL")] #[error("invalid logs base URL")]
#[diagnostic( #[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") help("Set LOGS_BASE_URL to the logs-service base URL, e.g. https://logs.prod.example.com")
)] )]
InvalidBaseUrl(#[source] url::ParseError), InvalidBaseUrl(#[source] url::ParseError),
#[error("request failed for {url}")] #[error("request failed for {url}")]
#[diagnostic( #[diagnostic(
code(logs_client.request_failed), code("logs_client.request_failed"),
help("Check network connectivity and TLS configuration for the logs service.") help("Check network connectivity and TLS configuration for the logs service.")
)] )]
Request { Request {
@ -58,7 +58,7 @@ pub enum LogsClientError {
}, },
#[error("logs service returned {status} for {url}")] #[error("logs service returned {status} for {url}")]
#[diagnostic( #[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.") help("Inspect the response body for details and verify the logs-service is reachable.")
)] )]
HttpStatus { HttpStatus {
@ -68,7 +68,7 @@ pub enum LogsClientError {
}, },
#[error("failed to parse JSON from {url}")] #[error("failed to parse JSON from {url}")]
#[diagnostic( #[diagnostic(
code(logs_client.json_decode), code("logs_client.json_decode"),
help("Confirm the logs-service response matches the expected schema.") help("Confirm the logs-service response matches the expected schema.")
)] )]
Json { Json {