Add TUI support and logs-client crate for enhanced job and log management

- Introduce a Terminal User Interface (TUI) to enable interactive browsing of jobs and logs.
- Add a new `logs-client` crate to handle communication with the logs service, including job listing and log retrieval.
- Extend `ciadm` to include new commands: `jobs`, `logs`, and `tui`, for interacting with the logs service.
- Enhance the CLI to support repository filtering, job status retrieval, and detailed log viewing.
- Refactor dependencies and organize logs-related functionality for modularity and reusability.

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2026-01-25 22:31:11 +01:00
parent 4c5a8567a4
commit 9306de0acf
No known key found for this signature in database
5 changed files with 745 additions and 2 deletions

1
.idea/solstice-ci.iml generated
View file

@ -12,6 +12,7 @@
<sourceFolder url="file://$MODULE_DIR$/crates/workflow-runner/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/crates/migration/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/crates/logs-service/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/crates/logs-client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/crates/webhook/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>

View file

@ -8,4 +8,10 @@ clap = { version = "4", features = ["derive", "env"] }
miette = { version = "7", features = ["fancy"] }
tracing = "0.1"
common = { path = "../common" }
logs-client = { path = "../logs-client" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
ratatui = "0.29"
crossterm = { version = "0.28", features = ["event-stream"] }
futures-util = "0.3"
chrono = "0.4"
uuid = { version = "1", features = ["v4"] }

View file

@ -1,6 +1,21 @@
use std::collections::BTreeMap;
use std::io::{self, Stdout};
use std::process::Command;
use std::time::Duration;
use clap::{Parser, Subcommand};
use miette::Result;
use tracing::info;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, terminal};
use futures_util::StreamExt;
use logs_client::{JobGroup, LogsClient};
use miette::{IntoDiagnostic, Result};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use tracing::{info, warn};
#[derive(Parser, Debug)]
#[command(name = "ciadm", version, about = "Solstice CI Admin CLI")]
@ -29,6 +44,36 @@ enum Commands {
#[arg(long)]
job_id: String,
},
/// List recent jobs from the logs service
Jobs {
/// Logs service base URL (e.g., https://logs.prod.example.com)
#[arg(long, env = "LOGS_BASE_URL")]
logs_base_url: Option<String>,
/// Filter to a repository URL (defaults to git remote origin)
#[arg(long)]
repo: Option<String>,
},
/// Fetch logs for a specific job
Logs {
/// Logs service base URL (e.g., https://logs.prod.example.com)
#[arg(long, env = "LOGS_BASE_URL")]
logs_base_url: Option<String>,
/// Job request ID
#[arg(long)]
job_id: String,
/// Log category (defaults to "default" or first category)
#[arg(long)]
category: Option<String>,
},
/// Interactive TUI for browsing jobs and logs
Tui {
/// Logs service base URL (e.g., https://logs.prod.example.com)
#[arg(long, env = "LOGS_BASE_URL")]
logs_base_url: Option<String>,
/// Preferred repository URL (defaults to git remote origin)
#[arg(long)]
repo: Option<String>,
},
}
#[tokio::main(flavor = "multi_thread")]
@ -50,6 +95,483 @@ async fn main() -> Result<()> {
// TODO: Query orchestrator for job status
println!("Job {job_id} status: PENDING (stub)");
}
Commands::Jobs {
logs_base_url,
repo,
} => {
let base_url = resolve_logs_base_url(logs_base_url)?;
let repo = resolve_repo_url(repo);
cmd_jobs(&base_url, repo.as_deref()).await?;
}
Commands::Logs {
logs_base_url,
job_id,
category,
} => {
let base_url = resolve_logs_base_url(logs_base_url)?;
cmd_logs(&base_url, &job_id, category.as_deref()).await?;
}
Commands::Tui { logs_base_url, repo } => {
let base_url = resolve_logs_base_url(logs_base_url)?;
let repo = resolve_repo_url(repo);
run_tui(&base_url, repo).await?;
}
}
Ok(())
}
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)"))
}
fn resolve_repo_url(arg: Option<String>) -> Option<String> {
if arg.is_some() {
return arg;
}
detect_git_remote()
}
fn detect_git_remote() -> Option<String> {
let output = Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> {
let client = LogsClient::new(base_url)?;
let groups = client.list_jobs().await?;
let groups = filter_groups_by_repo(groups, repo);
if groups.is_empty() {
println!("No jobs found.");
return Ok(());
}
for group in groups {
println!(
"{} {} ({} jobs)",
short_repo(&group.repo_url),
short_sha(&group.commit_sha),
group.total_jobs
);
for job in group.jobs {
println!(
" {} {:<10} {:<10} {}",
job.request_id,
job.state,
job.runs_on.clone().unwrap_or_else(|| "-".to_string()),
job.updated_at.to_rfc3339()
);
}
}
Ok(())
}
async fn cmd_logs(base_url: &str, job_id: &str, category: Option<&str>) -> Result<()> {
let client = LogsClient::new(base_url)?;
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?
} else {
client.get_default_logs(request_id).await?.0
};
print!("{text}");
Ok(())
}
async fn run_tui(base_url: &str, repo_hint: Option<String>) -> Result<()> {
let mut terminal = setup_terminal()?;
let res = run_tui_loop(base_url, repo_hint, &mut terminal).await;
restore_terminal(terminal)?;
res
}
async fn run_tui_loop(
base_url: &str,
repo_hint: Option<String>,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<Stdout>>,
) -> Result<()> {
let mut app = TuiApp::new(base_url, repo_hint)?;
app.refresh_jobs().await?;
app.refresh_logs().await?;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(200));
loop {
terminal.draw(|frame| app.draw(frame))?;
tokio::select! {
_ = tick.tick() => {}
maybe_event = events.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) => {
if app.handle_key(key).await? {
break;
}
}
Some(Ok(Event::Resize(_, _))) => {}
Some(Err(e)) => {
warn!(error = %e, "failed to read terminal event");
}
None => break,
}
}
}
}
Ok(())
}
fn setup_terminal() -> Result<Terminal<ratatui::backend::CrosstermBackend<Stdout>>> {
terminal::enable_raw_mode().into_diagnostic()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).into_diagnostic()?;
let backend = ratatui::backend::CrosstermBackend::new(stdout);
Terminal::new(backend).into_diagnostic()
}
fn restore_terminal(mut terminal: Terminal<ratatui::backend::CrosstermBackend<Stdout>>) -> Result<()> {
terminal::disable_raw_mode().into_diagnostic()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen).into_diagnostic()?;
terminal.show_cursor().into_diagnostic()?;
Ok(())
}
fn filter_groups_by_repo(groups: Vec<JobGroup>, repo: Option<&str>) -> Vec<JobGroup> {
if let Some(repo) = repo {
groups
.into_iter()
.filter(|g| g.repo_url == repo)
.collect()
} else {
groups
}
}
fn short_sha(sha: &str) -> String {
if sha.len() > 7 {
sha[..7].to_string()
} else {
sha.to_string()
}
}
fn short_repo(repo_url: &str) -> String {
let trimmed = repo_url.trim_end_matches(".git");
if let Some(rest) = trimmed.strip_prefix("https://") {
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() >= 3 {
return format!("{}/{}", parts[1], parts[2]);
}
}
if let Some(rest) = trimmed.strip_prefix("http://") {
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() >= 3 {
return format!("{}/{}", parts[1], parts[2]);
}
}
if let Some(rest) = trimmed.strip_prefix("ssh://") {
let after_host = rest.splitn(2, '/').nth(1).unwrap_or("");
let parts: Vec<&str> = after_host.split('/').collect();
if parts.len() >= 2 {
return format!("{}/{}", parts[0], parts[1]);
}
}
if let Some(idx) = trimmed.find(':') {
let after = &trimmed[idx + 1..];
let parts: Vec<&str> = after.split('/').collect();
if parts.len() >= 2 {
return format!("{}/{}", parts[0], parts[1]);
}
}
trimmed.to_string()
}
struct TuiApp {
client: LogsClient,
repo_hint: Option<String>,
repos: Vec<String>,
selected_repo: usize,
jobs: Vec<JobEntry>,
selected_job: usize,
logs_text: String,
logs_category: Option<String>,
logs_lines: usize,
logs_scroll: u16,
status: String,
}
#[derive(Clone)]
struct JobEntry {
request_id: uuid::Uuid,
commit_sha: String,
state: String,
runs_on: Option<String>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl TuiApp {
fn new(base_url: &str, repo_hint: Option<String>) -> Result<Self> {
Ok(Self {
client: LogsClient::new(base_url)?,
repo_hint,
repos: Vec::new(),
selected_repo: 0,
jobs: Vec::new(),
selected_job: 0,
logs_text: String::new(),
logs_category: None,
logs_lines: 0,
logs_scroll: 0,
status: String::new(),
})
}
async fn refresh_jobs(&mut self) -> Result<()> {
let groups = self.client.list_jobs().await?;
let mut repos_map: BTreeMap<String, Vec<JobEntry>> = BTreeMap::new();
for group in groups {
let entries = group
.jobs
.into_iter()
.map(|job| JobEntry {
request_id: job.request_id,
commit_sha: group.commit_sha.clone(),
state: job.state,
runs_on: job.runs_on,
updated_at: job.updated_at,
});
repos_map
.entry(group.repo_url)
.or_default()
.extend(entries);
}
let mut repos: Vec<String> = repos_map.keys().cloned().collect();
repos.sort();
self.repos = repos;
if let Some(hint) = self.repo_hint.as_deref() {
if let Some(idx) = self.repos.iter().position(|r| r == hint) {
self.selected_repo = idx;
}
self.repo_hint = None;
}
if self.selected_repo >= self.repos.len() {
self.selected_repo = 0;
}
self.jobs = self
.current_repo()
.and_then(|repo| repos_map.get(repo))
.cloned()
.unwrap_or_default();
self.jobs.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
if self.selected_job >= self.jobs.len() {
self.selected_job = 0;
}
if self.repos.is_empty() {
self.status = "No repositories found in logs service.".to_string();
}
Ok(())
}
async fn refresh_logs(&mut self) -> Result<()> {
let Some(job) = self.jobs.get(self.selected_job) else {
self.logs_text = "No job selected.".to_string();
self.logs_category = None;
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;
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();
}
}
Ok(())
}
fn current_repo(&self) -> Option<&String> {
self.repos.get(self.selected_repo)
}
async fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
match (key.code, key.modifiers) {
(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}");
} else {
self.status = "Refreshed jobs.".to_string();
}
self.refresh_logs().await?;
}
(KeyCode::Up, _) => {
if !self.jobs.is_empty() {
self.selected_job = self.selected_job.saturating_sub(1);
self.refresh_logs().await?;
}
}
(KeyCode::Down, _) => {
if self.selected_job + 1 < self.jobs.len() {
self.selected_job += 1;
self.refresh_logs().await?;
}
}
(KeyCode::Left, _) => {
if self.selected_repo > 0 {
self.selected_repo -= 1;
self.selected_job = 0;
self.refresh_jobs().await?;
self.refresh_logs().await?;
}
}
(KeyCode::Right, _) => {
if self.selected_repo + 1 < self.repos.len() {
self.selected_repo += 1;
self.selected_job = 0;
self.refresh_jobs().await?;
self.refresh_logs().await?;
}
}
(KeyCode::PageUp, _) => {
self.logs_scroll = self.logs_scroll.saturating_sub(5);
}
(KeyCode::PageDown, _) => {
let max_scroll = self.logs_lines.saturating_sub(1) as u16;
self.logs_scroll = (self.logs_scroll + 5).min(max_scroll);
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(true),
_ => {}
}
Ok(false)
}
fn draw(&self, frame: &mut Frame) {
let size = frame.area();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5), Constraint::Length(2)])
.split(size);
self.draw_header(frame, layout[0]);
self.draw_body(frame, layout[1]);
self.draw_footer(frame, layout[2]);
}
fn draw_header(&self, frame: &mut Frame, area: Rect) {
let repo_name = self
.current_repo()
.map(|r| short_repo(r))
.unwrap_or_else(|| "-".to_string());
let job_info = self.jobs.get(self.selected_job).map(|job| {
format!(
"{} {} {}",
short_sha(&job.commit_sha),
job.state,
job.runs_on.clone().unwrap_or_else(|| "-".to_string())
)
});
let mut spans = vec![
Span::styled("Repo: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(repo_name),
];
if let Some(info) = job_info {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"Job: ",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(info));
}
let title = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
frame.render_widget(title, area);
}
fn draw_body(&self, frame: &mut Frame, area: Rect) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(area);
self.draw_jobs(frame, columns[0]);
self.draw_logs(frame, columns[1]);
}
fn draw_jobs(&self, frame: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self
.jobs
.iter()
.map(|job| {
let runs_on = job.runs_on.clone().unwrap_or_else(|| "-".to_string());
let line = format!(
"{} {:<10} {:<8} {}",
short_sha(&job.commit_sha),
job.state,
runs_on,
job.updated_at.format("%H:%M:%S")
);
ListItem::new(line)
})
.collect();
let mut state = ratatui::widgets::ListState::default();
state.select(Some(self.selected_job));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Jobs"))
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_logs(&self, frame: &mut Frame, area: Rect) {
let title = match &self.logs_category {
Some(cat) => format!("Logs ({cat})"),
None => "Logs".to_string(),
};
let text = if self.logs_text.is_empty() {
Text::from("No logs available.")
} else {
Text::from(self.logs_text.as_str())
};
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false })
.scroll((self.logs_scroll, 0));
frame.render_widget(paragraph, area);
}
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 mut line = vec![Span::raw(help)];
if !self.status.is_empty() {
line.push(Span::raw(" "));
line.push(Span::styled(
&self.status,
Style::default().fg(Color::Yellow),
));
}
let footer = Paragraph::new(Line::from(line)).block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, area);
}
}

View file

@ -0,0 +1,14 @@
[package]
name = "logs-client"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
miette = { version = "7", features = ["fancy"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }

View file

@ -0,0 +1,200 @@
use chrono::{DateTime, Utc};
use miette::Diagnostic;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use thiserror::Error;
use url::Url;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobLinks {
pub logs: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobSummary {
pub request_id: Uuid,
pub runs_on: Option<String>,
pub state: String,
pub updated_at: DateTime<Utc>,
pub links: JobLinks,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobGroup {
pub repo_url: String,
pub commit_sha: String,
pub last_updated: DateTime<Utc>,
pub total_jobs: usize,
pub jobs: Vec<JobSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogCategorySummary {
pub category: String,
pub count: i64,
pub has_errors: bool,
pub first_ts: DateTime<Utc>,
pub last_ts: DateTime<Utc>,
}
#[derive(Debug, Error, Diagnostic)]
pub enum LogsClientError {
#[error("invalid logs base URL")]
#[diagnostic(
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),
help("Check network connectivity and TLS configuration for the logs service.")
)]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("logs service returned {status} for {url}")]
#[diagnostic(
code(logs_client.http_status),
help("Inspect the response body for details and verify the logs-service is reachable.")
)]
HttpStatus {
status: StatusCode,
url: String,
body: String,
},
#[error("failed to parse JSON from {url}")]
#[diagnostic(
code(logs_client.json_decode),
help("Confirm the logs-service response matches the expected schema.")
)]
Json {
url: String,
#[source]
source: serde_json::Error,
},
}
pub type Result<T> = std::result::Result<T, LogsClientError>;
#[derive(Clone)]
pub struct LogsClient {
base_url: Url,
client: reqwest::Client,
}
impl LogsClient {
pub fn new(base_url: &str) -> Result<Self> {
let base_url = Url::parse(base_url).map_err(LogsClientError::InvalidBaseUrl)?;
Ok(Self {
base_url,
client: reqwest::Client::new(),
})
}
pub async fn list_jobs(&self) -> Result<Vec<JobGroup>> {
let url = self.base_url.join("jobs").map_err(LogsClientError::InvalidBaseUrl)?;
self.get_json(url).await
}
pub async fn list_log_categories(&self, request_id: Uuid) -> Result<Vec<LogCategorySummary>> {
let url = self.job_logs_url(request_id, None)?;
self.get_json(url).await
}
pub async fn get_logs_by_category(
&self,
request_id: Uuid,
category: &str,
) -> Result<String> {
let url = self.job_logs_url(request_id, Some(category))?;
self.get_text(url).await
}
pub async fn get_default_logs(&self, request_id: Uuid) -> Result<(String, Option<String>)> {
let categories = self.list_log_categories(request_id).await?;
let category = categories
.iter()
.find(|c| c.category == "default")
.map(|c| c.category.clone())
.or_else(|| categories.first().map(|c| c.category.clone()));
let Some(category) = category else {
return Ok(("".to_string(), None));
};
let text = self.get_logs_by_category(request_id, &category).await?;
Ok((text, Some(category)))
}
async fn get_json<T: DeserializeOwned>(&self, url: Url) -> Result<T> {
let resp = self
.client
.get(url.clone())
.send()
.await
.map_err(|e| LogsClientError::Request {
url: url.to_string(),
source: e,
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| LogsClientError::Request {
url: url.to_string(),
source: e,
})?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes).to_string();
return Err(LogsClientError::HttpStatus {
status,
url: url.to_string(),
body,
});
}
serde_json::from_slice(&bytes).map_err(|e| LogsClientError::Json {
url: url.to_string(),
source: e,
})
}
async fn get_text(&self, url: Url) -> Result<String> {
let resp = self
.client
.get(url.clone())
.send()
.await
.map_err(|e| LogsClientError::Request {
url: url.to_string(),
source: e,
})?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(|e| LogsClientError::Request {
url: url.to_string(),
source: e,
})?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes).to_string();
return Err(LogsClientError::HttpStatus {
status,
url: url.to_string(),
body,
});
}
Ok(String::from_utf8_lossy(&bytes).to_string())
}
fn job_logs_url(&self, request_id: Uuid, category: Option<&str>) -> Result<Url> {
let mut url = self.base_url.clone();
{
let mut segments = url
.path_segments_mut()
.map_err(|_| LogsClientError::InvalidBaseUrl(url::ParseError::RelativeUrlWithoutBase))?;
segments.clear();
segments.extend(&["jobs", &request_id.to_string(), "logs"]);
if let Some(cat) = category {
segments.push(cat);
}
}
Ok(url)
}
}