mirror of
https://codeberg.org/Toasterson/solstice-ci.git
synced 2026-04-10 13:20:41 +00:00
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:
parent
9306de0acf
commit
e76f4c0278
3 changed files with 184 additions and 21 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
|
|
@ -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<()> {
|
||||
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<String>,
|
||||
logs_categories: Vec<String>,
|
||||
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<String>) -> Result<Self> {
|
||||
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<String, Vec<JobEntry>> = 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(" "));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue