diff --git a/src/commands/dash.rs b/src/commands/dash.rs new file mode 100644 index 000000000..8dbecf8cf --- /dev/null +++ b/src/commands/dash.rs @@ -0,0 +1,430 @@ +use anyhow::{anyhow, bail}; +use reqwest::Client; + +use super::*; +use crate::{ + controllers::{ + dash_tui::{DashTuiParams, DashboardAuthMode}, + environment::get_matched_environment, + project::get_project, + user::get_user, + }, + errors::RailwayError, +}; + +const DASHBOARD_LOGIN_ERROR: &str = "Not logged in. Run railway login first."; + +#[derive(Debug)] +enum LinkedProjectAuthPreflightError { + MissingProjectScopedContext, + Other(anyhow::Error), +} + +/// Open the Railway dashboard TUI +#[derive(Parser)] +pub struct Args { + /// Optional project ID to open directly + #[clap(short = 'p', long, value_name = "PROJECT_ID")] + project: Option, + + /// Optional environment name or ID to open directly + #[clap(short, long)] + environment: Option, +} + +pub async fn command(args: Args) -> Result<()> { + crate::interact_or!("`railway dash` requires a terminal"); + + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs).map_err(map_dashboard_client_auth_error)?; + let auth_mode = ensure_dashboard_login(&configs, &client, &args).await?; + + crate::controllers::dash_tui::run(DashTuiParams { + project: args.project, + environment: args.environment, + auth_mode, + }) + .await +} + +async fn ensure_dashboard_login( + configs: &Configs, + client: &reqwest::Client, + args: &Args, +) -> Result { + match validate_workspace_auth(configs, client).await { + Ok(()) => Ok(DashboardAuthMode::Workspace), + Err(workspace_error) if should_try_linked_project_auth(&workspace_error) => { + match resolve_linked_project_auth(configs, client, args).await { + Ok(auth_mode) => Ok(auth_mode), + Err(linked_error) => Err(select_dashboard_auth_preflight_error( + &workspace_error, + linked_error, + Configs::get_railway_token().is_some(), + )), + } + } + Err(error) => Err(error.into()), + } +} + +async fn validate_workspace_auth( + configs: &Configs, + client: &Client, +) -> std::result::Result<(), RailwayError> { + let _ = get_user(client, configs).await?; + let _ = post_graphql::( + client, + configs.get_backboard(), + queries::user_projects::Variables {}, + ) + .await?; + + Ok(()) +} + +async fn resolve_linked_project_auth( + configs: &Configs, + client: &Client, + args: &Args, +) -> std::result::Result { + if has_incomplete_env_var_project_scope() { + return Err(LinkedProjectAuthPreflightError::MissingProjectScopedContext); + } + + let linked_project = configs + .get_linked_project() + .await + .map_err(classify_linked_project_lookup_error)?; + + let environment_input = linked_project + .environment_name + .clone() + .or_else(|| linked_project.environment.clone()) + .ok_or(LinkedProjectAuthPreflightError::MissingProjectScopedContext)?; + + let project = get_project(client, configs, linked_project.project.clone()) + .await + .map_err(LinkedProjectAuthPreflightError::from)?; + + if project.deleted_at.is_some() { + return Err(LinkedProjectAuthPreflightError::from( + RailwayError::ProjectDeleted, + )); + } + + let environment = get_matched_environment(&project, environment_input) + .map_err(LinkedProjectAuthPreflightError::from)?; + + validate_requested_linked_project_target(args, &project.id, &environment.id, &environment.name) + .map_err(LinkedProjectAuthPreflightError::from)?; + + if environment.deleted_at.is_some() { + return Err(LinkedProjectAuthPreflightError::from( + RailwayError::EnvironmentDeleted, + )); + } + + Ok(DashboardAuthMode::LinkedProject { + project_id: project.id, + environment_id: environment.id, + }) +} + +fn validate_requested_linked_project_target( + args: &Args, + validated_project_id: &str, + validated_environment_id: &str, + validated_environment_name: &str, +) -> Result<()> { + if let Some(requested_project_id) = args.project.as_deref() + && requested_project_id != validated_project_id + { + bail!( + "`--project {requested_project_id}` does not match the authenticated project scope `{validated_project_id}`" + ); + } + + if let Some(requested_environment) = args.environment.as_deref() + && requested_environment != validated_environment_id + && requested_environment != validated_environment_name + { + bail!( + "`--environment {requested_environment}` does not match the authenticated environment scope `{validated_environment_name}` ({validated_environment_id})" + ); + } + + Ok(()) +} + +fn dashboard_login_error() -> anyhow::Error { + anyhow!(DASHBOARD_LOGIN_ERROR) +} + +fn project_token_requires_link_error() -> anyhow::Error { + anyhow!( + "`railway dash` is running with RAILWAY_TOKEN, which cannot open the workspace project picker. Link a project and environment first with `railway link` and `railway environment`, or unset RAILWAY_TOKEN and use `railway login`." + ) +} + +fn select_dashboard_auth_preflight_error( + workspace_error: &RailwayError, + linked_error: LinkedProjectAuthPreflightError, + is_project_token_auth: bool, +) -> anyhow::Error { + match linked_error { + LinkedProjectAuthPreflightError::MissingProjectScopedContext => { + if is_project_token_auth { + project_token_requires_link_error() + } else { + anyhow!(workspace_error.to_string()) + } + } + LinkedProjectAuthPreflightError::Other(error) => error, + } +} + +fn map_dashboard_client_auth_error(error: RailwayError) -> anyhow::Error { + match error { + RailwayError::Unauthorized => dashboard_login_error(), + other => other.into(), + } +} + +fn should_try_linked_project_auth(error: &RailwayError) -> bool { + should_map_auth_error_to_fallback(error) +} + +fn classify_linked_project_lookup_error(error: anyhow::Error) -> LinkedProjectAuthPreflightError { + if error + .downcast_ref::() + .is_some_and(|error| matches!(error, RailwayError::NoLinkedProject)) + { + LinkedProjectAuthPreflightError::MissingProjectScopedContext + } else { + LinkedProjectAuthPreflightError::Other(error) + } +} + +fn has_incomplete_env_var_project_scope() -> bool { + has_incomplete_env_var_project_scope_from( + Configs::get_railway_project_id(), + Configs::get_railway_environment_id(), + ) +} + +fn has_incomplete_env_var_project_scope_from( + project_id: Option, + environment_id: Option, +) -> bool { + project_id.is_none() && environment_id.is_some() +} + +fn should_map_auth_error_to_fallback(error: &RailwayError) -> bool { + matches!( + error, + RailwayError::Unauthorized + | RailwayError::UnauthorizedLogin + | RailwayError::UnauthorizedToken(_) + | RailwayError::InvalidRailwayToken(_) + ) +} + +impl From for LinkedProjectAuthPreflightError { + fn from(error: anyhow::Error) -> Self { + Self::Other(error) + } +} + +impl From for LinkedProjectAuthPreflightError { + fn from(error: RailwayError) -> Self { + Self::Other(error.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dashboard_login_error_text_is_stable() { + assert_eq!(dashboard_login_error().to_string(), DASHBOARD_LOGIN_ERROR); + } + + #[test] + fn project_token_requires_link_error_is_explicit() { + assert!( + project_token_requires_link_error() + .to_string() + .contains("RAILWAY_TOKEN") + ); + } + + #[test] + fn unauthorized_workspace_errors_fall_back_to_linked_project_mode() { + assert!(should_try_linked_project_auth(&RailwayError::Unauthorized)); + assert!(should_try_linked_project_auth( + &RailwayError::UnauthorizedLogin + )); + assert!(should_try_linked_project_auth( + &RailwayError::UnauthorizedToken("RAILWAY_TOKEN".to_string()) + )); + assert!(should_try_linked_project_auth( + &RailwayError::InvalidRailwayToken("RAILWAY_TOKEN".to_string()) + )); + } + + #[test] + fn non_auth_workspace_errors_do_not_fall_back_to_linked_project_mode() { + assert!(!should_try_linked_project_auth(&RailwayError::Ratelimited)); + assert!(!should_try_linked_project_auth( + &RailwayError::GraphQLError("boom".to_string()) + )); + } + + #[test] + fn only_missing_auth_maps_to_generic_login_message() { + assert_eq!( + map_dashboard_client_auth_error(RailwayError::Unauthorized).to_string(), + DASHBOARD_LOGIN_ERROR + ); + assert!( + map_dashboard_client_auth_error(RailwayError::UnauthorizedLogin) + .to_string() + .contains("railway login") + ); + assert!( + map_dashboard_client_auth_error(RailwayError::InvalidRailwayToken( + "RAILWAY_TOKEN".to_string() + )) + .to_string() + .contains("Invalid RAILWAY_TOKEN") + ); + } + + #[test] + fn missing_link_context_uses_workspace_auth_error_for_login_sessions() { + let error = select_dashboard_auth_preflight_error( + &RailwayError::UnauthorizedLogin, + LinkedProjectAuthPreflightError::MissingProjectScopedContext, + false, + ); + + assert_eq!( + error.to_string(), + RailwayError::UnauthorizedLogin.to_string() + ); + } + + #[test] + fn missing_link_context_is_explicit_for_project_token_auth() { + let error = select_dashboard_auth_preflight_error( + &RailwayError::UnauthorizedToken("RAILWAY_TOKEN".to_string()), + LinkedProjectAuthPreflightError::MissingProjectScopedContext, + true, + ); + + assert!(error.to_string().contains("RAILWAY_TOKEN")); + assert!(error.to_string().contains("railway link")); + } + + #[test] + fn resource_errors_are_not_collapsed_into_login_message() { + let error = select_dashboard_auth_preflight_error( + &RailwayError::UnauthorizedToken("RAILWAY_TOKEN".to_string()), + LinkedProjectAuthPreflightError::from(RailwayError::EnvironmentRestricted( + "prod".to_string(), + )), + true, + ); + + assert_eq!( + error.to_string(), + RailwayError::EnvironmentRestricted("prod".to_string()).to_string() + ); + } + + #[test] + fn linked_project_lookup_classification_is_precise() { + assert!(matches!( + classify_linked_project_lookup_error(RailwayError::NoLinkedProject.into()), + LinkedProjectAuthPreflightError::MissingProjectScopedContext + )); + assert!(matches!( + classify_linked_project_lookup_error(RailwayError::ProjectDeleted.into()), + LinkedProjectAuthPreflightError::Other(_) + )); + } + + #[test] + fn incomplete_env_var_project_scope_is_missing_context() { + assert!(has_incomplete_env_var_project_scope_from( + None, + Some("env_123".to_string()) + )); + assert!(!has_incomplete_env_var_project_scope_from( + Some("proj_123".to_string()), + Some("env_123".to_string()) + )); + assert!(!has_incomplete_env_var_project_scope_from(None, None)); + } + + #[test] + fn linked_project_target_validation_accepts_matching_inputs() { + let args = Args { + project: Some("proj_123".to_string()), + environment: Some("production".to_string()), + }; + + validate_requested_linked_project_target(&args, "proj_123", "env_123", "production") + .unwrap(); + + let args = Args { + project: Some("proj_123".to_string()), + environment: Some("env_123".to_string()), + }; + + validate_requested_linked_project_target(&args, "proj_123", "env_123", "production") + .unwrap(); + } + + #[test] + fn linked_project_target_validation_rejects_project_mismatch() { + let args = Args { + project: Some("proj_requested".to_string()), + environment: None, + }; + + let error = validate_requested_linked_project_target( + &args, + "proj_validated", + "env_123", + "production", + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("does not match the authenticated project scope") + ); + } + + #[test] + fn linked_project_target_validation_rejects_environment_mismatch() { + let args = Args { + project: None, + environment: Some("staging".to_string()), + }; + + let error = + validate_requested_linked_project_target(&args, "proj_123", "env_123", "production") + .unwrap_err(); + + assert!( + error + .to_string() + .contains("does not match the authenticated environment scope") + ); + } +} diff --git a/src/commands/deployment.rs b/src/commands/deployment.rs index b305959bf..946599918 100644 --- a/src/commands/deployment.rs +++ b/src/commands/deployment.rs @@ -1,8 +1,10 @@ use super::*; -use crate::client::post_graphql; -use crate::controllers::environment::get_matched_environment; -use crate::controllers::project::{ensure_project_and_environment_exist, get_project}; -use crate::gql::queries::deployments::{DeploymentStatus, ResponseData, Variables}; +use crate::controllers::{ + deployment::fetch_service_deployments, + environment::get_matched_environment, + project::{ensure_project_and_environment_exist, get_project}, +}; +use crate::gql::queries::deployments::DeploymentStatus; use chrono::{DateTime, Local, Utc}; use serde::Serialize; @@ -134,31 +136,16 @@ async fn list_deployments( ); }; - let variables = Variables { - input: crate::gql::queries::deployments::DeploymentListInput { - service_id: Some(service_id.clone()), - environment_id: Some(environment_id), - project_id: None, - status: None, - include_deleted: None, - }, - first: Some(limit), - }; - - let response: ResponseData = post_graphql::( + let deployments = fetch_service_deployments( &client, - configs.get_backboard(), - variables, + &configs.get_backboard(), + &project.id, + &environment_id, + &service_id, + limit, ) .await?; - let deployments = response - .deployments - .edges - .into_iter() - .map(|edge| edge.node) - .collect::>(); - if json { let output: Vec = deployments .into_iter() diff --git a/src/commands/logs.rs b/src/commands/logs.rs index 8cf6efaf0..c136ca1a8 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -2,6 +2,7 @@ use std::fmt; use std::str::FromStr; use is_terminal::IsTerminal; +use tokio::task::JoinSet; use crate::{ controllers::{ @@ -12,7 +13,7 @@ use crate::{ project::resolve_service_context, }, util::{ - logs::{LogFormat, print_http_log, print_log}, + logs::{LogFormat, format_log_string_plain, print_http_log, print_log}, time::parse_time, }, }; @@ -159,6 +160,161 @@ pub fn compose_http_filter( } } +#[derive(Debug, Clone)] +pub(crate) struct ResolvedLogsDeployment { + pub deployment_id: String, + pub default_deployment_id: String, + pub default_deployment_status: DeploymentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeployLogTarget { + pub service_name: String, + pub deployment_id: String, +} + +pub(crate) async fn resolve_logs_deployment( + client: &reqwest::Client, + backboard: &str, + project_id: &str, + environment_id: &str, + service_id: &str, + deployment_id: Option, + latest: bool, +) -> Result { + let vars = queries::deployments::Variables { + input: DeploymentListInput { + project_id: Some(project_id.to_string()), + environment_id: Some(environment_id.to_string()), + service_id: Some(service_id.to_string()), + include_deleted: None, + status: None, + }, + first: None, + }; + let deployments = post_graphql::(client, backboard, vars) + .await? + .deployments; + let mut all_deployments: Vec<_> = deployments + .edges + .into_iter() + .map(|deployment| deployment.node) + .collect(); + all_deployments.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + let default_deployment = if latest { + all_deployments.first() + } else { + all_deployments + .iter() + .find(|deployment| deployment.status == DeploymentStatus::SUCCESS) + .or_else(|| all_deployments.first()) + } + .context("No deployments found")?; + + Ok(ResolvedLogsDeployment { + deployment_id: deployment_id.unwrap_or_else(|| default_deployment.id.clone()), + default_deployment_id: default_deployment.id.clone(), + default_deployment_status: default_deployment.status.clone(), + }) +} + +pub(crate) async fn fetch_environment_deploy_log_lines( + client: &reqwest::Client, + backboard: &str, + targets: &[DeployLogTarget], + limit_per_target: Option, + filter: Option, +) -> Result> { + let mut merged_lines = Vec::new(); + + for target in targets { + let service_name = target.service_name.clone(); + let mut target_lines = Vec::new(); + fetch_deploy_logs( + FetchLogsParams { + client, + backboard, + deployment_id: target.deployment_id.clone(), + limit: limit_per_target, + filter: filter.clone(), + start_date: None, + end_date: None, + }, + |log| { + target_lines.push(( + log.timestamp.clone(), + format!( + "[{}] {}", + service_name, + format_log_string_plain(&log, LogFormat::Full) + ), + )); + }, + ) + .await?; + merged_lines.extend(target_lines); + } + + merged_lines.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(merged_lines.into_iter().map(|(_, line)| line).collect()) +} + +pub(crate) async fn stream_environment_deploy_log_lines( + targets: Vec, + filter: Option, + mut on_line: impl FnMut(String) + Send + 'static, +) -> Result<()> { + let (line_tx, mut line_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut streams = JoinSet::new(); + + for target in targets { + let line_tx = line_tx.clone(); + let filter = filter.clone(); + streams.spawn(async move { + let service_name = target.service_name; + stream_deploy_logs(target.deployment_id, filter, move |log| { + let _ = line_tx.send(format!( + "[{}] {}", + service_name, + format_log_string_plain(&log, LogFormat::Full) + )); + }) + .await + }); + } + drop(line_tx); + + let mut first_error = None; + let mut streams_done = streams.is_empty(); + let mut channel_open = true; + + while !streams_done || channel_open { + tokio::select! { + maybe_line = line_rx.recv(), if channel_open => { + match maybe_line { + Some(line) => on_line(line), + None => channel_open = false, + } + } + maybe_result = streams.join_next(), if !streams_done => { + match maybe_result { + Some(Ok(Ok(()))) => {} + Some(Ok(Err(error))) if first_error.is_none() => first_error = Some(error), + Some(Err(error)) if first_error.is_none() => first_error = Some(error.into()), + Some(_) => {} + None => streams_done = true, + } + } + } + } + + if let Some(error) = first_error { + Err(error) + } else { + Ok(()) + } +} + #[derive(Parser)] #[clap( about = "View build, deploy, or HTTP logs from a Railway deployment", @@ -314,50 +470,24 @@ pub async fn command(args: Args) -> Result<()> { let environment_id = ctx.environment_id; let service = ctx.service_id; - // Fetch all deployments so we can find a sensible default deployment id if - // none is provided - let vars = queries::deployments::Variables { - input: DeploymentListInput { - project_id: Some(project_id.clone()), - environment_id: Some(environment_id), - service_id: Some(service), - include_deleted: None, - status: None, - }, - first: None, - }; - let deployments = post_graphql::(&client, &backboard, vars) - .await? - .deployments; - let mut all_deployments: Vec<_> = deployments - .edges - .into_iter() - .map(|deployment| deployment.node) - .collect(); - all_deployments.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - let default_deployment = if args.latest { - all_deployments.first() - } else { - all_deployments - .iter() - .find(|d| d.status == DeploymentStatus::SUCCESS) - .or_else(|| all_deployments.first()) - } - .context("No deployments found")?; - - let deployment_id = if let Some(deployment_id) = args.deployment_id { - // Use the provided deployment ID directly - deployment_id - } else { - default_deployment.id.clone() - }; + let resolved_deployment = resolve_logs_deployment( + &client, + &backboard, + &project_id, + &environment_id, + &service, + args.deployment_id.clone(), + args.latest, + ) + .await?; + let deployment_id = resolved_deployment.deployment_id.clone(); let show_http_logs = args.http; let show_build_logs = !show_http_logs && !args.deployment && (args.build - || (default_deployment.status == DeploymentStatus::FAILED - && deployment_id == default_deployment.id)); + || (resolved_deployment.default_deployment_status == DeploymentStatus::FAILED + && deployment_id == resolved_deployment.default_deployment_id)); if show_http_logs { if should_stream { diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index b23fd1b52..039152a1e 100644 --- a/src/commands/metrics.rs +++ b/src/commands/metrics.rs @@ -1,6 +1,5 @@ use crate::{ controllers::{ - database::DatabaseType, db_stats::{self, DatabaseStats}, environment::get_matched_environment, metrics::{ @@ -15,7 +14,7 @@ use crate::{ get_project, }, }, - resources::is_database_service, + resources::{detect_database_type, is_database_service}, util::{progress::create_spinner_if, time::parse_time}, }; @@ -2088,22 +2087,6 @@ fn format_uptime(seconds: i64) -> String { } } -/// Detect the database type from the source image string. -fn detect_database_type(source_image: Option<&str>) -> Option { - let img = source_image?.to_lowercase(); - if img.contains("postgres") || img.contains("postgis") || img.contains("timescale") { - Some(DatabaseType::PostgreSQL) - } else if img.contains("redis") || img.contains("valkey") { - Some(DatabaseType::Redis) - } else if img.contains("mongo") { - Some(DatabaseType::MongoDB) - } else if img.contains("mysql") || img.contains("mariadb") { - Some(DatabaseType::MySQL) - } else { - None - } -} - #[cfg(test)] mod tests { use super::format_window_label; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dd44ef6c5..dd9419e4b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod bucket; pub mod completion; pub mod config; pub mod connect; +pub mod dash; pub mod delete; pub mod deploy; pub mod deployment; diff --git a/src/commands/redeploy.rs b/src/commands/redeploy.rs index 0926493c7..0980f4bed 100644 --- a/src/commands/redeploy.rs +++ b/src/commands/redeploy.rs @@ -2,14 +2,14 @@ use colored::*; use is_terminal::IsTerminal; use crate::{ - controllers::project::{ - find_service_instance, get_environment_instances, resolve_service_context, + controllers::{ + deployment::redeploy_latest_service_deployment, project::resolve_service_context, }, util::{progress::create_spinner_if, prompt::prompt_confirm_with_default}, }; use super::*; -use anyhow::{anyhow, bail}; +use anyhow::bail; /// Redeploy the latest deployment of a service #[derive(Parser)] @@ -48,27 +48,11 @@ pub async fn command(args: Args) -> Result<()> { let environment_name = ctx.environment_name; let service_id = ctx.service_id; let service_name = &ctx.service_name; - let service_node_id = service_id.clone(); - - let environment_instances = - get_environment_instances(&client, &configs, &project_id, &environment_id).await?; - let service_in_env = find_service_instance(&environment_instances, &service_node_id) - .ok_or_else(|| anyhow!("The service specified doesn't exist in the current environment"))?; let latest_deployment_id = if args.from_source { None } else { - let latest = service_in_env - .latest_deployment - .as_ref() - .ok_or_else(|| anyhow!("No deployment found for service"))?; - if !latest.can_redeploy { - bail!( - "The latest deployment for service {service_name} cannot be redeployed. \ - This may be because it's currently building, deploying, or was removed." - ); - } - Some(latest.id.clone()) + Some(String::new()) }; let (prompt_msg, spinner_msg, finish_msg) = if args.from_source { @@ -124,14 +108,17 @@ pub async fn command(args: Args) -> Result<()> { .await?; serde_json::json!({ "success": true }) } - Some(id) => { - let response = post_graphql::( + Some(_) => { + let redeployed_id = redeploy_latest_service_deployment( &client, - configs.get_backboard(), - mutations::deployment_redeploy::Variables { id }, + &configs, + &project_id, + &environment_id, + &service_id, + service_name, ) .await?; - serde_json::json!({ "id": response.deployment_redeploy.id }) + serde_json::json!({ "id": redeployed_id }) } }; diff --git a/src/commands/restart.rs b/src/commands/restart.rs index b423f18c1..487da6ebb 100644 --- a/src/commands/restart.rs +++ b/src/commands/restart.rs @@ -3,8 +3,9 @@ use futures::StreamExt; use is_terminal::IsTerminal; use crate::{ - controllers::project::{ - find_service_instance, get_environment_instances, resolve_service_context, + controllers::{ + deployment::restart_latest_service_deployment, + project::{find_service_instance, get_environment_instances, resolve_service_context}, }, subscription::subscribe_graphql, subscriptions::deployment::DeploymentStatus, @@ -85,12 +86,12 @@ pub async fn command(args: Args) -> Result<()> { ), ); - post_graphql::( + restart_latest_service_deployment( &client, - configs.get_backboard(), - mutations::deployment_restart::Variables { - id: latest.id.clone(), - }, + &configs, + &ctx.project_id, + &ctx.environment_id, + &service_id, ) .await?; diff --git a/src/controllers/dash_tui/data.rs b/src/controllers/dash_tui/data.rs new file mode 100644 index 000000000..1f30de693 --- /dev/null +++ b/src/controllers/dash_tui/data.rs @@ -0,0 +1,360 @@ +use anyhow::{Result, anyhow, bail}; +use chrono::{DateTime, Utc}; +use reqwest::Client; + +use crate::{ + client::GQLClient, + commands::{ + Configs, + queries::{RailwayProject, project::ProjectProjectEnvironmentsEdgesNode}, + }, + controllers::{ + environment::get_matched_environment, + project::{ + ProjectEnvironmentInstances, find_service_instance, get_environment_instances, + get_project, + }, + }, + errors::RailwayError, + workspace::{Project, workspaces}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectCard { + pub id: String, + pub name: String, + pub workspace_name: Option, + pub service_count: usize, + pub environment_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectLoadTarget { + pub project_id: String, + pub environment_hint: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DashboardProject { + pub id: String, + pub name: String, + pub workspace_name: Option, + pub selected_environment_id: String, + pub selected_environment_name: String, + pub environments: Vec, + pub services: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnvironmentSummary { + pub id: String, + pub name: String, + pub deleted: bool, + pub accessible: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DashboardService { + pub id: String, + pub name: String, + pub active_in_environment: bool, + pub num_replicas: Option, + pub latest_deployment: Option, + pub domains: Vec, + pub source_repo: Option, + pub source_image: Option, + pub cron_schedule: Option, + pub next_cron_run_at: Option>, + pub start_command: Option, + pub volume_mounts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeploymentSummary { + pub id: String, + pub status: String, + pub created_at: DateTime, + pub can_redeploy: bool, + pub stopped: bool, +} + +impl ProjectCard { + pub fn matches_filter(&self, filter: &str) -> bool { + let filter = filter.trim().to_lowercase(); + if filter.is_empty() { + return true; + } + + self.name.to_lowercase().contains(&filter) + || self.id.to_lowercase().contains(&filter) + || self + .workspace_name + .as_deref() + .unwrap_or_default() + .to_lowercase() + .contains(&filter) + } +} + +impl DashboardProject { + pub fn accessible_environments(&self) -> Vec<&EnvironmentSummary> { + self.environments + .iter() + .filter(|environment| environment.accessible && !environment.deleted) + .collect() + } +} + +pub async fn load_project_cards() -> Result> { + let mut cards = Vec::new(); + + for workspace in workspaces().await? { + let workspace_name = workspace.name().to_string(); + + for project in workspace.projects() { + if project.deleted_at().is_some() { + continue; + } + + cards.push(project_card_from_project(project, workspace_name.clone())); + } + } + + Ok(cards) +} + +pub async fn load_dashboard_project(target: ProjectLoadTarget) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + load_dashboard_project_with_client(&configs, &client, target).await +} + +pub async fn load_dashboard_project_with_client( + configs: &Configs, + client: &Client, + target: ProjectLoadTarget, +) -> Result { + let project = get_project(client, configs, target.project_id.clone()).await?; + + if project.deleted_at.is_some() { + bail!(RailwayError::ProjectDeleted); + } + + let environment = resolve_project_environment(configs, &project, &target).await?; + if environment.deleted_at.is_some() { + bail!(RailwayError::EnvironmentDeleted); + } + + let instances = + get_environment_instances(client, configs, &project.id, &environment.id).await?; + + Ok(map_dashboard_project(project, environment, instances)) +} + +fn project_card_from_project(project: Project, workspace_name: String) -> ProjectCard { + match project { + Project::External(project) => ProjectCard { + id: project.id, + name: project.name, + workspace_name: Some(workspace_name), + service_count: project.services.edges.len(), + environment_count: project.environments.edges.len(), + }, + Project::Workspace(project) => ProjectCard { + id: project.id, + name: project.name, + workspace_name: Some(workspace_name), + service_count: project.services.edges.len(), + environment_count: project.environments.edges.len(), + }, + } +} + +async fn resolve_project_environment( + configs: &Configs, + project: &RailwayProject, + target: &ProjectLoadTarget, +) -> Result { + if let Some(environment_hint) = target.environment_hint.clone() { + return get_matched_environment(project, environment_hint); + } + + if let Ok(linked_project) = configs.get_linked_project().await + && linked_project.project == project.id + && let Some(environment_hint) = linked_project + .environment_name + .clone() + .or(linked_project.environment.clone()) + { + return get_matched_environment(project, environment_hint); + } + + project + .environments + .edges + .iter() + .find(|environment| environment.node.can_access && environment.node.deleted_at.is_none()) + .map(|environment| environment.node.clone()) + .ok_or_else(|| anyhow!("Project `{}` has no accessible environments.", project.name)) +} + +fn map_dashboard_project( + project: RailwayProject, + selected_environment: ProjectProjectEnvironmentsEdgesNode, + instances: ProjectEnvironmentInstances, +) -> DashboardProject { + let mut services: Vec<_> = project + .services + .edges + .into_iter() + .map(|edge| { + let service = edge.node; + let instance = find_service_instance(&instances, &service.id); + let domains = instance + .map(|instance| { + instance + .domains + .service_domains + .iter() + .map(|domain| domain.domain.clone()) + .chain( + instance + .domains + .custom_domains + .iter() + .map(|domain| domain.domain.clone()), + ) + .collect() + }) + .unwrap_or_default(); + let latest_deployment = instance.and_then(|instance| { + instance + .latest_deployment + .as_ref() + .map(|deployment| DeploymentSummary { + id: deployment.id.clone(), + status: format!("{:?}", deployment.status), + created_at: deployment.created_at, + can_redeploy: deployment.can_redeploy, + stopped: deployment.deployment_stopped, + }) + }); + let volume_mounts = instances + .volume_instances + .iter() + .filter(|volume| volume.node.service_id.as_deref() == Some(service.id.as_str())) + .map(|volume| format!("{} → {}", volume.node.volume.name, volume.node.mount_path)) + .collect(); + + DashboardService { + id: service.id, + name: service.name, + active_in_environment: instance.is_some(), + num_replicas: instance.and_then(|instance| instance.num_replicas), + latest_deployment, + domains, + source_repo: instance.and_then(|instance| { + instance + .source + .as_ref() + .and_then(|source| source.repo.clone()) + .filter(|repo| !repo.is_empty()) + }), + source_image: instance.and_then(|instance| { + instance + .source + .as_ref() + .and_then(|source| source.image.clone()) + .filter(|image| !image.is_empty()) + }), + cron_schedule: instance.and_then(|instance| instance.cron_schedule.clone()), + next_cron_run_at: instance.and_then(|instance| instance.next_cron_run_at), + start_command: instance.and_then(|instance| instance.start_command.clone()), + volume_mounts, + } + }) + .collect(); + services.sort_by_key(|service| service.name.to_ascii_lowercase()); + + DashboardProject { + id: project.id, + name: project.name, + workspace_name: project.workspace.map(|workspace| workspace.name), + selected_environment_id: selected_environment.id, + selected_environment_name: selected_environment.name, + environments: project + .environments + .edges + .into_iter() + .map(|environment| EnvironmentSummary { + id: environment.node.id, + name: environment.node.name, + deleted: environment.node.deleted_at.is_some(), + accessible: environment.node.can_access, + }) + .collect(), + services, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_card_filter_matches_name_workspace_and_id() { + let card = ProjectCard { + id: "proj_123".to_string(), + name: "api".to_string(), + workspace_name: Some("platform".to_string()), + service_count: 3, + environment_count: 2, + }; + + assert!(card.matches_filter("api")); + assert!(card.matches_filter("platform")); + assert!(card.matches_filter("proj_123")); + assert!(!card.matches_filter("worker")); + } + + #[test] + fn accessible_environment_filter_excludes_deleted_and_restricted_entries() { + let project = DashboardProject { + id: "proj_123".to_string(), + name: "api".to_string(), + workspace_name: Some("platform".to_string()), + selected_environment_id: "env_123".to_string(), + selected_environment_name: "production".to_string(), + environments: vec![ + EnvironmentSummary { + id: "env_123".to_string(), + name: "production".to_string(), + deleted: false, + accessible: true, + }, + EnvironmentSummary { + id: "env_456".to_string(), + name: "staging".to_string(), + deleted: false, + accessible: false, + }, + EnvironmentSummary { + id: "env_789".to_string(), + name: "old".to_string(), + deleted: true, + accessible: true, + }, + ], + services: Vec::new(), + }; + + assert_eq!( + project + .accessible_environments() + .into_iter() + .map(|environment| environment.name.as_str()) + .collect::>(), + vec!["production"] + ); + } +} diff --git a/src/controllers/dash_tui/logs.rs b/src/controllers/dash_tui/logs.rs new file mode 100644 index 000000000..351cd7528 --- /dev/null +++ b/src/controllers/dash_tui/logs.rs @@ -0,0 +1,334 @@ +use std::collections::VecDeque; + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::{HandleKeyAction, project::ProjectScreenState, service::ServiceScreenState}; +use crate::commands::logs::DeployLogTarget; + +const LOG_BUFFER_LIMIT: usize = 2_000; +const LOG_SCROLL_STEP: usize = 8; + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) struct LogsScreenState { + pub(in crate::controllers::dash_tui) project_name: String, + pub(in crate::controllers::dash_tui) environment_name: String, + pub(in crate::controllers::dash_tui) service_name: Option, + pub(in crate::controllers::dash_tui) targets: Vec, + pub(in crate::controllers::dash_tui) lines: VecDeque, + pub(in crate::controllers::dash_tui) loading: bool, + pub(in crate::controllers::dash_tui) error: Option, + pub(in crate::controllers::dash_tui) paused: bool, + pub(in crate::controllers::dash_tui) scroll_offset_from_bottom: usize, + pub(in crate::controllers::dash_tui) current_request_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) struct LoadedLogs { + pub(in crate::controllers::dash_tui) lines: Vec, +} + +impl LogsScreenState { + pub(in crate::controllers::dash_tui) fn from_project( + state: &ProjectScreenState, + ) -> Option { + let project = state.project.as_ref()?; + let mut targets: Vec<_> = project + .services + .iter() + .filter_map(|service| { + service + .latest_deployment + .as_ref() + .map(|deployment| DeployLogTarget { + service_name: service.name.clone(), + deployment_id: deployment.id.clone(), + }) + }) + .collect(); + targets.sort_by(|a, b| a.service_name.cmp(&b.service_name)); + + Some(Self::new( + project.name.clone(), + project.selected_environment_name.clone(), + None, + targets, + )) + } + + pub(in crate::controllers::dash_tui) fn from_service(state: &ServiceScreenState) -> Self { + let targets = state + .detail + .service + .latest_deployment + .as_ref() + .map(|deployment| { + vec![DeployLogTarget { + service_name: state.detail.service.name.clone(), + deployment_id: deployment.id.clone(), + }] + }) + .unwrap_or_default(); + + Self::new( + state.detail.project_name.clone(), + state.detail.environment_name.clone(), + Some(state.detail.service.name.clone()), + targets, + ) + } + + fn new( + project_name: String, + environment_name: String, + service_name: Option, + targets: Vec, + ) -> Self { + Self { + project_name, + environment_name, + service_name, + targets, + lines: VecDeque::new(), + loading: false, + error: None, + paused: false, + scroll_offset_from_bottom: 0, + current_request_id: 0, + } + } + + pub(in crate::controllers::dash_tui) fn start_loading(&mut self) { + self.current_request_id += 1; + self.lines.clear(); + self.loading = true; + self.error = None; + self.paused = false; + self.scroll_offset_from_bottom = 0; + } + + pub(in crate::controllers::dash_tui) fn apply_loaded_logs(&mut self, loaded: LoadedLogs) { + self.lines = loaded.lines.into_iter().collect(); + self.loading = false; + self.error = None; + self.trim_lines(); + self.scroll_offset_from_bottom = 0; + } + + pub(in crate::controllers::dash_tui) fn push_line(&mut self, line: String) { + self.lines.push_back(line); + self.trim_lines(); + self.loading = false; + if !self.paused { + self.scroll_offset_from_bottom = 0; + } + } + + pub(in crate::controllers::dash_tui) fn set_error(&mut self, error: String) { + self.loading = false; + self.error = Some(error); + } + + pub(in crate::controllers::dash_tui) fn toggle_paused(&mut self) { + self.paused = !self.paused; + if !self.paused { + self.scroll_offset_from_bottom = 0; + } + } + + pub(in crate::controllers::dash_tui) fn scroll_up(&mut self, amount: usize) { + if self.lines.is_empty() { + return; + } + + self.paused = true; + self.scroll_offset_from_bottom = + (self.scroll_offset_from_bottom + amount).min(self.lines.len().saturating_sub(1)); + } + + pub(in crate::controllers::dash_tui) fn scroll_down(&mut self, amount: usize) { + self.scroll_offset_from_bottom = self.scroll_offset_from_bottom.saturating_sub(amount); + } + + pub(in crate::controllers::dash_tui) fn jump_top(&mut self) { + if self.lines.is_empty() { + return; + } + + self.paused = true; + self.scroll_offset_from_bottom = self.lines.len().saturating_sub(1); + } + + pub(in crate::controllers::dash_tui) fn jump_bottom(&mut self) { + self.paused = false; + self.scroll_offset_from_bottom = 0; + } + + pub(in crate::controllers::dash_tui) fn service_count(&self) -> usize { + self.targets.len() + } + + pub(in crate::controllers::dash_tui) fn is_service_scoped(&self) -> bool { + self.service_name.is_some() + } + + fn trim_lines(&mut self) { + while self.lines.len() > LOG_BUFFER_LIMIT { + self.lines.pop_front(); + } + self.scroll_offset_from_bottom = self + .scroll_offset_from_bottom + .min(self.lines.len().saturating_sub(1)); + } +} + +pub(in crate::controllers::dash_tui) fn handle_logs_screen_key( + state: &mut LogsScreenState, + key: KeyEvent, +) -> HandleKeyAction { + match key.code { + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::Back, + KeyCode::Up | KeyCode::Char('i') => { + state.scroll_up(1); + HandleKeyAction::None + } + KeyCode::Down | KeyCode::Char('k') => { + state.scroll_down(1); + HandleKeyAction::None + } + KeyCode::PageUp => { + state.scroll_up(LOG_SCROLL_STEP); + HandleKeyAction::None + } + KeyCode::PageDown => { + state.scroll_down(LOG_SCROLL_STEP); + HandleKeyAction::None + } + KeyCode::Char('g') => { + state.jump_top(); + HandleKeyAction::None + } + KeyCode::Char('G') => { + state.jump_bottom(); + HandleKeyAction::None + } + KeyCode::Char('p') => { + state.toggle_paused(); + HandleKeyAction::None + } + _ => HandleKeyAction::None, + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::controllers::dash_tui::{ + data::{DashboardProject, DashboardService, DeploymentSummary, ProjectLoadTarget}, + project::ProjectScreenState, + }; + + fn project_state() -> ProjectScreenState { + let mut state = ProjectScreenState::new(ProjectLoadTarget { + project_id: "proj_123".to_string(), + environment_hint: Some("production".to_string()), + }); + state.project = Some(DashboardProject { + id: "proj_123".to_string(), + name: "api".to_string(), + workspace_name: Some("workspace".to_string()), + selected_environment_id: "env_123".to_string(), + selected_environment_name: "production".to_string(), + environments: Vec::new(), + services: vec![ + DashboardService { + id: "svc_123".to_string(), + name: "web".to_string(), + active_in_environment: true, + num_replicas: Some(1), + latest_deployment: Some(DeploymentSummary { + id: "dep_web".to_string(), + status: "SUCCESS".to_string(), + created_at: Utc::now(), + can_redeploy: true, + stopped: false, + }), + domains: Vec::new(), + source_repo: None, + source_image: None, + cron_schedule: None, + next_cron_run_at: None, + start_command: None, + volume_mounts: Vec::new(), + }, + DashboardService { + id: "svc_456".to_string(), + name: "worker".to_string(), + active_in_environment: true, + num_replicas: Some(1), + latest_deployment: Some(DeploymentSummary { + id: "dep_worker".to_string(), + status: "SUCCESS".to_string(), + created_at: Utc::now(), + can_redeploy: true, + stopped: false, + }), + domains: Vec::new(), + source_repo: None, + source_image: None, + cron_schedule: None, + next_cron_run_at: None, + start_command: None, + volume_mounts: Vec::new(), + }, + ], + }); + state + } + + #[test] + fn logs_state_is_created_for_the_entire_environment() { + let state = LogsScreenState::from_project(&project_state()).expect("logs state"); + + assert_eq!(state.project_name, "api"); + assert_eq!(state.environment_name, "production"); + assert_eq!(state.service_name, None); + assert!(!state.is_service_scoped()); + assert_eq!(state.targets.len(), 2); + assert_eq!(state.targets[0].service_name, "web"); + assert_eq!(state.targets[1].service_name, "worker"); + } + + #[test] + fn logs_state_can_be_scoped_to_a_single_service() { + let project_state = project_state(); + let service_state = ServiceScreenState::from_project(&project_state).expect("service"); + let state = LogsScreenState::from_service(&service_state); + + assert_eq!(state.project_name, "api"); + assert_eq!(state.environment_name, "production"); + assert_eq!(state.service_name.as_deref(), Some("web")); + assert!(state.is_service_scoped()); + assert_eq!(state.targets.len(), 1); + assert_eq!(state.targets[0].service_name, "web"); + assert_eq!(state.targets[0].deployment_id, "dep_web"); + } + + #[test] + fn toggling_pause_and_jump_bottom_resets_follow_mode() { + let mut state = LogsScreenState::from_project(&project_state()).expect("logs state"); + state.push_line("line one".to_string()); + state.push_line("line two".to_string()); + + state.toggle_paused(); + assert!(state.paused); + + state.scroll_up(1); + assert_eq!(state.scroll_offset_from_bottom, 1); + + state.jump_bottom(); + assert!(!state.paused); + assert_eq!(state.scroll_offset_from_bottom, 0); + } +} diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs new file mode 100644 index 000000000..14de1e50e --- /dev/null +++ b/src/controllers/dash_tui/mod.rs @@ -0,0 +1,1192 @@ +mod data; +mod logs; +mod project; +mod service; +mod ui; + +use std::io::stdout; +use std::panic; +use std::time::Duration; + +use anyhow::Result; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures_util::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use ratatui::style::Color; +use tokio::sync::mpsc; +use tokio::time::MissedTickBehavior; + +use self::data::{ + DashboardProject, ProjectCard, ProjectLoadTarget, load_dashboard_project, load_project_cards, +}; +use self::logs::{LoadedLogs, LogsScreenState, handle_logs_screen_key}; +use self::project::{EnvironmentSelectorState, ProjectScreenState, handle_project_screen_key}; +use self::service::{ + ServiceAction, ServiceScreenState, build_metrics_tui_params, handle_service_screen_key, + load_service_deployments, run_service_action, +}; +use crate::{ + client::GQLClient, + commands::Configs, + commands::logs::{fetch_environment_deploy_log_lines, stream_environment_deploy_log_lines}, +}; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const RAILWAY_VIOLET: Color = Color::Rgb(127, 86, 217); +const RAILWAY_PURPLE: Color = Color::Rgb(155, 107, 255); +const RAILWAY_PINK: Color = Color::Rgb(236, 72, 153); +const RAILWAY_LAVENDER: Color = Color::Rgb(221, 214, 254); +const RAILWAY_MUTED: Color = Color::Rgb(161, 152, 190); +const RAILWAY_PANEL: Color = Color::Rgb(91, 78, 129); +const RAILWAY_ERROR: Color = Color::Rgb(248, 113, 113); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DashboardAuthMode { + Workspace, + LinkedProject { + project_id: String, + environment_id: String, + }, +} + +#[derive(Clone, Debug)] +pub struct DashTuiParams { + pub project: Option, + pub environment: Option, + pub auth_mode: DashboardAuthMode, +} + +#[derive(Debug)] +struct DashApp { + params: DashTuiParams, + screen: DashboardScreen, + history: Vec, + spinner_tick: usize, + log_stream_task: Option>, +} + +#[derive(Clone, Debug)] +enum DashboardScreen { + Projects(ProjectsScreenState), + Project(ProjectScreenState), + Service(ServiceScreenState), + Logs(LogsScreenState), +} + +#[derive(Clone, Debug)] +struct ProjectsScreenState { + cards: Vec, + selected: usize, + filter: String, + filter_mode: bool, + loading: bool, + error: Option, + current_request_id: u64, + initial_selection_hint: Option, +} + +enum LoaderEvent { + ProjectsLoaded { + request_id: u64, + result: std::result::Result, String>, + }, + ProjectLoaded { + request_id: u64, + result: std::result::Result, + }, + ServiceDeploymentsLoaded { + request_id: u64, + result: std::result::Result, String>, + }, + ServiceActionFinished { + action: ServiceAction, + result: std::result::Result, + }, + LogsLoaded { + request_id: u64, + result: std::result::Result, + }, + LogLine { + request_id: u64, + line: String, + }, + LogStreamEnded { + request_id: u64, + error: Option, + }, +} + +enum HandleKeyAction { + None, + OpenProject { project_id: String }, + OpenSelectedService, + OpenProjectLogs, + OpenServiceLogs, + OpenServiceMetrics, + RefreshProjects, + RefreshProject, + RunServiceAction(ServiceAction), + Back, + OpenEnvironmentSelector, +} + +impl DashApp { + fn new(params: DashTuiParams) -> Self { + let screen = match ¶ms.auth_mode { + DashboardAuthMode::Workspace => match ¶ms.project { + Some(project_id) => { + DashboardScreen::Project(ProjectScreenState::new(ProjectLoadTarget { + project_id: project_id.clone(), + environment_hint: params.environment.clone(), + })) + } + None => DashboardScreen::Projects(ProjectsScreenState::new(None)), + }, + DashboardAuthMode::LinkedProject { + project_id, + environment_id, + } => DashboardScreen::Project(ProjectScreenState::new(ProjectLoadTarget { + project_id: project_id.clone(), + environment_hint: Some(environment_id.clone()), + })), + }; + + Self { + params, + screen, + history: Vec::new(), + spinner_tick: 0, + log_stream_task: None, + } + } + + fn start_initial_load(&mut self, tx: &mpsc::UnboundedSender) { + match self.screen { + DashboardScreen::Projects(_) => self.refresh_projects(tx), + DashboardScreen::Project(_) => self.refresh_project(tx), + DashboardScreen::Service(_) => self.refresh_service_deployments(tx), + DashboardScreen::Logs(_) => self.start_logs_stream(tx), + } + } + + fn refresh_projects(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Projects(state) = &mut self.screen else { + return; + }; + + state.loading = true; + state.error = None; + state.current_request_id += 1; + let request_id = state.current_request_id; + let tx = tx.clone(); + + tokio::spawn(async move { + let result = load_project_cards() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ProjectsLoaded { request_id, result }); + }); + } + + fn refresh_project(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Project(state) = &mut self.screen else { + return; + }; + + state.loading = true; + state.error = None; + state.current_request_id += 1; + let request_id = state.current_request_id; + let target = state.target.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = load_dashboard_project(target) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ProjectLoaded { request_id, result }); + }); + } + + fn push_screen(&mut self, screen: DashboardScreen) { + self.history.push(self.screen.clone()); + self.screen = screen; + } + + fn open_project(&mut self, project_id: String, tx: &mpsc::UnboundedSender) { + let environment_hint = self.params.environment.clone(); + self.push_screen(DashboardScreen::Project(ProjectScreenState::new( + ProjectLoadTarget { + project_id, + environment_hint, + }, + ))); + self.refresh_project(tx); + } + + fn open_selected_service(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Project(state) = &self.screen else { + return; + }; + + let Some(service_screen) = ServiceScreenState::from_project(state) else { + return; + }; + + self.push_screen(DashboardScreen::Service(service_screen)); + self.refresh_service_deployments(tx); + } + + fn open_project_logs(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Project(state) = &self.screen else { + return; + }; + + let Some(logs_screen) = LogsScreenState::from_project(state) else { + return; + }; + + self.stop_logs_stream(); + self.push_screen(DashboardScreen::Logs(logs_screen)); + self.start_logs_stream(tx); + } + + fn open_service_logs(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Service(state) = &self.screen else { + return; + }; + + let logs_screen = LogsScreenState::from_service(state); + + self.stop_logs_stream(); + self.push_screen(DashboardScreen::Logs(logs_screen)); + self.start_logs_stream(tx); + } + + async fn open_service_metrics( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + let DashboardScreen::Service(state) = &self.screen else { + return Ok(()); + }; + + let params = match build_metrics_tui_params(&state.detail).await { + Ok(params) => params, + Err(error) => { + if let DashboardScreen::Service(state) = &mut self.screen { + state.set_toast(format!("Unable to open metrics: {error}"), true); + } + return Ok(()); + } + }; + + restore_terminal(); + let metrics_result = crate::controllers::metrics_tui::run(params).await; + *terminal = setup_terminal()?; + + if let Err(error) = metrics_result + && let DashboardScreen::Service(state) = &mut self.screen + { + state.set_toast(format!("Unable to open metrics: {error}"), true); + } + + Ok(()) + } + + fn refresh_service_deployments(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.start_loading(); + let request_id = state.current_request_id; + let detail = state.detail.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = load_service_deployments(&detail) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceDeploymentsLoaded { request_id, result }); + }); + } + + fn start_logs_stream(&mut self, tx: &mpsc::UnboundedSender) { + self.stop_logs_stream(); + + let DashboardScreen::Logs(state) = &mut self.screen else { + return; + }; + + state.start_loading(); + let request_id = state.current_request_id; + let targets = state.targets.clone(); + let tx = tx.clone(); + + self.log_stream_task = Some(tokio::spawn(async move { + let result = async { + if targets.is_empty() { + return Ok::(LoadedLogs { lines: Vec::new() }); + } + + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let backboard = configs.get_backboard(); + let lines = fetch_environment_deploy_log_lines( + &client, + &backboard, + &targets, + Some(80), + None, + ) + .await?; + + Ok::(LoadedLogs { lines }) + } + .await; + + if let Err(error) = result.as_ref() { + let _ = tx.send(LoaderEvent::LogsLoaded { + request_id, + result: Err(error.to_string()), + }); + return; + } + + let _ = tx.send(LoaderEvent::LogsLoaded { + request_id, + result: result.map_err(|error| error.to_string()), + }); + + if targets.is_empty() { + return; + } + + let line_tx = tx.clone(); + let stream_result = stream_environment_deploy_log_lines(targets, None, move |line| { + let _ = line_tx.send(LoaderEvent::LogLine { request_id, line }); + }) + .await; + let _ = tx.send(LoaderEvent::LogStreamEnded { + request_id, + error: stream_result.err().map(|error| error.to_string()), + }); + })); + } + + fn stop_logs_stream(&mut self) { + if let Some(task) = self.log_stream_task.take() { + task.abort(); + } + } + + fn start_service_action( + &mut self, + action: ServiceAction, + tx: &mpsc::UnboundedSender, + ) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + if state.loading || state.confirmation.as_ref() != Some(&action) { + return; + } + + state.confirmation = None; + state.close_deployment_dialog(); + state.loading = true; + state.error = None; + state.clear_toast(); + let detail = state.detail.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = run_service_action(&detail, &action) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceActionFinished { action, result }); + }); + } + + fn handle_service_action_result( + &mut self, + action: ServiceAction, + result: std::result::Result, + tx: &mpsc::UnboundedSender, + ) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.loading = false; + match result { + Ok(deployment_id) => { + state.set_toast(action.success_message(&deployment_id), false); + self.refresh_service_deployments(tx); + } + Err(error) => { + state.set_toast(error, true); + } + } + } + + fn open_environment_selector(&mut self) { + let DashboardScreen::Project(state) = &mut self.screen else { + return; + }; + + let Some(project) = &state.project else { + return; + }; + + let environments = project.accessible_environments(); + if environments.is_empty() { + return; + } + + let selected = environments + .iter() + .position(|environment| environment.id == project.selected_environment_id) + .unwrap_or(0); + state.environment_selector = Some(EnvironmentSelectorState { selected }); + } + + fn switch_environment( + &mut self, + selected_index: usize, + tx: &mpsc::UnboundedSender, + ) { + let DashboardScreen::Project(state) = &mut self.screen else { + return; + }; + + let Some(project) = &state.project else { + return; + }; + + let accessible_environments = project.accessible_environments(); + let Some(environment) = accessible_environments.get(selected_index) else { + return; + }; + + state.target.environment_hint = Some(environment.id.clone()); + state.environment_selector = None; + self.refresh_project(tx); + } + + fn back(&mut self, tx: &mpsc::UnboundedSender) { + if let Some(screen) = self.history.pop() { + self.stop_logs_stream(); + self.screen = screen; + return; + } + + let DashboardScreen::Project(state) = &self.screen else { + return; + }; + let project_id = state.target.project_id.clone(); + + if !matches!(self.params.auth_mode, DashboardAuthMode::Workspace) { + return; + } + + self.stop_logs_stream(); + self.screen = DashboardScreen::Projects(ProjectsScreenState::new(Some(project_id))); + self.refresh_projects(tx); + } + + fn handle_loader_event(&mut self, event: LoaderEvent, tx: &mpsc::UnboundedSender) { + match event { + LoaderEvent::ProjectsLoaded { request_id, result } => { + let DashboardScreen::Projects(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + match result { + Ok(cards) => state.apply_loaded_cards(cards), + Err(error) => state.set_error(error), + } + } + LoaderEvent::ProjectLoaded { request_id, result } => { + let DashboardScreen::Project(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + match result { + Ok(project) => state.apply_loaded_project(project), + Err(error) => state.set_error(error), + } + } + LoaderEvent::ServiceDeploymentsLoaded { request_id, result } => { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + match result { + Ok(deployments) => state.apply_loaded_deployments(deployments), + Err(error) => state.set_error(error), + } + } + LoaderEvent::ServiceActionFinished { action, result } => { + self.handle_service_action_result(action, result, tx); + } + LoaderEvent::LogsLoaded { request_id, result } => { + let DashboardScreen::Logs(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + match result { + Ok(loaded) => state.apply_loaded_logs(loaded), + Err(error) => state.set_error(error), + } + } + LoaderEvent::LogLine { request_id, line } => { + let DashboardScreen::Logs(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + state.push_line(line); + } + LoaderEvent::LogStreamEnded { request_id, error } => { + let DashboardScreen::Logs(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + state.loading = false; + if let Some(error) = error { + state.set_error(error); + } + } + } + } + + async fn handle_event( + &mut self, + event: Event, + terminal: &mut Terminal>, + tx: &mpsc::UnboundedSender, + ) -> Result { + match event { + Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + let size = terminal.size().unwrap_or_default(); + self.handle_key(key, Rect::new(0, 0, size.width, size.height), terminal, tx) + .await + } + Event::Resize(_, _) => { + let _ = terminal.clear(); + Ok(false) + } + _ => Ok(false), + } + } + + async fn handle_key( + &mut self, + key: KeyEvent, + terminal_area: Rect, + terminal: &mut Terminal>, + tx: &mpsc::UnboundedSender, + ) -> Result { + if matches!(key.code, KeyCode::Char('q')) + || (matches!(key.code, KeyCode::Char('c')) + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + self.stop_logs_stream(); + return Ok(true); + } + + if matches!( + self.screen, + DashboardScreen::Projects(ProjectsScreenState { + filter_mode: true, + .. + }) + ) && let DashboardScreen::Projects(state) = &mut self.screen + { + handle_projects_filter_input(state, key); + return Ok(false); + } + + if let DashboardScreen::Project(state) = &mut self.screen + && state.environment_selector.is_some() + { + return Ok(self.handle_environment_selector_key(key, tx)); + } + + let action = match &mut self.screen { + DashboardScreen::Projects(state) => { + handle_projects_screen_key(state, key, terminal_area) + } + DashboardScreen::Project(state) => handle_project_screen_key(state, key, terminal_area), + DashboardScreen::Service(state) => handle_service_screen_key(state, key), + DashboardScreen::Logs(state) => handle_logs_screen_key(state, key), + }; + + match action { + HandleKeyAction::None => {} + HandleKeyAction::OpenProject { project_id } => self.open_project(project_id, tx), + HandleKeyAction::OpenSelectedService => self.open_selected_service(tx), + HandleKeyAction::OpenProjectLogs => self.open_project_logs(tx), + HandleKeyAction::OpenServiceLogs => self.open_service_logs(tx), + HandleKeyAction::OpenServiceMetrics => self.open_service_metrics(terminal).await?, + HandleKeyAction::RefreshProjects => self.refresh_projects(tx), + HandleKeyAction::RefreshProject => self.refresh_project(tx), + HandleKeyAction::RunServiceAction(action) => self.start_service_action(action, tx), + HandleKeyAction::Back => self.back(tx), + HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), + } + + Ok(false) + } + + fn handle_environment_selector_key( + &mut self, + key: KeyEvent, + tx: &mpsc::UnboundedSender, + ) -> bool { + let DashboardScreen::Project(state) = &mut self.screen else { + return false; + }; + let Some(selector) = &mut state.environment_selector else { + return false; + }; + + let environment_count = state + .project + .as_ref() + .map(|project| project.accessible_environments().len()) + .unwrap_or(0); + + match key.code { + KeyCode::Esc | KeyCode::Backspace => state.environment_selector = None, + KeyCode::Up | KeyCode::Char('i') => { + selector.selected = selector.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('k') => { + if environment_count > 0 { + selector.selected = (selector.selected + 1).min(environment_count - 1); + } + } + KeyCode::Enter => { + let selected = selector.selected; + self.switch_environment(selected, tx); + } + _ => {} + } + + false + } + + fn on_tick(&mut self) { + self.spinner_tick = (self.spinner_tick + 1) % SPINNER_FRAMES.len(); + } +} + +impl ProjectsScreenState { + fn new(initial_selection_hint: Option) -> Self { + Self { + cards: Vec::new(), + selected: 0, + filter: String::new(), + filter_mode: false, + loading: false, + error: None, + current_request_id: 0, + initial_selection_hint, + } + } + + fn visible_indices(&self) -> Vec { + self.cards + .iter() + .enumerate() + .filter_map(|(index, card)| card.matches_filter(&self.filter).then_some(index)) + .collect() + } + + fn selected_card(&self) -> Option<&ProjectCard> { + let visible = self.visible_indices(); + visible + .get(self.selected) + .and_then(|index| self.cards.get(*index)) + } + + fn move_left(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + fn move_right(&mut self) { + let visible_len = self.visible_indices().len(); + if visible_len > 0 { + self.selected = (self.selected + 1).min(visible_len - 1); + } + } + + fn move_up(&mut self, columns: usize) { + self.selected = self.selected.saturating_sub(columns.max(1)); + } + + fn move_down(&mut self, columns: usize) { + let visible_len = self.visible_indices().len(); + if visible_len > 0 { + self.selected = (self.selected + columns.max(1)).min(visible_len - 1); + } + } + + fn apply_loaded_cards(&mut self, cards: Vec) { + let preferred_id = self + .selected_card() + .map(|card| card.id.clone()) + .or_else(|| self.initial_selection_hint.clone()); + + self.cards = cards; + self.loading = false; + self.error = None; + self.clamp_selection(); + + if let Some(preferred_id) = preferred_id { + self.select_by_project_id(&preferred_id); + } + + self.initial_selection_hint = None; + } + + fn set_error(&mut self, error: String) { + self.loading = false; + self.error = Some(error); + self.clamp_selection(); + } + + fn select_by_project_id(&mut self, project_id: &str) { + let visible = self.visible_indices(); + if let Some(position) = visible + .iter() + .position(|index| self.cards[*index].id == project_id) + { + self.selected = position; + } else { + self.clamp_selection(); + } + } + + fn clamp_selection(&mut self) { + let visible_len = self.visible_indices().len(); + if visible_len == 0 { + self.selected = 0; + } else { + self.selected = self.selected.min(visible_len - 1); + } + } +} + +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + + let rollback = scopeguard::guard((), |_| { + restore_terminal(); + }); + + execute!(stdout(), EnterAlternateScreen, Hide, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout()); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + std::mem::forget(rollback); + + Ok(terminal) +} + +fn restore_terminal() { + let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen, Show); + let _ = disable_raw_mode(); +} + +pub async fn run(params: DashTuiParams) -> Result<()> { + let original_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + restore_terminal(); + original_hook(info); + })); + + let mut terminal = setup_terminal()?; + let _cleanup = scopeguard::guard((), |_| { + restore_terminal(); + }); + + let mut app = DashApp::new(params); + let mut events = EventStream::new(); + let (loader_tx, mut loader_rx) = mpsc::unbounded_channel(); + app.start_initial_load(&loader_tx); + + let mut tick = tokio::time::interval(Duration::from_millis(120)); + tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + terminal.draw(|frame| ui::render(frame, &app))?; + + tokio::select! { + Some(Ok(event)) = events.next() => { + if app.handle_event(event, &mut terminal, &loader_tx).await? { + break; + } + } + Some(loader_event) = loader_rx.recv() => { + app.handle_loader_event(loader_event, &loader_tx); + } + _ = tick.tick() => { + app.on_tick(); + } + _ = tokio::signal::ctrl_c() => { + break; + } + } + } + + app.stop_logs_stream(); + Ok(()) +} + +fn handle_projects_screen_key( + state: &mut ProjectsScreenState, + key: KeyEvent, + terminal_area: Rect, +) -> HandleKeyAction { + let [_, body, _] = ui::dashboard_sections(terminal_area); + let [_, grid_area] = ui::screen_sections(body); + let (columns, _, _) = ui::project_grid_metrics(ui::panel_block("cards").inner(grid_area)); + + match key.code { + KeyCode::Up | KeyCode::Char('i') => state.move_up(columns), + KeyCode::Down | KeyCode::Char('k') => state.move_down(columns), + KeyCode::Left | KeyCode::Char('j') => state.move_left(), + KeyCode::Right | KeyCode::Char('l') => state.move_right(), + KeyCode::Enter => { + if let Some(card) = state.selected_card() { + return HandleKeyAction::OpenProject { + project_id: card.id.clone(), + }; + } + } + KeyCode::Char('/') => state.filter_mode = true, + KeyCode::Char('r') => return HandleKeyAction::RefreshProjects, + _ => {} + } + + HandleKeyAction::None +} + +fn handle_projects_filter_input(state: &mut ProjectsScreenState, key: KeyEvent) { + match key.code { + KeyCode::Esc => state.filter_mode = false, + KeyCode::Enter => state.filter_mode = false, + KeyCode::Backspace => { + state.filter.pop(); + state.clamp_selection(); + } + KeyCode::Delete => { + state.filter.clear(); + state.selected = 0; + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + state.filter.clear(); + state.selected = 0; + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + state.filter.push(ch); + state.selected = 0; + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::controllers::dash_tui::data::DashboardService; + + fn card(id: &str, name: &str) -> ProjectCard { + ProjectCard { + id: id.to_string(), + name: name.to_string(), + workspace_name: Some("workspace".to_string()), + service_count: 2, + environment_count: 3, + } + } + + fn service(id: &str, name: &str) -> DashboardService { + DashboardService { + id: id.to_string(), + name: name.to_string(), + active_in_environment: true, + num_replicas: Some(1), + latest_deployment: None, + domains: Vec::new(), + source_repo: None, + source_image: None, + cron_schedule: None, + next_cron_run_at: None, + start_command: None, + volume_mounts: Vec::new(), + } + } + + fn project() -> DashboardProject { + DashboardProject { + id: "proj_123".to_string(), + name: "api".to_string(), + workspace_name: Some("workspace".to_string()), + selected_environment_id: "env_123".to_string(), + selected_environment_name: "production".to_string(), + environments: Vec::new(), + services: vec![service("svc_1", "one"), service("svc_2", "two")], + } + } + + #[test] + fn refresh_preserves_selection_by_project_id() { + let mut state = ProjectsScreenState::new(None); + state.cards = vec![card("one", "one"), card("two", "two")]; + state.selected = 1; + + state.apply_loaded_cards(vec![card("zero", "zero"), card("two", "two")]); + + assert_eq!( + state.selected_card().map(|card| card.id.as_str()), + Some("two") + ); + } + + #[test] + fn initial_selection_hint_is_applied_after_first_load() { + let mut state = ProjectsScreenState::new(Some("proj_two".to_string())); + state.apply_loaded_cards(vec![card("proj_one", "one"), card("proj_two", "two")]); + + assert_eq!( + state.selected_card().map(|card| card.id.as_str()), + Some("proj_two") + ); + } + + #[test] + fn project_grid_has_at_least_one_column() { + assert_eq!(ui::project_grid_metrics(Rect::new(0, 0, 10, 10)).0, 1); + assert!(ui::project_grid_metrics(Rect::new(0, 0, 120, 10)).0 >= 1); + } + + #[test] + fn service_grid_has_at_least_one_column() { + assert_eq!(ui::service_grid_metrics(Rect::new(0, 0, 10, 10)).0, 1); + assert!(ui::service_grid_metrics(Rect::new(0, 0, 120, 10)).0 >= 1); + } + + #[test] + fn project_refresh_preserves_selected_service_by_id() { + let mut state = ProjectScreenState::new(ProjectLoadTarget { + project_id: "proj_123".to_string(), + environment_hint: Some("production".to_string()), + }); + state.project = Some(project()); + state.selected_service = 1; + + let mut refreshed = project(); + refreshed.services = vec![service("svc_0", "zero"), service("svc_2", "two")]; + state.apply_loaded_project(refreshed); + + assert_eq!( + state.selected_service().map(|service| service.id.as_str()), + Some("svc_2") + ); + } + + #[test] + fn back_to_projects_restores_cached_screen_without_reloading() { + let mut restored = ProjectsScreenState::new(Some("proj_two".to_string())); + restored.cards = vec![card("proj_one", "one"), card("proj_two", "two")]; + restored.selected = 0; + restored.filter = "tw".to_string(); + restored.current_request_id = 7; + + let mut app = DashApp { + params: DashTuiParams { + project: None, + environment: None, + auth_mode: DashboardAuthMode::Workspace, + }, + screen: DashboardScreen::Project(ProjectScreenState::new(ProjectLoadTarget { + project_id: "proj_two".to_string(), + environment_hint: Some("production".to_string()), + })), + history: vec![DashboardScreen::Projects(restored.clone())], + spinner_tick: 0, + log_stream_task: None, + }; + + let (tx, _rx) = mpsc::unbounded_channel(); + app.back(&tx); + + match app.screen { + DashboardScreen::Projects(state) => { + assert_eq!( + state.selected_card().map(|card| card.id.as_str()), + Some("proj_two") + ); + assert_eq!(state.filter, "tw"); + assert_eq!(state.current_request_id, 7); + assert!(!state.loading); + } + DashboardScreen::Project(_) + | DashboardScreen::Service(_) + | DashboardScreen::Logs(_) => { + panic!("expected projects screen after backing out") + } + } + } + + #[test] + fn open_service_and_back_restores_project_screen_state() { + let mut project_state = ProjectScreenState::new(ProjectLoadTarget { + project_id: "proj_123".to_string(), + environment_hint: Some("production".to_string()), + }); + project_state.project = Some(project()); + project_state.selected_service = 1; + + let mut app = DashApp { + params: DashTuiParams { + project: None, + environment: None, + auth_mode: DashboardAuthMode::Workspace, + }, + screen: DashboardScreen::Project(project_state.clone()), + history: Vec::new(), + spinner_tick: 0, + log_stream_task: None, + }; + + app.history + .push(DashboardScreen::Project(project_state.clone())); + app.screen = DashboardScreen::Service( + ServiceScreenState::from_project(&project_state) + .expect("expected selected service to exist"), + ); + + match &app.screen { + DashboardScreen::Service(state) => { + assert_eq!(state.detail.service.id, "svc_2"); + assert_eq!(state.detail.project_name, "api"); + assert_eq!(state.detail.environment_name, "production"); + } + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Logs(_) => { + panic!("expected service screen after opening selected service") + } + } + + let (tx, _rx) = mpsc::unbounded_channel(); + app.back(&tx); + + match app.screen { + DashboardScreen::Project(state) => { + assert_eq!( + state.selected_service().map(|service| service.id.as_str()), + Some("svc_2") + ); + } + DashboardScreen::Projects(_) + | DashboardScreen::Service(_) + | DashboardScreen::Logs(_) => { + panic!("expected project screen after backing out of service detail") + } + } + } + + #[test] + fn service_logs_back_restores_service_screen_state() { + let mut project_state = ProjectScreenState::new(ProjectLoadTarget { + project_id: "proj_123".to_string(), + environment_hint: Some("production".to_string()), + }); + project_state.project = Some(project()); + project_state.selected_service = 1; + + let service_state = + ServiceScreenState::from_project(&project_state).expect("expected selected service"); + + let mut app = DashApp { + params: DashTuiParams { + project: None, + environment: None, + auth_mode: DashboardAuthMode::Workspace, + }, + screen: DashboardScreen::Service(service_state.clone()), + history: Vec::new(), + spinner_tick: 0, + log_stream_task: None, + }; + + app.history + .push(DashboardScreen::Service(service_state.clone())); + app.screen = DashboardScreen::Logs(LogsScreenState::from_service(&service_state)); + + match &app.screen { + DashboardScreen::Logs(state) => { + assert_eq!(state.service_name.as_deref(), Some("two")); + assert!(state.is_service_scoped()); + } + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Service(_) => panic!("expected logs screen"), + } + + let (tx, _rx) = mpsc::unbounded_channel(); + app.back(&tx); + + match app.screen { + DashboardScreen::Service(state) => { + assert_eq!(state.detail.service.id, "svc_2"); + assert_eq!(state.detail.service.name, "two"); + } + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Logs(_) => panic!("expected service screen after backing out"), + } + } +} diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs new file mode 100644 index 000000000..7c311b5b8 --- /dev/null +++ b/src/controllers/dash_tui/project.rs @@ -0,0 +1,147 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Rect; + +use super::{HandleKeyAction, ui}; +use crate::controllers::dash_tui::data::{DashboardProject, DashboardService, ProjectLoadTarget}; + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) struct ProjectScreenState { + pub(in crate::controllers::dash_tui) target: ProjectLoadTarget, + pub(in crate::controllers::dash_tui) project: Option, + pub(in crate::controllers::dash_tui) selected_service: usize, + pub(in crate::controllers::dash_tui) loading: bool, + pub(in crate::controllers::dash_tui) error: Option, + pub(in crate::controllers::dash_tui) current_request_id: u64, + pub(in crate::controllers::dash_tui) environment_selector: Option, +} + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) struct EnvironmentSelectorState { + pub(in crate::controllers::dash_tui) selected: usize, +} + +impl ProjectScreenState { + pub(in crate::controllers::dash_tui) fn new(target: ProjectLoadTarget) -> Self { + Self { + target, + project: None, + selected_service: 0, + loading: false, + error: None, + current_request_id: 0, + environment_selector: None, + } + } + + pub(in crate::controllers::dash_tui) fn selected_service(&self) -> Option<&DashboardService> { + self.project + .as_ref() + .and_then(|project| project.services.get(self.selected_service)) + } + + pub(in crate::controllers::dash_tui) fn apply_loaded_project( + &mut self, + project: DashboardProject, + ) { + let preferred_service_id = self.selected_service().map(|service| service.id.clone()); + self.loading = false; + self.error = None; + self.project = Some(project); + self.environment_selector = None; + self.clamp_selection(); + + if let Some(preferred_service_id) = preferred_service_id { + self.select_service_by_id(&preferred_service_id); + } + } + + pub(in crate::controllers::dash_tui) fn set_error(&mut self, error: String) { + self.loading = false; + self.error = Some(error); + self.environment_selector = None; + self.clamp_selection(); + } + + pub(in crate::controllers::dash_tui) fn move_left(&mut self) { + self.selected_service = self.selected_service.saturating_sub(1); + } + + pub(in crate::controllers::dash_tui) fn move_right(&mut self) { + let len = self + .project + .as_ref() + .map(|project| project.services.len()) + .unwrap_or(0); + if len > 0 { + self.selected_service = (self.selected_service + 1).min(len - 1); + } + } + + pub(in crate::controllers::dash_tui) fn move_up(&mut self, columns: usize) { + self.selected_service = self.selected_service.saturating_sub(columns.max(1)); + } + + pub(in crate::controllers::dash_tui) fn move_down(&mut self, columns: usize) { + let len = self + .project + .as_ref() + .map(|project| project.services.len()) + .unwrap_or(0); + if len > 0 { + self.selected_service = (self.selected_service + columns.max(1)).min(len - 1); + } + } + + pub(in crate::controllers::dash_tui) fn select_service_by_id(&mut self, service_id: &str) { + if let Some(project) = &self.project { + if let Some(index) = project + .services + .iter() + .position(|service| service.id == service_id) + { + self.selected_service = index; + } else { + self.clamp_selection(); + } + } + } + + pub(in crate::controllers::dash_tui) fn clamp_selection(&mut self) { + let len = self + .project + .as_ref() + .map(|project| project.services.len()) + .unwrap_or(0); + if len == 0 { + self.selected_service = 0; + } else { + self.selected_service = self.selected_service.min(len - 1); + } + } +} + +pub(in crate::controllers::dash_tui) fn handle_project_screen_key( + state: &mut ProjectScreenState, + key: KeyEvent, + terminal_area: Rect, +) -> HandleKeyAction { + let [_, body, _] = ui::dashboard_sections(terminal_area); + let [_, main_area] = ui::screen_sections(body); + let [diagram_area, _] = ui::project_overview_sections(main_area); + let (columns, _, _) = ui::service_grid_metrics(ui::panel_block("services").inner(diagram_area)); + + match key.code { + KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::Back, + KeyCode::Up | KeyCode::Char('i') => state.move_up(columns), + KeyCode::Down | KeyCode::Char('k') => state.move_down(columns), + KeyCode::Left | KeyCode::Char('j') => state.move_left(), + KeyCode::Right | KeyCode::Char('l') => state.move_right(), + KeyCode::Enter => return HandleKeyAction::OpenSelectedService, + KeyCode::Char('L') => return HandleKeyAction::OpenProjectLogs, + KeyCode::Char('e') => return HandleKeyAction::OpenEnvironmentSelector, + KeyCode::Char('r') => return HandleKeyAction::RefreshProject, + _ => {} + } + + HandleKeyAction::None +} diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs new file mode 100644 index 000000000..841157da5 --- /dev/null +++ b/src/controllers/dash_tui/service.rs @@ -0,0 +1,711 @@ +use anyhow::{Result, anyhow}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{ + HandleKeyAction, + data::{DashboardProject, DashboardService}, +}; +use crate::{ + client::GQLClient, + commands::{Configs, metrics::Sections}, + controllers::{ + db_stats, + deployment::{ + ServiceDeployment, fetch_service_deployments, redeploy_deployment, + restart_latest_service_deployment, rollback_deployment, + }, + metrics::get_volume_metrics, + project::{find_service_instance, get_environment_instances}, + }, + resources::{detect_database_type, is_database_service}, +}; + +const SERVICE_DEPLOYMENTS_LIMIT: i64 = 20; + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) struct ServiceScreenState { + pub(in crate::controllers::dash_tui) detail: ServiceDetail, + pub(in crate::controllers::dash_tui) deployments: Vec, + pub(in crate::controllers::dash_tui) selected_deployment: usize, + pub(in crate::controllers::dash_tui) focus: ServiceFocus, + pub(in crate::controllers::dash_tui) loading: bool, + pub(in crate::controllers::dash_tui) error: Option, + pub(in crate::controllers::dash_tui) current_request_id: u64, + pub(in crate::controllers::dash_tui) deployment_dialog: Option, + pub(in crate::controllers::dash_tui) confirmation: Option, + pub(in crate::controllers::dash_tui) toast: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) struct ServiceDetail { + pub(in crate::controllers::dash_tui) project_id: String, + pub(in crate::controllers::dash_tui) project_name: String, + pub(in crate::controllers::dash_tui) workspace_name: Option, + pub(in crate::controllers::dash_tui) environment_id: String, + pub(in crate::controllers::dash_tui) environment_name: String, + pub(in crate::controllers::dash_tui) service: DashboardService, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) enum ServiceFocus { + Overview, + Deployments, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) enum ServiceAction { + Redeploy { deployment_id: String }, + Restart { deployment_id: String }, + Rollback { deployment_id: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) struct ServiceToast { + pub(in crate::controllers::dash_tui) message: String, + pub(in crate::controllers::dash_tui) is_error: bool, +} + +impl ServiceAction { + pub(in crate::controllers::dash_tui) fn success_message(&self, deployment_id: &str) -> String { + match self { + Self::Redeploy { .. } => format!("Redeploy triggered for deployment {deployment_id}"), + Self::Restart { .. } => format!("Restart triggered for deployment {deployment_id}"), + Self::Rollback { .. } => { + format!("Rollback triggered to deployment {deployment_id}") + } + } + } +} + +impl ServiceScreenState { + pub(in crate::controllers::dash_tui) fn from_project( + state: &super::project::ProjectScreenState, + ) -> Option { + let project = state.project.as_ref()?; + let service = state.selected_service()?.clone(); + + Some(Self { + detail: service_detail_from_project(project, service), + deployments: Vec::new(), + selected_deployment: 0, + focus: ServiceFocus::Overview, + loading: false, + error: None, + current_request_id: 0, + deployment_dialog: None, + confirmation: None, + toast: None, + }) + } + + pub(in crate::controllers::dash_tui) fn start_loading(&mut self) { + self.loading = true; + self.error = None; + self.current_request_id += 1; + } + + pub(in crate::controllers::dash_tui) fn apply_loaded_deployments( + &mut self, + deployments: Vec, + ) { + let selected_deployment_id = self + .selected_deployment() + .map(|deployment| deployment.id.clone()); + let dialog_deployment_id = self.deployment_dialog.clone(); + self.deployments = deployments; + self.loading = false; + self.error = None; + self.clamp_selected_deployment(); + + if let Some(selected_deployment_id) = selected_deployment_id { + self.select_deployment_by_id(&selected_deployment_id); + } + + if let Some(dialog_deployment_id) = dialog_deployment_id + && self + .deployments + .iter() + .all(|deployment| deployment.id != dialog_deployment_id) + { + self.deployment_dialog = None; + } + } + + pub(in crate::controllers::dash_tui) fn set_error(&mut self, error: String) { + self.loading = false; + self.error = Some(error); + } + + pub(in crate::controllers::dash_tui) fn set_toast( + &mut self, + message: impl Into, + is_error: bool, + ) { + self.toast = Some(ServiceToast { + message: message.into(), + is_error, + }); + } + + pub(in crate::controllers::dash_tui) fn clear_toast(&mut self) { + self.toast = None; + } + + pub(in crate::controllers::dash_tui) fn selected_deployment( + &self, + ) -> Option<&ServiceDeployment> { + self.deployments.get(self.selected_deployment) + } + + pub(in crate::controllers::dash_tui) fn dialog_deployment(&self) -> Option<&ServiceDeployment> { + let deployment_id = self.deployment_dialog.as_deref()?; + self.deployments + .iter() + .find(|deployment| deployment.id == deployment_id) + } + + pub(in crate::controllers::dash_tui) fn move_deployment_up(&mut self) { + self.selected_deployment = self.selected_deployment.saturating_sub(1); + } + + pub(in crate::controllers::dash_tui) fn move_deployment_down(&mut self) { + if !self.deployments.is_empty() { + self.selected_deployment = + (self.selected_deployment + 1).min(self.deployments.len() - 1); + } + } + + pub(in crate::controllers::dash_tui) fn focus_deployments(&mut self) { + self.focus = ServiceFocus::Deployments; + } + + pub(in crate::controllers::dash_tui) fn focus_overview(&mut self) { + self.focus = ServiceFocus::Overview; + } + + pub(in crate::controllers::dash_tui) fn toggle_focus(&mut self) { + self.focus = match self.focus { + ServiceFocus::Overview => ServiceFocus::Deployments, + ServiceFocus::Deployments => ServiceFocus::Overview, + }; + } + + fn latest_restartable_deployment_id(&self) -> Result { + self.detail + .service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.clone()) + .ok_or_else(|| anyhow!("No deployment found for service")) + } + + pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { + match self.active_deployment() { + Some(deployment) => { + self.confirmation = Some(ServiceAction::Redeploy { + deployment_id: deployment.id.clone(), + }); + self.clear_toast(); + } + None => self.set_toast("No deployment selected.", true), + } + } + + pub(in crate::controllers::dash_tui) fn open_restart_confirmation(&mut self) { + match self.latest_restartable_deployment_id() { + Ok(deployment_id) => { + self.confirmation = Some(ServiceAction::Restart { deployment_id }); + self.clear_toast(); + } + Err(error) => self.set_toast(error.to_string(), true), + } + } + + pub(in crate::controllers::dash_tui) fn open_rollback_confirmation(&mut self) { + match self.active_deployment() { + Some(deployment) => { + self.confirmation = Some(ServiceAction::Rollback { + deployment_id: deployment.id.clone(), + }); + self.clear_toast(); + } + None => self.set_toast("No deployment selected.", true), + } + } + + pub(in crate::controllers::dash_tui) fn open_selected_deployment_dialog(&mut self) { + match self.selected_deployment() { + Some(deployment) => { + self.deployment_dialog = Some(deployment.id.clone()); + self.clear_toast(); + } + None => self.set_toast("No deployment selected.", true), + } + } + + pub(in crate::controllers::dash_tui) fn close_deployment_dialog(&mut self) { + self.deployment_dialog = None; + } + + pub(in crate::controllers::dash_tui) fn select_deployment_by_id( + &mut self, + deployment_id: &str, + ) { + if let Some(index) = self + .deployments + .iter() + .position(|deployment| deployment.id == deployment_id) + { + self.selected_deployment = index; + } else { + self.clamp_selected_deployment(); + } + } + + pub(in crate::controllers::dash_tui) fn clamp_selected_deployment(&mut self) { + if self.deployments.is_empty() { + self.selected_deployment = 0; + } else { + self.selected_deployment = self.selected_deployment.min(self.deployments.len() - 1); + } + } + + fn active_deployment(&self) -> Option<&ServiceDeployment> { + self.dialog_deployment() + .or_else(|| self.selected_deployment()) + } +} + +pub(in crate::controllers::dash_tui) async fn load_service_deployments( + detail: &ServiceDetail, +) -> Result> { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + + load_service_deployments_with_client( + &client, + &configs, + &detail.project_id, + &detail.environment_id, + &detail.service.id, + ) + .await +} + +async fn load_service_deployments_with_client( + client: &reqwest::Client, + configs: &Configs, + project_id: &str, + environment_id: &str, + service_id: &str, +) -> Result> { + fetch_service_deployments( + client, + &configs.get_backboard(), + project_id, + environment_id, + service_id, + SERVICE_DEPLOYMENTS_LIMIT, + ) + .await +} + +pub(in crate::controllers::dash_tui) async fn build_metrics_tui_params( + detail: &ServiceDetail, +) -> Result { + if !detail.service.active_in_environment { + return Err(anyhow!( + "Service `{}` is not active in environment `{}`.", + detail.service.name, + detail.environment_name + )); + } + + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let backboard = configs.get_backboard(); + let environment_instances = get_environment_instances( + &client, + &configs, + &detail.project_id, + &detail.environment_id, + ) + .await?; + let service_instance = find_service_instance(&environment_instances, &detail.service.id); + let source_image = service_instance + .and_then(|instance| instance.source.as_ref()) + .and_then(|source| source.image.as_deref()) + .or(detail.service.source_image.as_deref()); + let db_type = detect_database_type(source_image); + + Ok(crate::controllers::metrics_tui::ServiceTuiParams { + client, + backboard, + service_id: detail.service.id.clone(), + service_name: detail.service.name.clone(), + environment_id: detail.environment_id.clone(), + environment_name: detail.environment_name.clone(), + since_label: "1h".to_string(), + sections: Sections { + cpu: true, + memory: true, + network: true, + volume: true, + http: true, + has_explicit_filter: false, + }, + is_db: is_database_service(source_image), + db_stats_supported: db_type.is_some(), + method: None, + path: None, + volumes: get_volume_metrics(&environment_instances, &detail.service.id), + db_type: db_type.clone(), + service_instance_id: db_type + .as_ref() + .and_then(|_| service_instance.map(|instance| instance.id.clone())), + db_stats_preflight_error: db_type + .as_ref() + .and_then(|_| db_stats::preflight_db_stats_ssh().err()), + }) +} + +pub(in crate::controllers::dash_tui) async fn run_service_action( + detail: &ServiceDetail, + action: &ServiceAction, +) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + + match action { + ServiceAction::Redeploy { deployment_id } => { + redeploy_deployment(&client, &configs.get_backboard(), deployment_id).await + } + ServiceAction::Restart { .. } => { + restart_latest_service_deployment( + &client, + &configs, + &detail.project_id, + &detail.environment_id, + &detail.service.id, + ) + .await + } + ServiceAction::Rollback { deployment_id } => { + rollback_deployment(&client, &configs.get_backboard(), deployment_id).await + } + } +} + +pub(in crate::controllers::dash_tui) fn handle_service_screen_key( + state: &mut ServiceScreenState, + key: KeyEvent, +) -> HandleKeyAction { + if let Some(confirmation) = state.confirmation.clone() { + return match key.code { + KeyCode::Esc | KeyCode::Backspace => { + state.confirmation = None; + HandleKeyAction::None + } + KeyCode::Enter => HandleKeyAction::RunServiceAction(confirmation), + _ => HandleKeyAction::None, + }; + } + + if state.deployment_dialog.is_some() { + return match key.code { + KeyCode::Esc | KeyCode::Backspace => { + state.close_deployment_dialog(); + HandleKeyAction::None + } + KeyCode::Char('D') if !state.loading => { + state.open_redeploy_confirmation(); + HandleKeyAction::None + } + KeyCode::Char('R') if !state.loading => { + state.open_rollback_confirmation(); + HandleKeyAction::None + } + _ => HandleKeyAction::None, + }; + } + + match key.code { + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::Back, + KeyCode::Up | KeyCode::Char('i') if matches!(state.focus, ServiceFocus::Deployments) => { + state.move_deployment_up(); + HandleKeyAction::None + } + KeyCode::Down | KeyCode::Char('k') if matches!(state.focus, ServiceFocus::Deployments) => { + state.move_deployment_down(); + HandleKeyAction::None + } + KeyCode::BackTab => { + state.focus_overview(); + HandleKeyAction::None + } + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { + state.focus_overview(); + HandleKeyAction::None + } + KeyCode::Tab => { + state.toggle_focus(); + HandleKeyAction::None + } + KeyCode::Left | KeyCode::Char('j') => { + state.focus_overview(); + HandleKeyAction::None + } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('d') => { + state.focus_deployments(); + HandleKeyAction::None + } + KeyCode::Enter if matches!(state.focus, ServiceFocus::Deployments) => { + state.open_selected_deployment_dialog(); + HandleKeyAction::None + } + KeyCode::Char('r') if !state.loading => { + state.open_restart_confirmation(); + HandleKeyAction::None + } + KeyCode::Char('D') if !state.loading => { + state.open_redeploy_confirmation(); + HandleKeyAction::None + } + KeyCode::Char('R') if !state.loading => { + state.open_rollback_confirmation(); + HandleKeyAction::None + } + KeyCode::Char('L') => HandleKeyAction::OpenServiceLogs, + KeyCode::Char('m') => HandleKeyAction::OpenServiceMetrics, + _ => HandleKeyAction::None, + } +} + +fn service_detail_from_project( + project: &DashboardProject, + service: DashboardService, +) -> ServiceDetail { + ServiceDetail { + project_id: project.id.clone(), + project_name: project.name.clone(), + workspace_name: project.workspace_name.clone(), + environment_id: project.selected_environment_id.clone(), + environment_name: project.selected_environment_name.clone(), + service, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + commands::queries::deployments::DeploymentStatus, + controllers::dash_tui::data::DeploymentSummary, + }; + use chrono::Utc; + + fn detail(can_redeploy: bool) -> ServiceDetail { + ServiceDetail { + project_id: "proj_123".to_string(), + project_name: "api".to_string(), + workspace_name: Some("workspace".to_string()), + environment_id: "env_123".to_string(), + environment_name: "production".to_string(), + service: DashboardService { + id: "svc_123".to_string(), + name: "web".to_string(), + active_in_environment: true, + num_replicas: Some(1), + latest_deployment: Some(DeploymentSummary { + id: "dep_123".to_string(), + status: "SUCCESS".to_string(), + created_at: Utc::now(), + can_redeploy, + stopped: false, + }), + domains: Vec::new(), + source_repo: None, + source_image: None, + cron_schedule: None, + next_cron_run_at: None, + start_command: None, + volume_mounts: Vec::new(), + }, + } + } + + fn service_state() -> ServiceScreenState { + ServiceScreenState { + detail: detail(true), + deployments: vec![ + ServiceDeployment { + id: "dep_123".to_string(), + status: DeploymentStatus::SUCCESS, + created_at: Utc::now(), + meta: None, + }, + ServiceDeployment { + id: "dep_122".to_string(), + status: DeploymentStatus::FAILED, + created_at: Utc::now(), + meta: None, + }, + ], + selected_deployment: 0, + focus: ServiceFocus::Overview, + loading: false, + error: None, + current_request_id: 0, + deployment_dialog: None, + confirmation: None, + toast: None, + } + } + + #[test] + fn d_focuses_deployments_and_down_moves_selection() { + let mut state = service_state(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('d'))); + assert!(matches!(action, HandleKeyAction::None)); + assert!(matches!(state.focus, ServiceFocus::Deployments)); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Down)); + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!(state.selected_deployment, 1); + } + + #[test] + fn tab_toggles_focus_between_overview_and_deployments() { + let mut state = service_state(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Tab)); + assert!(matches!(action, HandleKeyAction::None)); + assert!(matches!(state.focus, ServiceFocus::Deployments)); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Tab)); + assert!(matches!(action, HandleKeyAction::None)); + assert!(matches!(state.focus, ServiceFocus::Overview)); + } + + #[test] + fn uppercase_d_opens_confirmation_for_selected_deployment() { + let mut state = service_state(); + state.focus_deployments(); + state.selected_deployment = 1; + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('D'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!( + state.confirmation.as_ref(), + Some(&ServiceAction::Redeploy { + deployment_id: "dep_122".to_string() + }) + ); + } + + #[test] + fn enter_opens_selected_deployment_dialog_when_deployments_are_focused() { + let mut state = service_state(); + state.focus_deployments(); + state.selected_deployment = 1; + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Enter)); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!(state.deployment_dialog.as_deref(), Some("dep_122")); + } + + #[test] + fn rollback_from_deployment_dialog_targets_dialog_deployment() { + let mut state = service_state(); + state.focus_deployments(); + state.selected_deployment = 0; + state.deployment_dialog = Some("dep_122".to_string()); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('R'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!( + state.confirmation.as_ref(), + Some(&ServiceAction::Rollback { + deployment_id: "dep_122".to_string() + }) + ); + } + + #[test] + fn lowercase_r_opens_restart_confirmation_for_latest_deployment() { + let mut state = service_state(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('r'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!( + state.confirmation.as_ref(), + Some(&ServiceAction::Restart { + deployment_id: "dep_123".to_string() + }) + ); + } + + #[test] + fn uppercase_r_opens_rollback_confirmation_for_selected_deployment() { + let mut state = service_state(); + state.focus_deployments(); + state.selected_deployment = 1; + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('R'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!( + state.confirmation.as_ref(), + Some(&ServiceAction::Rollback { + deployment_id: "dep_122".to_string() + }) + ); + } + + #[test] + fn enter_runs_confirmed_service_action() { + let mut state = service_state(); + state.open_restart_confirmation(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Enter)); + + assert!(matches!( + action, + HandleKeyAction::RunServiceAction(ServiceAction::Restart { .. }) + )); + } + + #[test] + fn mutation_shortcuts_are_ignored_while_loading() { + let mut state = service_state(); + state.loading = true; + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('r'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!(state.confirmation.as_ref(), None); + } + + #[test] + fn uppercase_l_opens_service_logs() { + let mut state = service_state(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('L'))); + + assert!(matches!(action, HandleKeyAction::OpenServiceLogs)); + } + + #[test] + fn lowercase_m_opens_service_metrics() { + let mut state = service_state(); + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('m'))); + + assert!(matches!(action, HandleKeyAction::OpenServiceMetrics)); + } +} diff --git a/src/controllers/dash_tui/ui/logs.rs b/src/controllers/dash_tui/ui/logs.rs new file mode 100644 index 000000000..09a9cbf02 --- /dev/null +++ b/src/controllers/dash_tui/ui/logs.rs @@ -0,0 +1,162 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph, Wrap}; + +use super::{ + hero_style, loading_style, muted_style, panel_block, screen_sections, selected_border_style, +}; +use crate::controllers::dash_tui::logs::LogsScreenState; + +pub(super) fn render_logs_screen(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState) { + frame.render_widget(Clear, area); + + let [status_area, logs_area] = screen_sections(area); + render_logs_status(frame, status_area, state); + render_logs_output(frame, logs_area, state); +} + +fn render_logs_status(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState) { + let services = match state.service_count() { + 0 => "0 services".to_string(), + 1 => "1 service".to_string(), + count => format!("{count} services"), + }; + let scope = if state.is_service_scoped() { + "service logs" + } else { + "environment logs" + }; + let activity_line = if let Some(error) = &state.error { + Line::from(vec![ + Span::styled("error: ", Style::default().fg(ratatui::style::Color::Red)), + Span::raw(error.as_str()), + ]) + } else if state.loading && state.lines.is_empty() { + Line::from(vec![ + Span::styled("⠿ ", loading_style()), + Span::styled( + if state.is_service_scoped() { + "Loading service logs..." + } else { + "Loading environment logs..." + }, + hero_style(), + ), + ]) + } else if state.paused { + Line::from(vec![ + Span::styled("paused", hero_style()), + Span::raw(" • press p to resume following live logs"), + ]) + } else { + Line::from(vec![ + Span::styled("streaming", hero_style()), + Span::raw(if state.is_service_scoped() { + " • following latest log lines for the selected service" + } else { + " • following latest log lines across the environment" + }), + ]) + }; + + let mut detail_spans = vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.project_name.as_str()), + Span::raw(" • "), + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(state.environment_name.as_str()), + ]; + + if let Some(service_name) = state.service_name.as_deref() { + detail_spans.push(Span::raw(" • ")); + detail_spans.push(Span::styled( + "service: ", + Style::default().add_modifier(Modifier::BOLD), + )); + detail_spans.push(Span::raw(service_name)); + } + + detail_spans.push(Span::raw(" • ")); + detail_spans.push(Span::styled( + "buffer: ", + Style::default().add_modifier(Modifier::BOLD), + )); + detail_spans.push(Span::styled(state.lines.len().to_string(), muted_style())); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("scope: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(scope, hero_style()), + Span::raw(" • "), + Span::styled("services: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(services), + ]), + Line::from(detail_spans), + activity_line, + ]) + .block(panel_block("logs")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_logs_output(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState) { + let block = panel_block(if state.is_service_scoped() { + "service deploy logs" + } else { + "environment deploy logs" + }) + .border_style(selected_border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if state.loading && state.lines.is_empty() { + frame.render_widget( + Paragraph::new(if state.is_service_scoped() { + "Loading recent logs from the latest deployment for this service..." + } else { + "Loading recent logs from the latest deployments in this environment..." + }) + .style(loading_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + if state.lines.is_empty() { + let message = if state.error.is_some() { + "No logs loaded." + } else if state.is_service_scoped() { + "No deploy logs are available for this service yet." + } else { + "No deploy logs are available in this environment yet." + }; + frame.render_widget( + Paragraph::new(message) + .style(muted_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let viewport_height = inner.height.max(1) as usize; + let len = state.lines.len(); + let start = len.saturating_sub(viewport_height + state.scroll_offset_from_bottom); + let visible = state + .lines + .iter() + .skip(start) + .take(viewport_height) + .cloned(); + let lines = visible.map(Line::from).collect::>(); + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} diff --git a/src/controllers/dash_tui/ui/mod.rs b/src/controllers/dash_tui/ui/mod.rs new file mode 100644 index 000000000..1e18b8cf7 --- /dev/null +++ b/src/controllers/dash_tui/ui/mod.rs @@ -0,0 +1,235 @@ +mod logs; +mod project; +mod projects; +mod service; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use self::logs::render_logs_screen; +use self::project::render_project_screen; +use self::projects::render_projects_screen; +use self::service::render_service_screen; +use super::{ + DashApp, DashboardAuthMode, DashboardScreen, RAILWAY_ERROR, RAILWAY_LAVENDER, RAILWAY_MUTED, + RAILWAY_PANEL, RAILWAY_PINK, RAILWAY_PURPLE, RAILWAY_VIOLET, +}; + +pub(super) const PROJECT_CARD_MIN_WIDTH: u16 = 30; +pub(super) const PROJECT_CARD_HEIGHT: u16 = 7; +pub(super) const PROJECT_CARD_GAP: u16 = 1; +pub(super) const SERVICE_CARD_MIN_WIDTH: u16 = 26; +pub(super) const SERVICE_CARD_HEIGHT: u16 = 7; +pub(super) const SERVICE_CARD_GAP: u16 = 1; + +pub(super) fn render(frame: &mut Frame<'_>, app: &DashApp) { + let [header, body, footer] = dashboard_sections(frame.area()); + + render_header(frame, header, app); + + match &app.screen { + DashboardScreen::Projects(state) => render_projects_screen(frame, body, app, state), + DashboardScreen::Project(state) => render_project_screen(frame, body, app, state), + DashboardScreen::Service(state) => render_service_screen(frame, body, state), + DashboardScreen::Logs(state) => render_logs_screen(frame, body, state), + } + + render_footer(frame, footer, app); +} + +fn grid_metrics(area: Rect, min_width: u16, card_height: u16, gap: u16) -> (usize, usize, u16) { + let columns = ((area.width + gap) / (min_width + gap)).max(1) as usize; + let rows_per_page = ((area.height + gap) / (card_height + gap)).max(1) as usize; + let card_width = area + .width + .saturating_sub(gap.saturating_mul(columns.saturating_sub(1) as u16)) + / columns as u16; + (columns, rows_per_page, card_width) +} + +pub(super) fn project_grid_metrics(area: Rect) -> (usize, usize, u16) { + grid_metrics( + area, + PROJECT_CARD_MIN_WIDTH, + PROJECT_CARD_HEIGHT, + PROJECT_CARD_GAP, + ) +} + +pub(super) fn service_grid_metrics(area: Rect) -> (usize, usize, u16) { + grid_metrics( + area, + SERVICE_CARD_MIN_WIDTH, + SERVICE_CARD_HEIGHT, + SERVICE_CARD_GAP, + ) +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { + let subtitle = match &app.screen { + DashboardScreen::Projects(state) if state.filter_mode => "project cards • filtering", + DashboardScreen::Projects(_) => "project cards", + DashboardScreen::Project(state) if state.environment_selector.is_some() => { + "project overview • environment selector" + } + DashboardScreen::Project(_) => "project overview", + DashboardScreen::Service(_) => "service detail", + DashboardScreen::Logs(_) => "logs", + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" Railway Dashboard ", hero_style()), + Span::styled(subtitle, Style::default().fg(RAILWAY_LAVENDER)), + ])) + .block(panel_block("railway dash")), + area, + ); +} + +fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { + let text = match &app.screen { + DashboardScreen::Projects(state) if state.filter_mode => { + "type to filter • Enter apply • Esc close • Backspace delete • q quit" + } + DashboardScreen::Projects(_) => { + "Enter open • arrows/ijkl move • / filter • r reload • q quit" + } + DashboardScreen::Project(state) if state.environment_selector.is_some() => { + "Enter switch environment • arrows/ik move • Esc cancel • q quit" + } + DashboardScreen::Project(_) => match app.params.auth_mode { + DashboardAuthMode::Workspace => { + "Enter service • L logs • Esc back • e environments • arrows/ijkl move • r reload • q quit" + } + DashboardAuthMode::LinkedProject { .. } => { + "Enter service • L logs • e environments • arrows/ijkl move • r reload • q quit" + } + }, + DashboardScreen::Service(_) => { + "Tab switch panes • arrows/ik scroll • r restart service • Esc back • q quit" + } + DashboardScreen::Logs(_) => { + "Esc back • p pause/resume • g/G top/bottom • arrows/ik scroll • q quit" + } + }; + + frame.render_widget( + Paragraph::new(text) + .style(muted_style()) + .block(panel_block("controls")) + .wrap(Wrap { trim: true }), + area, + ); +} + +pub(super) fn dashboard_sections(area: Rect) -> [Rect; 3] { + Layout::vertical([ + Constraint::Length(3), + Constraint::Min(12), + Constraint::Length(3), + ]) + .areas(area) +} + +pub(super) fn screen_sections(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) +} + +pub(super) fn project_overview_sections(area: Rect) -> [Rect; 2] { + Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]).areas(area) +} + +pub(super) fn panel_block<'a>(title: &'a str) -> Block<'a> { + Block::default() + .borders(Borders::ALL) + .border_style(panel_border_style()) + .title(Span::styled(title, panel_title_style())) +} + +pub(super) fn panel_border_style() -> Style { + Style::default().fg(RAILWAY_PANEL) +} + +pub(super) fn panel_title_style() -> Style { + Style::default() + .fg(RAILWAY_PURPLE) + .add_modifier(Modifier::BOLD) +} + +pub(super) fn hero_style() -> Style { + Style::default() + .fg(RAILWAY_LAVENDER) + .add_modifier(Modifier::BOLD) +} + +pub(super) fn selected_border_style() -> Style { + Style::default().fg(RAILWAY_PINK) +} + +pub(super) fn selected_title_style() -> Style { + Style::default() + .fg(RAILWAY_PINK) + .add_modifier(Modifier::BOLD) +} + +pub(super) fn loading_style() -> Style { + Style::default().fg(RAILWAY_VIOLET) +} + +pub(super) fn accent_style() -> Style { + Style::default().fg(RAILWAY_PINK) +} + +pub(super) fn muted_style() -> Style { + Style::default().fg(RAILWAY_MUTED) +} + +pub(super) fn error_style() -> Style { + Style::default() + .fg(RAILWAY_ERROR) + .add_modifier(Modifier::BOLD) +} + +pub(super) fn render_centered_message( + frame: &mut Frame<'_>, + area: Rect, + message: &str, + color: ratatui::style::Color, + title: &str, +) { + frame.render_widget( + Paragraph::new(message) + .style(Style::default().fg(color)) + .block(panel_block(title)) + .wrap(Wrap { trim: true }), + area, + ); +} + +pub(super) fn pluralize(count: usize, singular: &str) -> String { + if count == 1 { + singular.to_string() + } else { + format!("{singular}s") + } +} + +pub(super) fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + + Layout::horizontal([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} diff --git a/src/controllers/dash_tui/ui/project.rs b/src/controllers/dash_tui/ui/project.rs new file mode 100644 index 000000000..7eaf10c48 --- /dev/null +++ b/src/controllers/dash_tui/ui/project.rs @@ -0,0 +1,386 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph, Wrap}; + +use super::super::{DashApp, RAILWAY_ERROR, RAILWAY_LAVENDER, RAILWAY_VIOLET, SPINNER_FRAMES}; +use super::{ + SERVICE_CARD_GAP, SERVICE_CARD_HEIGHT, SERVICE_CARD_MIN_WIDTH, accent_style, centered_rect, + error_style, hero_style, loading_style, muted_style, panel_block, panel_border_style, + project_overview_sections, render_centered_message, screen_sections, selected_border_style, + selected_title_style, service_grid_metrics, +}; +use crate::controllers::dash_tui::data::DashboardService; +use crate::controllers::dash_tui::project::ProjectScreenState; + +pub(super) fn render_project_screen( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectScreenState, +) { + frame.render_widget(Clear, area); + + let [status_area, main_area] = screen_sections(area); + + render_project_status(frame, status_area, app, state); + + if state.loading && state.project.is_none() { + render_centered_message( + frame, + main_area, + &format!( + "{} Loading project overview...", + SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()] + ), + RAILWAY_VIOLET, + "project overview", + ); + return; + } + + if let Some(error) = &state.error + && state.project.is_none() + { + render_centered_message( + frame, + main_area, + &format!("Unable to open project.\n\n{error}\n\nPress r to retry."), + RAILWAY_ERROR, + "project overview", + ); + return; + } + + let [diagram_area, summary_area] = project_overview_sections(main_area); + + render_project_diagram(frame, diagram_area, state); + render_project_summary(frame, summary_area, state); + + if state.environment_selector.is_some() { + render_environment_selector(frame, centered_rect(main_area, 55, 60), state); + } +} + +fn render_project_status( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectScreenState, +) { + let spinner = SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()]; + + let title_line = if let Some(project) = &state.project { + Line::from(vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.name.as_str()), + Span::raw(" • "), + Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.workspace_name.as_deref().unwrap_or("personal")), + ]) + } else { + Line::from(vec![ + Span::styled( + "project id: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(state.target.project_id.as_str()), + ]) + }; + + let detail_line = if let Some(error) = &state.error { + Line::from(vec![ + Span::styled("error: ", error_style()), + Span::raw(error), + ]) + } else if state.loading { + Line::from(vec![ + Span::styled(format!("{spinner} "), loading_style()), + Span::styled( + "Refreshing project overview...", + Style::default().fg(RAILWAY_LAVENDER), + ), + ]) + } else if let Some(project) = &state.project { + let accessible_count = project.accessible_environments().len(); + let selected_service = state + .selected_service() + .map(|service| service.name.as_str()) + .unwrap_or("none"); + Line::from(vec![ + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(project.selected_environment_name.as_str()), + Span::raw(" • "), + Span::styled( + format!("{} accessible envs", accessible_count), + muted_style(), + ), + Span::raw(" • "), + Span::styled( + format!("{} services", project.services.len()), + muted_style(), + ), + Span::raw(" • "), + Span::styled("selected: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(selected_service), + ]) + } else { + Line::from("Waiting for project data...") + }; + + frame.render_widget( + Paragraph::new(vec![title_line, Line::default(), detail_line]) + .block(panel_block("project")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_project_diagram(frame: &mut Frame<'_>, area: Rect, state: &ProjectScreenState) { + let block = panel_block("services"); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < SERVICE_CARD_MIN_WIDTH || inner.height < SERVICE_CARD_HEIGHT { + frame.render_widget( + Paragraph::new("Terminal too small for service cards. Resize to continue.") + .style(accent_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let Some(project) = &state.project else { + return; + }; + + if project.services.is_empty() { + frame.render_widget( + Paragraph::new("This project has no services yet.") + .style(muted_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let (columns, rows_per_page, card_width) = service_grid_metrics(inner); + let selected_row = state.selected_service / columns; + let start_row = selected_row.saturating_sub(rows_per_page.saturating_sub(1)); + let start_index = start_row * columns; + let end_index = (start_index + (rows_per_page * columns)).min(project.services.len()); + + for (visible_index, service) in project.services[start_index..end_index].iter().enumerate() { + let absolute_index = start_index + visible_index; + let row = visible_index / columns; + let col = visible_index % columns; + let x = inner.x + (col as u16 * (card_width + SERVICE_CARD_GAP)); + let y = inner.y + (row as u16 * (SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP)); + let rect = Rect { + x, + y, + width: card_width, + height: SERVICE_CARD_HEIGHT, + }; + render_service_card( + frame, + rect, + service, + absolute_index == state.selected_service, + ); + } +} + +fn render_service_card( + frame: &mut Frame<'_>, + area: Rect, + service: &DashboardService, + selected: bool, +) { + let border_style = if selected { + selected_border_style() + } else if service.active_in_environment { + panel_border_style() + } else { + error_style() + }; + let title_style = if selected { + selected_title_style() + } else if service.active_in_environment { + hero_style() + } else { + Style::default() + .fg(RAILWAY_ERROR) + .add_modifier(Modifier::BOLD) + }; + let status = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.status.as_str()) + .unwrap_or(if service.active_in_environment { + "no latest deployment" + } else { + "no instance in env" + }); + let replicas = service + .num_replicas + .map(|replicas| replicas.to_string()) + .unwrap_or_else(|| "-".to_string()); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(service.name.as_str(), title_style)), + Line::default(), + Line::from(vec![ + Span::styled("status: ", muted_style()), + Span::raw(status), + ]), + Line::from(vec![ + Span::styled("replicas: ", muted_style()), + Span::raw(replicas), + ]), + Line::from(vec![ + Span::styled("domains: ", muted_style()), + Span::raw(service.domains.len().to_string()), + ]), + ]) + .block(panel_block(service.id.as_str()).border_style(border_style)) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_project_summary(frame: &mut Frame<'_>, area: Rect, state: &ProjectScreenState) { + if state.project.is_none() { + frame.render_widget( + Paragraph::new("Project summary will appear once the overview loads.") + .style(muted_style()) + .block(panel_block("summary")) + .wrap(Wrap { trim: true }), + area, + ); + return; + } + + let Some(service) = state.selected_service() else { + frame.render_widget( + Paragraph::new("No service selected.") + .style(muted_style()) + .block(panel_block("summary")) + .wrap(Wrap { trim: true }), + area, + ); + return; + }; + + let label_style = Style::default().add_modifier(Modifier::BOLD); + let status = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.status.as_str()) + .unwrap_or(if service.active_in_environment { + "no latest deployment" + } else { + "no instance in env" + }); + let deployment = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()) + .unwrap_or("none"); + let replicas = service + .num_replicas + .map(|replicas| replicas.to_string()) + .unwrap_or_else(|| "-".to_string()); + let domains = match service.domains.len() { + 0 => "no domains".to_string(), + 1 => "1 domain".to_string(), + count => format!("{count} domains"), + }; + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(service.name.as_str(), hero_style())), + Line::default(), + Line::from(vec![ + Span::styled("status: ", label_style), + Span::raw(status), + ]), + Line::from(vec![ + Span::styled("deployment: ", label_style), + Span::raw(deployment), + ]), + Line::from(vec![ + Span::styled("replicas: ", label_style), + Span::raw(replicas), + ]), + Line::from(vec![ + Span::styled("domains: ", label_style), + Span::raw(domains), + ]), + Line::default(), + Line::from(Span::styled( + "Press Enter for full service details.", + muted_style(), + )), + ]) + .block(panel_block("selected service").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_environment_selector(frame: &mut Frame<'_>, area: Rect, state: &ProjectScreenState) { + frame.render_widget(Clear, area); + + let Some(project) = &state.project else { + return; + }; + let Some(selector) = &state.environment_selector else { + return; + }; + + let environments = project.accessible_environments(); + let mut lines = vec![Line::from(Span::styled( + "Choose an environment", + hero_style(), + ))]; + lines.push(Line::default()); + + for (index, environment) in environments.iter().enumerate() { + let is_selected = index == selector.selected; + let marker = if environment.id == project.selected_environment_id { + "●" + } else { + "○" + }; + let prefix = if is_selected { ">" } else { " " }; + let style = if is_selected { + selected_title_style() + } else { + Style::default().fg(RAILWAY_LAVENDER) + }; + + lines.push(Line::from(vec![ + Span::styled(format!("{prefix} {marker} "), style), + Span::styled( + environment.name.as_str(), + style.add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(environment.id.as_str(), muted_style()), + ])); + } + + frame.render_widget( + Paragraph::new(lines) + .block(panel_block("environment selector").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/src/controllers/dash_tui/ui/projects.rs b/src/controllers/dash_tui/ui/projects.rs new file mode 100644 index 000000000..cc1348911 --- /dev/null +++ b/src/controllers/dash_tui/ui/projects.rs @@ -0,0 +1,215 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph, Wrap}; + +use super::super::{DashApp, ProjectsScreenState, RAILWAY_LAVENDER, SPINNER_FRAMES}; +use super::{ + PROJECT_CARD_GAP, PROJECT_CARD_HEIGHT, PROJECT_CARD_MIN_WIDTH, accent_style, error_style, + hero_style, loading_style, muted_style, panel_block, panel_border_style, pluralize, + project_grid_metrics, screen_sections, selected_border_style, selected_title_style, +}; +use crate::controllers::dash_tui::data::ProjectCard; + +pub(super) fn render_projects_screen( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectsScreenState, +) { + frame.render_widget(Clear, area); + + let [status_area, grid_area] = screen_sections(area); + + render_projects_status(frame, status_area, app, state); + render_projects_grid(frame, grid_area, state); +} + +fn render_projects_status( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectsScreenState, +) { + let selected = state + .selected_card() + .map(|card| card.name.as_str()) + .unwrap_or("none"); + let visible_count = state.visible_indices().len(); + let spinner = SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()]; + + let filter_line = if state.filter_mode { + Line::from(vec![ + Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(format!("{}█", state.filter), selected_title_style()), + ]) + } else if state.filter.is_empty() { + Line::from(vec![ + Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("/ to search projects", muted_style()), + ]) + } else { + Line::from(vec![ + Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(state.filter.as_str(), selected_title_style()), + ]) + }; + + let status_line = if let Some(error) = &state.error { + Line::from(vec![ + Span::styled("error: ", error_style()), + Span::raw(error), + ]) + } else if state.loading { + let label = if state.cards.is_empty() { + "Loading projects..." + } else { + "Refreshing projects..." + }; + Line::from(vec![ + Span::styled(format!("{spinner} "), loading_style()), + Span::styled(label, Style::default().fg(RAILWAY_LAVENDER)), + ]) + } else { + Line::from(vec![ + Span::styled("selected: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(selected, Style::default().fg(RAILWAY_LAVENDER)), + Span::raw(" • "), + Span::styled( + format!("{visible_count} visible / {} total", state.cards.len()), + muted_style(), + ), + ]) + }; + + frame.render_widget( + Paragraph::new(vec![filter_line, Line::default(), status_line]) + .block(panel_block("projects")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_projects_grid(frame: &mut Frame<'_>, area: Rect, state: &ProjectsScreenState) { + let block = panel_block("cards"); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < PROJECT_CARD_MIN_WIDTH || inner.height < PROJECT_CARD_HEIGHT { + frame.render_widget( + Paragraph::new("Terminal too small for project cards. Resize to continue.") + .style(accent_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let visible = state.visible_indices(); + if state.loading && state.cards.is_empty() { + frame.render_widget( + Paragraph::new("Loading projects...") + .style(loading_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + if let Some(error) = &state.error + && state.cards.is_empty() + { + frame.render_widget( + Paragraph::new(format!( + "Unable to load projects.\n\n{error}\n\nPress r to retry." + )) + .style(error_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + if visible.is_empty() { + let message = if state.filter.is_empty() { + "No projects found for this account." + } else { + "No projects match the current filter." + }; + frame.render_widget( + Paragraph::new(message) + .style(muted_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let (columns, rows_per_page, card_width) = project_grid_metrics(inner); + let selected_row = state.selected / columns; + let start_row = selected_row.saturating_sub(rows_per_page.saturating_sub(1)); + let start_index = start_row * columns; + let end_index = (start_index + (rows_per_page * columns)).min(visible.len()); + + for (visible_index, card_index) in visible[start_index..end_index].iter().enumerate() { + let absolute_visible_index = start_index + visible_index; + let card = &state.cards[*card_index]; + let row = visible_index / columns; + let col = visible_index % columns; + let x = inner.x + (col as u16 * (card_width + PROJECT_CARD_GAP)); + let y = inner.y + (row as u16 * (PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP)); + let rect = Rect { + x, + y, + width: card_width, + height: PROJECT_CARD_HEIGHT, + }; + render_project_card(frame, rect, card, absolute_visible_index == state.selected); + } +} + +fn render_project_card(frame: &mut Frame<'_>, area: Rect, card: &ProjectCard, selected: bool) { + let border_style = if selected { + selected_border_style() + } else { + panel_border_style() + }; + let title_style = if selected { + selected_title_style() + } else { + hero_style() + }; + + let workspace_name = card.workspace_name.as_deref().unwrap_or("personal"); + let service_label = pluralize(card.service_count, "service"); + let environment_label = pluralize(card.environment_count, "environment"); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(card.name.as_str(), title_style)), + Line::default(), + Line::from(vec![ + Span::styled( + format!("{} ", card.service_count), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(service_label), + ]), + Line::from(vec![ + Span::styled( + format!("{} ", card.environment_count), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(environment_label), + ]), + Line::from(vec![ + Span::styled("workspace: ", muted_style()), + Span::styled(workspace_name, Style::default().fg(RAILWAY_LAVENDER)), + ]), + ]) + .block(panel_block(card.id.as_str()).border_style(border_style)) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs new file mode 100644 index 000000000..18d7df702 --- /dev/null +++ b/src/controllers/dash_tui/ui/service.rs @@ -0,0 +1,589 @@ +use chrono_humanize::HumanTime; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph, Wrap}; +use serde_json::to_string_pretty; + +use super::{ + centered_rect, error_style, hero_style, loading_style, muted_style, panel_block, + panel_title_style, screen_sections, selected_border_style, selected_title_style, +}; +use crate::{ + commands::queries::deployments::DeploymentStatus, + controllers::{ + dash_tui::service::{ServiceAction, ServiceFocus, ServiceScreenState}, + deployment::ServiceDeployment, + }, +}; + +pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let [status_area, main_area] = screen_sections(area); + let [content_area, sidebar_area] = + Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .areas(main_area); + let [details_area, resources_area] = + Layout::vertical([Constraint::Percentage(68), Constraint::Percentage(32)]) + .areas(content_area); + + render_service_status(frame, status_area, state); + render_service_overview(frame, details_area, state); + render_service_resources(frame, resources_area, state); + render_service_deployments(frame, sidebar_area, state); + + if state.deployment_dialog.is_some() { + render_service_deployment_dialog(frame, centered_rect(main_area, 70, 62), state); + } + + if state.confirmation.is_some() { + render_service_confirmation(frame, centered_rect(main_area, 60, 42), state); + } +} + +fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let workspace_name = state.detail.workspace_name.as_deref().unwrap_or("personal"); + + let state_line = if let Some(toast) = &state.toast { + let label_style = if toast.is_error { + error_style() + } else { + hero_style() + }; + Line::from(vec![ + Span::styled( + if toast.is_error { "error: " } else { "info: " }, + label_style, + ), + Span::raw(toast.message.as_str()), + ]) + } else if let Some(error) = &state.error { + Line::from(vec![ + Span::styled("error: ", error_style()), + Span::raw(error.as_str()), + ]) + } else if state.loading { + Line::from(vec![ + Span::styled("⠿ ", loading_style()), + Span::styled("Refreshing service detail...", hero_style()), + ]) + } else { + let latest = state + .detail + .service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()) + .unwrap_or("none"); + Line::from(vec![ + Span::styled( + "latest deployment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(latest), + Span::raw(" • "), + Span::styled( + format!("{} deployments loaded", state.deployments.len()), + muted_style(), + ), + ]) + }; + + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("service: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(state.detail.service.name.as_str(), hero_style()), + Span::raw(" • "), + Span::styled("id: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.detail.service.id.as_str()), + ]), + Line::from(vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.detail.project_name.as_str()), + Span::raw(" • "), + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(state.detail.environment_name.as_str()), + Span::raw(" • "), + Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(workspace_name), + ]), + state_line, + ]) + .block(panel_block("service")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_service_overview(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let service = &state.detail.service; + let latest_deployment_id = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()) + .unwrap_or("none"); + let latest_deployment_status = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.status.as_str()) + .unwrap_or("none"); + let latest_deployment_created = service + .latest_deployment + .as_ref() + .map(|deployment| HumanTime::from(deployment.created_at).to_string()) + .unwrap_or_else(|| "none".to_string()); + let latest_deployment_stopped = service + .latest_deployment + .as_ref() + .map(|deployment| if deployment.stopped { "yes" } else { "no" }) + .unwrap_or("unknown"); + let source_repo = service.source_repo.as_deref().unwrap_or("none"); + let source_image = service.source_image.as_deref().unwrap_or("none"); + let next_cron_run = service + .next_cron_run_at + .map(|next_run| HumanTime::from(next_run).to_string()) + .unwrap_or_else(|| "none".to_string()); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled("overview", panel_title_style())), + Line::default(), + Line::from(vec![ + Span::styled( + "active in env: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(if service.active_in_environment { + "yes" + } else { + "no" + }), + ]), + Line::from(vec![ + Span::styled("replicas: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw( + service + .num_replicas + .map(|replicas| replicas.to_string()) + .unwrap_or_else(|| "-".to_string()), + ), + ]), + Line::default(), + Line::from(vec![ + Span::styled( + "deployment id: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(latest_deployment_id), + ]), + Line::from(vec![ + Span::styled( + "deployment status: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(latest_deployment_status), + ]), + Line::from(vec![ + Span::styled( + "deployment created: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(latest_deployment_created), + ]), + Line::from(vec![ + Span::styled("stopped: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(latest_deployment_stopped), + ]), + Line::default(), + Line::from(vec![ + Span::styled( + "source repo: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(source_repo), + ]), + Line::from(vec![ + Span::styled( + "source image: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(source_image), + ]), + Line::from(vec![ + Span::styled( + "cron schedule: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(service.cron_schedule.as_deref().unwrap_or("none")), + ]), + Line::from(vec![ + Span::styled( + "next cron run: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(next_cron_run), + ]), + Line::from(vec![ + Span::styled( + "start command: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(service.start_command.as_deref().unwrap_or("none")), + ]), + ]) + .block(panel_block("details").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let service = &state.detail.service; + let domains = if service.domains.is_empty() { + "none".to_string() + } else { + service.domains.join("\n") + }; + let volumes = if service.volume_mounts.is_empty() { + "none".to_string() + } else { + service.volume_mounts.join("\n") + }; + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled("domains", panel_title_style())), + Line::from(domains), + Line::default(), + Line::from(Span::styled("volumes", panel_title_style())), + Line::from(volumes), + Line::default(), + Line::from(Span::styled("actions", panel_title_style())), + Line::from(Span::styled("m open metrics dashboard", muted_style())), + Line::from(Span::styled("L open filtered logs", muted_style())), + Line::from(Span::styled("d focus deployments", muted_style())), + Line::from(Span::styled( + "Enter inspect selected deployment", + muted_style(), + )), + Line::from(Span::styled("r restart current service", muted_style())), + Line::from(Span::styled( + "D redeploy selected deployment", + muted_style(), + )), + Line::from(Span::styled( + "R rollback to selected deployment", + muted_style(), + )), + ]) + .block(panel_block("resources")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let is_focused = matches!(state.focus, ServiceFocus::Deployments); + let block = if is_focused { + panel_block("deployments").border_style(selected_border_style()) + } else { + panel_block("deployments") + }; + let inner = block.inner(area); + frame.render_widget(block, area); + + if state.loading && state.deployments.is_empty() { + frame.render_widget( + Paragraph::new("Loading deployments...") + .style(loading_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + if let Some(error) = &state.error + && state.deployments.is_empty() + { + frame.render_widget( + Paragraph::new(format!("Unable to load deployments.\n\n{error}")) + .style(error_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + if state.deployments.is_empty() { + frame.render_widget( + Paragraph::new("No deployments found for this service.") + .style(muted_style()) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let current_deployment_id = state + .detail + .service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()); + + let mut lines = vec![Line::from(vec![Span::styled( + if is_focused { + "deployments focused • Enter view details" + } else { + "press d to focus" + }, + muted_style(), + )])]; + lines.push(Line::default()); + + let entries_per_page = ((inner.height.saturating_sub(2)) / 4).max(1) as usize; + let selected = state + .selected_deployment + .min(state.deployments.len().saturating_sub(1)); + let start = selected.saturating_sub(entries_per_page.saturating_sub(1)); + let end = (start + entries_per_page).min(state.deployments.len()); + + for (index, deployment) in state.deployments[start..end].iter().enumerate() { + let absolute_index = start + index; + let is_current = current_deployment_id == Some(deployment.id.as_str()); + let is_selected = absolute_index == selected; + let status_style = deployment_style(deployment, is_current, is_selected, is_focused); + let age = HumanTime::from(deployment.created_at).to_string(); + let prefix = if is_selected { ">" } else { " " }; + + lines.push(Line::from(vec![ + Span::styled( + format!("{prefix} "), + if is_selected { + selected_border_style() + } else { + muted_style() + }, + ), + Span::styled(status_glyph(deployment, is_current), status_style), + Span::raw(" "), + Span::styled(format_status(&deployment.status), status_style), + ])); + lines.push(Line::from(vec![Span::styled( + deployment.id.as_str(), + if is_selected { + selected_title_style() + } else { + Style::default().add_modifier(Modifier::BOLD) + }, + )])); + lines.push(Line::from(vec![Span::styled(age, muted_style())])); + lines.push(Line::default()); + } + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner); +} + +fn deployment_style( + deployment: &ServiceDeployment, + is_current: bool, + is_selected: bool, + is_focused: bool, +) -> Style { + let base = if is_current { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + match deployment.status { + DeploymentStatus::FAILED | DeploymentStatus::CRASHED => error_style(), + DeploymentStatus::REMOVED | DeploymentStatus::REMOVING | DeploymentStatus::SKIPPED => { + muted_style() + } + DeploymentStatus::SUCCESS => hero_style(), + _ => loading_style(), + } + }; + + if is_selected && is_focused { + base.add_modifier(Modifier::UNDERLINED) + } else { + base + } +} + +fn status_glyph(deployment: &ServiceDeployment, is_current: bool) -> &'static str { + if is_current { + "●" + } else { + match deployment.status { + DeploymentStatus::FAILED | DeploymentStatus::CRASHED => "✕", + DeploymentStatus::REMOVED | DeploymentStatus::REMOVING | DeploymentStatus::SKIPPED => { + "○" + } + DeploymentStatus::SUCCESS => "•", + _ => "◌", + } + } +} + +fn format_status(status: &DeploymentStatus) -> String { + format!("{status:?}") +} + +fn render_service_deployment_dialog(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let Some(deployment) = state.dialog_deployment() else { + return; + }; + + let is_current = state + .detail + .service + .latest_deployment + .as_ref() + .map(|latest| latest.id.as_str()) + == Some(deployment.id.as_str()); + let created_at = deployment + .created_at + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(); + let age = HumanTime::from(deployment.created_at).to_string(); + let meta = deployment + .meta + .as_ref() + .map(|meta| to_string_pretty(meta).unwrap_or_else(|_| meta.to_string())) + .unwrap_or_else(|| "none".to_string()); + let meta_preview = truncate_multiline(&meta, 12, 1000); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled("deployment details", hero_style())), + Line::default(), + Line::from(vec![ + Span::styled("service: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.detail.service.name.as_str()), + ]), + Line::from(vec![ + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(state.detail.environment_name.as_str()), + ]), + Line::from(vec![ + Span::styled( + "deployment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(deployment.id.as_str()), + ]), + Line::from(vec![ + Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format_status(&deployment.status)), + Span::raw(" • "), + Span::styled("current: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if is_current { "yes" } else { "no" }), + ]), + Line::from(vec![ + Span::styled("created: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(created_at), + ]), + Line::from(vec![ + Span::styled("age: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(age), + ]), + Line::default(), + Line::from(Span::styled("meta", panel_title_style())), + Line::from(meta_preview), + Line::default(), + Line::from(Span::styled( + "D redeploy • R rollback • Esc close", + muted_style(), + )), + ]) + .block(panel_block("deployment").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn truncate_multiline(text: &str, max_lines: usize, max_chars: usize) -> String { + let mut truncated = String::new(); + let mut line_count = 0usize; + + for line in text.lines() { + if line_count == max_lines || truncated.len() >= max_chars { + break; + } + + if !truncated.is_empty() { + truncated.push('\n'); + } + + let remaining = max_chars.saturating_sub(truncated.len()); + if line.len() > remaining { + truncated.push_str(&line[..remaining]); + break; + } + + truncated.push_str(line); + line_count += 1; + } + + if truncated.is_empty() { + text.chars().take(max_chars).collect() + } else if truncated.len() < text.len() { + format!("{truncated}\n…") + } else { + truncated + } +} + +fn render_service_confirmation(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let (title, deployment_id) = match state.confirmation.as_ref() { + Some(ServiceAction::Redeploy { deployment_id }) => ("Confirm redeploy", deployment_id), + Some(ServiceAction::Restart { deployment_id }) => ("Confirm restart", deployment_id), + Some(ServiceAction::Rollback { deployment_id }) => ("Confirm rollback", deployment_id), + None => return, + }; + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(title, hero_style())), + Line::default(), + Line::from(vec![ + Span::styled("service: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.detail.service.name.as_str()), + ]), + Line::from(vec![ + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(state.detail.environment_name.as_str()), + ]), + Line::from(vec![ + Span::styled( + "deployment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(deployment_id), + ]), + Line::default(), + Line::from(Span::styled("Enter confirm • Esc cancel", muted_style())), + ]) + .block(panel_block("confirmation").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/src/controllers/deployment.rs b/src/controllers/deployment.rs index d259c2eda..dd62189dc 100644 --- a/src/controllers/deployment.rs +++ b/src/controllers/deployment.rs @@ -1,8 +1,10 @@ use crate::{ commands::{ + Configs, mutations, queries::{self}, subscriptions::{self, build_logs, deployment, deployment_logs, http_logs}, }, + controllers::project::{find_service_instance, get_environment_instances}, post_graphql, subscription::subscribe_graphql, util::retry::RetryConfig, @@ -37,6 +39,152 @@ pub struct FetchLogsParams<'a> { pub end_date: Option>, } +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceDeployment { + pub id: String, + pub status: queries::deployments::DeploymentStatus, + pub created_at: DateTime, + pub meta: Option, +} + +pub async fn fetch_service_deployments( + client: &Client, + backboard: &str, + project_id: &str, + environment_id: &str, + service_id: &str, + limit: i64, +) -> Result> { + let vars = queries::deployments::Variables { + input: queries::deployments::DeploymentListInput { + project_id: Some(project_id.to_string()), + environment_id: Some(environment_id.to_string()), + service_id: Some(service_id.to_string()), + include_deleted: None, + status: None, + }, + first: Some(limit), + }; + + let deployments = post_graphql::(client, backboard, vars) + .await? + .deployments; + let mut all: Vec<_> = deployments + .edges + .into_iter() + .map(|edge| edge.node) + .collect(); + all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + Ok(all + .into_iter() + .take(limit.max(1) as usize) + .map(|deployment| ServiceDeployment { + id: deployment.id, + status: deployment.status, + created_at: deployment.created_at, + meta: deployment.meta, + }) + .collect()) +} + +pub async fn redeploy_deployment( + client: &Client, + backboard: &str, + deployment_id: &str, +) -> Result { + let response = post_graphql::( + client, + backboard, + mutations::deployment_redeploy::Variables { + id: deployment_id.to_string(), + }, + ) + .await?; + + Ok(response.deployment_redeploy.id) +} + +pub async fn redeploy_latest_service_deployment( + client: &Client, + configs: &Configs, + project_id: &str, + environment_id: &str, + service_id: &str, + service_name: &str, +) -> Result { + let environment_instances = + get_environment_instances(client, configs, project_id, environment_id).await?; + let service_in_env = find_service_instance(&environment_instances, service_id) + .ok_or_else(|| anyhow!("The service specified doesn't exist in the current environment"))?; + + let latest = service_in_env + .latest_deployment + .as_ref() + .ok_or_else(|| anyhow!("No deployment found for service"))?; + if !latest.can_redeploy { + anyhow::bail!( + "The latest deployment for service {service_name} cannot be redeployed. This may be because it's currently building, deploying, or was removed." + ); + } + + redeploy_deployment(client, &configs.get_backboard(), &latest.id).await +} + +pub async fn restart_deployment( + client: &Client, + backboard: &str, + deployment_id: &str, +) -> Result { + post_graphql::( + client, + backboard, + mutations::deployment_restart::Variables { + id: deployment_id.to_string(), + }, + ) + .await?; + + Ok(deployment_id.to_string()) +} + +pub async fn rollback_deployment( + client: &Client, + backboard: &str, + deployment_id: &str, +) -> Result { + post_graphql::( + client, + backboard, + mutations::deployment_rollback::Variables { + id: deployment_id.to_string(), + }, + ) + .await?; + + Ok(deployment_id.to_string()) +} + +pub async fn restart_latest_service_deployment( + client: &Client, + configs: &Configs, + project_id: &str, + environment_id: &str, + service_id: &str, +) -> Result { + let environment_instances = + get_environment_instances(client, configs, project_id, environment_id).await?; + let service_in_env = find_service_instance(&environment_instances, service_id) + .ok_or_else(|| anyhow!("The service specified doesn't exist in the current environment"))?; + + let latest = service_in_env + .latest_deployment + .as_ref() + .ok_or_else(|| anyhow!("No deployment found for service"))?; + + restart_deployment(client, &configs.get_backboard(), &latest.id).await +} + // Helper to handle the API's off-by-one bug where it returns limit+1 logs fn take_last_n_logs(mut logs: Vec, limit: Option) -> Vec { if let Some(l) = limit { diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index eb8468a13..7d6f78f2e 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,5 +1,6 @@ pub mod chat; pub mod config; +pub mod dash_tui; pub mod database; pub mod db_stats; pub mod deployment; diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index 8bae67745..9654ee0d2 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -174,6 +174,15 @@ pub struct DeploymentRedeploy; )] pub struct DeploymentRestart; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/mutations/strings/DeploymentRollback.graphql", + response_derives = "Debug, Serialize, Clone", + skip_serializing_none +)] +pub struct DeploymentRollback; + #[derive(GraphQLQuery)] #[graphql( schema_path = "src/gql/schema.json", diff --git a/src/gql/mutations/strings/DeploymentRollback.graphql b/src/gql/mutations/strings/DeploymentRollback.graphql new file mode 100644 index 000000000..46b63ac9a --- /dev/null +++ b/src/gql/mutations/strings/DeploymentRollback.graphql @@ -0,0 +1,3 @@ +mutation DeploymentRollback($id: String!) { + deploymentRollback(id: $id) +} diff --git a/src/main.rs b/src/main.rs index 31b282d8e..f3775d1d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ commands!( completion, config, connect, + dash, delete, deploy, deployment, diff --git a/src/resources.rs b/src/resources.rs index 669958663..1fe41fbf0 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,5 +1,5 @@ use crate::{ - controllers::project::ProjectServiceInstanceEdge, + controllers::{database::DatabaseType, project::ProjectServiceInstanceEdge}, gql::queries::{self}, }; @@ -55,6 +55,21 @@ pub(crate) fn is_database_service(source_image: Option<&str>) -> bool { }) } +pub(crate) fn detect_database_type(source_image: Option<&str>) -> Option { + let image = source_image?.to_ascii_lowercase(); + if image.contains("postgres") || image.contains("postgis") || image.contains("timescale") { + Some(DatabaseType::PostgreSQL) + } else if image.contains("redis") || image.contains("valkey") { + Some(DatabaseType::Redis) + } else if image.contains("mongo") { + Some(DatabaseType::MongoDB) + } else if image.contains("mysql") || image.contains("mariadb") { + Some(DatabaseType::MySQL) + } else { + None + } +} + pub(crate) fn database_label( service_instance: &ProjectServiceInstanceEdge, ) -> Option<&'static str> { diff --git a/src/util/logs.rs b/src/util/logs.rs index 736ecd536..ebe6601f1 100644 --- a/src/util/logs.rs +++ b/src/util/logs.rs @@ -118,6 +118,35 @@ where } } +pub fn format_log_string_plain(log: &T, format: LogFormat) -> String { + let timestamp = log.timestamp().trim_matches('"'); + let mut level = None; + let mut others = Vec::new(); + + for (key, value) in log.attributes() { + let value = value.trim_matches('"'); + match key.to_lowercase().as_str() { + "level" | "lvl" | "severity" => level = Some(value.to_uppercase()), + _ if matches!(format, LogFormat::Full) => others.push(format!("{key}={value}")), + _ => {} + } + } + + let mut parts = Vec::new(); + if !timestamp.is_empty() { + parts.push(timestamp.to_string()); + } + if let Some(level) = level { + parts.push(format!("[{level}]")); + } + parts.push(log.message().to_string()); + if !others.is_empty() { + parts.push(others.join(" ")); + } + + parts.join(" ") +} + /// Format a log entry as a string based and print it pub fn print_log(log: T, json: bool, format: LogFormat) where