diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94e72b2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install protobuf + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Cargo fmt + run: cargo fmt --all -- --check + + - name: Cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Cargo test + run: cargo test --all + + - name: Cargo build + run: cargo build --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2eceec1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install protobuf (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Install protobuf (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + + - name: Install protobuf (Windows) + if: runner.os == 'Windows' + run: choco install protoc --no-progress + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Upload release binaries + uses: taiki-e/upload-rust-binary@v1 + with: + bin: t9s + target: ${{ matrix.target }} + archive: $bin-$tag-$target diff --git a/src/app.rs b/src/app.rs index 4f0ecbe..f207d17 100644 --- a/src/app.rs +++ b/src/app.rs @@ -681,7 +681,7 @@ impl App { "open" | "goto" => { if let Some(uri) = args { match parse_deep_link(uri) { - Ok(location) => return self.apply_location(location), + Ok(location) => self.apply_location(location), Err(err) => { self.last_error = Some(( format!("invalid uri: {}", format_uri_error(err)), diff --git a/src/client/grpc.rs b/src/client/grpc.rs index 04d65a8..1700f81 100644 --- a/src/client/grpc.rs +++ b/src/client/grpc.rs @@ -18,7 +18,9 @@ struct ApiKeyInterceptor { impl Interceptor for ApiKeyInterceptor { fn call(&mut self, mut request: Request<()>) -> Result, Status> { if let Some(ref token) = self.api_key { - request.metadata_mut().insert("authorization", token.clone()); + request + .metadata_mut() + .insert("authorization", token.clone()); } if let Some(ref ns) = self.namespace { request @@ -29,8 +31,9 @@ impl Interceptor for ApiKeyInterceptor { } } -type InterceptedClient = - WorkflowServiceClient>; +type InterceptedClient = WorkflowServiceClient< + tonic::service::interceptor::InterceptedService, +>; pub struct GrpcTemporalClient { client: InterceptedClient, @@ -65,10 +68,15 @@ impl GrpcTemporalClient { // mTLS client certificates if let (Some(cert_path), Some(key_path)) = (tls_cert, tls_key) { - let cert = std::fs::read(&cert_path) - .map_err(|e| ClientError::ConfigError(format!("failed to read TLS cert {}: {}", cert_path, e)))?; - let key = std::fs::read(&key_path) - .map_err(|e| ClientError::ConfigError(format!("failed to read TLS key {}: {}", key_path, e)))?; + let cert = std::fs::read(&cert_path).map_err(|e| { + ClientError::ConfigError(format!( + "failed to read TLS cert {}: {}", + cert_path, e + )) + })?; + let key = std::fs::read(&key_path).map_err(|e| { + ClientError::ConfigError(format!("failed to read TLS key {}: {}", key_path, e)) + })?; let identity = tonic::transport::Identity::from_pem(cert, key); tls_config = tls_config.identity(identity); } @@ -86,9 +94,9 @@ impl GrpcTemporalClient { tracing::info!("Connected to Temporal successfully"); let interceptor = ApiKeyInterceptor { - api_key: api_key.as_ref().and_then(|key| { - format!("Bearer {}", key).parse::().ok() - }), + api_key: api_key + .as_ref() + .and_then(|key| format!("Bearer {}", key).parse::().ok()), namespace: namespace.parse::().ok(), }; @@ -101,7 +109,10 @@ impl GrpcTemporalClient { Request::new(inner) } - fn wf_execution(workflow_id: &str, run_id: Option<&str>) -> proto::temporal::api::common::v1::WorkflowExecution { + fn wf_execution( + workflow_id: &str, + run_id: Option<&str>, + ) -> proto::temporal::api::common::v1::WorkflowExecution { proto::temporal::api::common::v1::WorkflowExecution { workflow_id: workflow_id.to_string(), run_id: run_id.unwrap_or("").to_string(), @@ -288,11 +299,7 @@ impl TemporalClient for GrpcTemporalClient { Ok(all_events) } - async fn count_workflows( - &self, - namespace: &str, - query: Option<&str>, - ) -> ClientResult { + async fn count_workflows(&self, namespace: &str, query: Option<&str>) -> ClientResult { let inner = proto::CountWorkflowExecutionsRequest { namespace: namespace.to_string(), query: query.unwrap_or("").to_string(), @@ -437,12 +444,8 @@ impl TemporalClient for GrpcTemporalClient { next_run: info .and_then(|i| i.future_action_times.first()) .map(timestamp_to_datetime), - recent_action_count: info - .map(|i| i.recent_actions.len() as u64) - .unwrap_or(0), - notes: info - .map(|i| i.notes.clone()) - .unwrap_or_default(), + recent_action_count: info.map(|i| i.recent_actions.len() as u64).unwrap_or(0), + notes: info.map(|i| i.notes.clone()).unwrap_or_default(), } }) .collect(); @@ -478,9 +481,9 @@ impl TemporalClient for GrpcTemporalClient { .and_then(|s| s.action.as_ref()) .and_then(|a| a.action.as_ref()) .and_then(|a| match a { - proto::temporal::api::schedule::v1::schedule_action::Action::StartWorkflow(wf) => { - wf.workflow_type.as_ref().map(|t| t.name.clone()) - } + proto::temporal::api::schedule::v1::schedule_action::Action::StartWorkflow( + wf, + ) => wf.workflow_type.as_ref().map(|t| t.name.clone()), }) .unwrap_or_default(), state: { @@ -522,8 +525,16 @@ impl TemporalClient for GrpcTemporalClient { namespace: namespace.to_string(), schedule_id: schedule_id.to_string(), patch: Some(proto::temporal::api::schedule::v1::SchedulePatch { - pause: if pause { "paused by t9s".to_string() } else { String::new() }, - unpause: if !pause { "unpaused by t9s".to_string() } else { String::new() }, + pause: if pause { + "paused by t9s".to_string() + } else { + String::new() + }, + unpause: if !pause { + "unpaused by t9s".to_string() + } else { + String::new() + }, ..Default::default() }), identity: "t9s".to_string(), @@ -539,11 +550,7 @@ impl TemporalClient for GrpcTemporalClient { Ok(()) } - async fn trigger_schedule( - &self, - namespace: &str, - schedule_id: &str, - ) -> ClientResult<()> { + async fn trigger_schedule(&self, namespace: &str, schedule_id: &str) -> ClientResult<()> { let inner = proto::PatchScheduleRequest { namespace: namespace.to_string(), schedule_id: schedule_id.to_string(), @@ -569,11 +576,7 @@ impl TemporalClient for GrpcTemporalClient { Ok(()) } - async fn delete_schedule( - &self, - namespace: &str, - schedule_id: &str, - ) -> ClientResult<()> { + async fn delete_schedule(&self, namespace: &str, schedule_id: &str) -> ClientResult<()> { let inner = proto::DeleteScheduleRequest { namespace: namespace.to_string(), schedule_id: schedule_id.to_string(), @@ -710,7 +713,9 @@ fn event_type_name(event_type: i32) -> String { } } -fn decode_payloads(payloads: &Option) -> serde_json::Value { +fn decode_payloads( + payloads: &Option, +) -> serde_json::Value { let Some(payloads) = payloads else { return serde_json::Value::Null; }; @@ -745,19 +750,27 @@ fn decode_payload(payload: &proto::temporal::api::common::v1::Payload) -> serde_ } } -fn decode_failure(failure: &Option) -> serde_json::Value { +fn decode_failure( + failure: &Option, +) -> serde_json::Value { let Some(f) = failure else { return serde_json::Value::Null; }; let mut map = serde_json::Map::new(); if !f.message.is_empty() { - map.insert("message".into(), serde_json::Value::String(f.message.clone())); + map.insert( + "message".into(), + serde_json::Value::String(f.message.clone()), + ); } if !f.source.is_empty() { map.insert("source".into(), serde_json::Value::String(f.source.clone())); } if !f.stack_trace.is_empty() { - map.insert("stack_trace".into(), serde_json::Value::String(f.stack_trace.clone())); + map.insert( + "stack_trace".into(), + serde_json::Value::String(f.stack_trace.clone()), + ); } if let Some(ref cause) = f.cause { map.insert("cause".into(), decode_failure(&Some(*cause.clone()))); @@ -778,10 +791,16 @@ fn extract_event_details( Attributes::WorkflowExecutionStartedEventAttributes(a) => { let mut map = serde_json::Map::new(); if let Some(ref wt) = a.workflow_type { - map.insert("workflow_type".into(), serde_json::Value::String(wt.name.clone())); + map.insert( + "workflow_type".into(), + serde_json::Value::String(wt.name.clone()), + ); } if let Some(ref tq) = a.task_queue { - map.insert("task_queue".into(), serde_json::Value::String(tq.name.clone())); + map.insert( + "task_queue".into(), + serde_json::Value::String(tq.name.clone()), + ); } let input = decode_payloads(&a.input); if !input.is_null() { @@ -808,10 +827,16 @@ fn extract_event_details( Attributes::ActivityTaskScheduledEventAttributes(a) => { let mut map = serde_json::Map::new(); if let Some(ref at) = a.activity_type { - map.insert("activity_type".into(), serde_json::Value::String(at.name.clone())); + map.insert( + "activity_type".into(), + serde_json::Value::String(at.name.clone()), + ); } if let Some(ref tq) = a.task_queue { - map.insert("task_queue".into(), serde_json::Value::String(tq.name.clone())); + map.insert( + "task_queue".into(), + serde_json::Value::String(tq.name.clone()), + ); } let input = decode_payloads(&a.input); if !input.is_null() { @@ -837,7 +862,10 @@ fn extract_event_details( } Attributes::TimerStartedEventAttributes(a) => { let mut map = serde_json::Map::new(); - map.insert("timer_id".into(), serde_json::Value::String(a.timer_id.clone())); + map.insert( + "timer_id".into(), + serde_json::Value::String(a.timer_id.clone()), + ); if let Some(ref d) = a.start_to_fire_timeout { map.insert( "start_to_fire_timeout".into(), @@ -848,12 +876,18 @@ fn extract_event_details( } Attributes::TimerFiredEventAttributes(a) => { let mut map = serde_json::Map::new(); - map.insert("timer_id".into(), serde_json::Value::String(a.timer_id.clone())); + map.insert( + "timer_id".into(), + serde_json::Value::String(a.timer_id.clone()), + ); serde_json::Value::Object(map) } Attributes::WorkflowExecutionSignaledEventAttributes(a) => { let mut map = serde_json::Map::new(); - map.insert("signal_name".into(), serde_json::Value::String(a.signal_name.clone())); + map.insert( + "signal_name".into(), + serde_json::Value::String(a.signal_name.clone()), + ); let input = decode_payloads(&a.input); if !input.is_null() { map.insert("input".into(), input); @@ -873,10 +907,16 @@ fn extract_event_details( Attributes::ChildWorkflowExecutionStartedEventAttributes(a) => { let mut map = serde_json::Map::new(); if let Some(ref wt) = a.workflow_type { - map.insert("workflow_type".into(), serde_json::Value::String(wt.name.clone())); + map.insert( + "workflow_type".into(), + serde_json::Value::String(wt.name.clone()), + ); } if let Some(ref exec) = a.workflow_execution { - map.insert("workflow_id".into(), serde_json::Value::String(exec.workflow_id.clone())); + map.insert( + "workflow_id".into(), + serde_json::Value::String(exec.workflow_id.clone()), + ); } serde_json::Value::Object(map) } @@ -899,9 +939,15 @@ fn extract_event_details( Attributes::StartChildWorkflowExecutionInitiatedEventAttributes(a) => { let mut map = serde_json::Map::new(); if let Some(ref wt) = a.workflow_type { - map.insert("workflow_type".into(), serde_json::Value::String(wt.name.clone())); + map.insert( + "workflow_type".into(), + serde_json::Value::String(wt.name.clone()), + ); } - map.insert("workflow_id".into(), serde_json::Value::String(a.workflow_id.clone())); + map.insert( + "workflow_id".into(), + serde_json::Value::String(a.workflow_id.clone()), + ); let input = decode_payloads(&a.input); if !input.is_null() { map.insert("input".into(), input); diff --git a/src/client/traits.rs b/src/client/traits.rs index 53fd9b6..38904b7 100644 --- a/src/client/traits.rs +++ b/src/client/traits.rs @@ -47,11 +47,7 @@ pub trait TemporalClient: Send + Sync { run_id: Option<&str>, ) -> ClientResult>; - async fn count_workflows( - &self, - namespace: &str, - query: Option<&str>, - ) -> ClientResult; + async fn count_workflows(&self, namespace: &str, query: Option<&str>) -> ClientResult; async fn cancel_workflow( &self, @@ -83,11 +79,8 @@ pub trait TemporalClient: Send + Sync { query: Option<&str>, ) -> ClientResult>; - async fn describe_schedule( - &self, - namespace: &str, - schedule_id: &str, - ) -> ClientResult; + async fn describe_schedule(&self, namespace: &str, schedule_id: &str) + -> ClientResult; async fn patch_schedule( &self, @@ -96,17 +89,9 @@ pub trait TemporalClient: Send + Sync { pause: bool, ) -> ClientResult<()>; - async fn trigger_schedule( - &self, - namespace: &str, - schedule_id: &str, - ) -> ClientResult<()>; + async fn trigger_schedule(&self, namespace: &str, schedule_id: &str) -> ClientResult<()>; - async fn delete_schedule( - &self, - namespace: &str, - schedule_id: &str, - ) -> ClientResult<()>; + async fn delete_schedule(&self, namespace: &str, schedule_id: &str) -> ClientResult<()>; async fn describe_task_queue( &self, diff --git a/src/event.rs b/src/event.rs index 1692a24..f42c226 100644 --- a/src/event.rs +++ b/src/event.rs @@ -113,9 +113,7 @@ pub fn key_to_action( match overlay { Overlay::Help => { return match key.code { - KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { - Some(Action::ToggleHelp) - } + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => Some(Action::ToggleHelp), _ => None, }; } @@ -171,9 +169,7 @@ pub fn key_to_action( } InputMode::Search => { return match key.code { - KeyCode::Esc => { - Some(Action::CloseOverlay) - } + KeyCode::Esc => Some(Action::CloseOverlay), KeyCode::Enter => Some(Action::SubmitSearch(input_buffer.to_string())), KeyCode::Backspace => { let mut buf = input_buffer.to_string(); @@ -213,9 +209,7 @@ pub fn key_to_action( // Global KeyCode::Char('q') => Some(Action::Quit), KeyCode::Char(':') => Some(Action::OpenCommandInput), - KeyCode::Char('/') if matches!(view, View::Collection(_)) => { - Some(Action::OpenSearch) - } + KeyCode::Char('/') if matches!(view, View::Collection(_)) => Some(Action::OpenSearch), KeyCode::Char('?') => Some(Action::ToggleHelp), KeyCode::Char('j') | KeyCode::Down => Some(Action::NavigateDown), KeyCode::Char('k') | KeyCode::Up => Some(Action::NavigateUp), @@ -236,7 +230,10 @@ pub fn key_to_action( Some(Action::OpenWorkflowActivities) } KeyCode::Char('w') - if matches!(view, View::Collection(KindId::Schedule) | View::Detail(KindId::Schedule)) => + if matches!( + view, + View::Collection(KindId::Schedule) | View::Detail(KindId::Schedule) + ) => { Some(Action::OpenScheduleWorkflows) } diff --git a/src/main.rs b/src/main.rs index 0b1d0c1..2ac7e09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,11 @@ use tokio::sync::mpsc; use t9s::action::Action; use t9s::app::{App, ConfirmAction, Effect, InputMode, Overlay, View}; -use t9s::kinds::{detail_spec, operation_effect_spec}; use t9s::client::GrpcTemporalClient; use t9s::config::Cli; use t9s::event::{key_to_action, AppEvent, RawEventHandler}; use t9s::kinds::KindId; +use t9s::kinds::{detail_spec, operation_effect_spec}; use t9s::widgets; use t9s::worker::{CliRequest, CliWorker}; @@ -199,15 +199,14 @@ fn render(app: &mut App, frame: &mut ratatui::Frame) { // Dark navy background frame.render_widget( - ratatui::widgets::Block::default().style( - ratatui::style::Style::default().bg(t9s::theme::BG_DARK), - ), + ratatui::widgets::Block::default() + .style(ratatui::style::Style::default().bg(t9s::theme::BG_DARK)), area, ); let layout = Layout::vertical([ Constraint::Length(1), // Tab bar - Constraint::Fill(1), // Content + Constraint::Fill(1), // Content Constraint::Length(1), // Footer ]) .split(area); @@ -270,11 +269,7 @@ fn render(app: &mut App, frame: &mut ratatui::Frame) { widgets::error_toast::render(app, frame, area); } -fn handle_effects( - effects: Vec, - cli_handle: &t9s::worker::CliHandle, - app: &App, -) { +fn handle_effects(effects: Vec, cli_handle: &t9s::worker::CliHandle, app: &App) { for effect in effects { match effect { Effect::LoadWorkflows => { diff --git a/src/nav/uri.rs b/src/nav/uri.rs index b7b4672..f63172e 100644 --- a/src/nav/uri.rs +++ b/src/nav/uri.rs @@ -186,10 +186,8 @@ fn build_query(location: &Location) -> String { if let Some(segment) = location.leaf() { match segment { - RouteSegment::Workflows(WorkflowsRoute::Collection { query }) => { - if let Some(q) = query { - params.push((String::from("q"), q.clone())); - } + RouteSegment::Workflows(WorkflowsRoute::Collection { query: Some(q) }) => { + params.push((String::from("q"), q.clone())); } RouteSegment::Workflows(WorkflowsRoute::Detail { run_id, tab, .. }) => { if let Some(run_id) = run_id { @@ -199,15 +197,11 @@ fn build_query(location: &Location) -> String { params.push((String::from("tab"), tab.clone())); } } - RouteSegment::Schedules(SchedulesRoute::Collection { query }) => { - if let Some(q) = query { - params.push((String::from("q"), q.clone())); - } + RouteSegment::Schedules(SchedulesRoute::Collection { query: Some(q) }) => { + params.push((String::from("q"), q.clone())); } - RouteSegment::Schedules(SchedulesRoute::Workflows { query, .. }) => { - if let Some(q) = query { - params.push((String::from("q"), q.clone())); - } + RouteSegment::Schedules(SchedulesRoute::Workflows { query: Some(q), .. }) => { + params.push((String::from("q"), q.clone())); } _ => {} } diff --git a/src/widgets/help_overlay.rs b/src/widgets/help_overlay.rs index 1f08931..e5a9d54 100644 --- a/src/widgets/help_overlay.rs +++ b/src/widgets/help_overlay.rs @@ -56,7 +56,7 @@ pub fn render(view: &View, frame: &mut Frame, area: Rect) { lines.push(Line::from("")); lines.push(section("Workflow Actions")); for op in kind_spec(KindId::WorkflowExecution).operations { - lines.push(binding(&op.key.to_string(), op.label)); + lines.push(binding(op.key.to_string(), op.label)); } if is_detail { lines.push(binding("h / l", "Switch detail tabs")); @@ -73,7 +73,7 @@ pub fn render(view: &View, frame: &mut Frame, area: Rect) { } else { op.key.to_string() }; - lines.push(binding(&key, op.label)); + lines.push(binding(key, op.label)); } lines.push(binding("w", "Schedule workflows")); } diff --git a/src/worker.rs b/src/worker.rs index 80db332..1650b40 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -266,29 +266,17 @@ impl CliWorker { CliRequest::TriggerSchedule { namespace, schedule_id, - } => { - match self - .client - .trigger_schedule(&namespace, &schedule_id) - .await - { - Ok(()) => Action::Refresh, - Err(e) => Action::Error(format!("failed to trigger schedule: {}", e)), - } - } + } => match self.client.trigger_schedule(&namespace, &schedule_id).await { + Ok(()) => Action::Refresh, + Err(e) => Action::Error(format!("failed to trigger schedule: {}", e)), + }, CliRequest::DeleteSchedule { namespace, schedule_id, - } => { - match self - .client - .delete_schedule(&namespace, &schedule_id) - .await - { - Ok(()) => Action::Refresh, - Err(e) => Action::Error(format!("failed to delete schedule: {}", e)), - } - } + } => match self.client.delete_schedule(&namespace, &schedule_id).await { + Ok(()) => Action::Refresh, + Err(e) => Action::Error(format!("failed to delete schedule: {}", e)), + }, CliRequest::DescribeTaskQueue { namespace, task_queue,