From 0698e5ee89bc3b25ab9825fc2b0920f4595c412c Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 20:38:09 +0100 Subject: [PATCH 01/21] dash-tui: implement scaffold to enter TUI --- src/commands/dash.rs | 26 +++++ src/commands/mod.rs | 1 + src/controllers/dash_tui/mod.rs | 189 ++++++++++++++++++++++++++++++++ src/controllers/mod.rs | 1 + src/main.rs | 1 + 5 files changed, 218 insertions(+) create mode 100644 src/commands/dash.rs create mode 100644 src/controllers/dash_tui/mod.rs diff --git a/src/commands/dash.rs b/src/commands/dash.rs new file mode 100644 index 000000000..6b4e98632 --- /dev/null +++ b/src/commands/dash.rs @@ -0,0 +1,26 @@ +use super::*; + +/// 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)?; + + crate::controllers::dash_tui::run(crate::controllers::dash_tui::DashTuiParams { + project: args.project, + environment: args.environment, + }) + .await +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1aa8d7b56..3a71214e3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod autoupdate; pub mod bucket; pub mod completion; pub mod connect; +pub mod dash; pub mod delete; pub mod deploy; pub mod deployment; diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs new file mode 100644 index 000000000..5870758a9 --- /dev/null +++ b/src/controllers/dash_tui/mod.rs @@ -0,0 +1,189 @@ +use std::io::stdout; +use std::panic; + +use anyhow::Result; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind, + KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures_util::StreamExt; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; + +#[derive(Clone, Debug)] +pub struct DashTuiParams { + pub project: Option, + pub environment: Option, +} + +#[derive(Clone, Debug)] +struct DashApp { + params: DashTuiParams, +} + +impl DashApp { + fn new(params: DashTuiParams) -> Self { + Self { params } + } +} + +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 app = DashApp::new(params); + let mut events = EventStream::new(); + + loop { + terminal.draw(|frame| render(frame, &app))?; + + tokio::select! { + Some(Ok(event)) = events.next() => { + if should_quit(event, &mut terminal) { + break; + } + } + _ = tokio::signal::ctrl_c() => { + break; + } + } + } + + Ok(()) +} + +fn should_quit(event: Event, terminal: &mut Terminal>) -> bool { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + matches!(key.code, KeyCode::Char('q')) + || (matches!(key.code, KeyCode::Char('c')) + && key.modifiers.contains(KeyModifiers::CONTROL)) + } + Event::Resize(_, _) => { + let _ = terminal.clear(); + false + } + _ => false, + } +} + +fn render(frame: &mut Frame<'_>, app: &DashApp) { + let area = frame.area(); + let [header, body, footer] = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(12), + Constraint::Length(3), + ]) + .areas(area); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " Railway Dashboard ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("phase 1 placeholder"), + ])) + .block(Block::default().borders(Borders::ALL).title("railway dash")), + header, + ); + + render_placeholder(frame, body, app); + + frame.render_widget( + Paragraph::new("q quit • Ctrl-C quit • project cards and service views coming next") + .block(Block::default().borders(Borders::ALL).title("controls")) + .wrap(Wrap { trim: true }), + footer, + ); +} + +fn render_placeholder(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { + frame.render_widget(Clear, area); + + let requested_project = app.params.project.as_deref().unwrap_or("(linked project)"); + let requested_environment = app + .params + .environment + .as_deref() + .unwrap_or("(linked/default environment)"); + + let content = vec![ + Line::from(Span::styled( + "Dashboard shell is wired up.", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from("This is the initial TUI scaffold for `railway dash`."), + Line::from( + "It validates auth before opening the alternate screen and restores the terminal on exit.", + ), + Line::default(), + Line::from(vec![ + Span::styled( + "requested project: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(requested_project), + ]), + Line::from(vec![ + Span::styled( + "requested environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(requested_environment), + ]), + Line::default(), + Line::from("Planned next steps:"), + Line::from("• project cards / linked-project entry"), + Line::from("• project overview with service cards"), + Line::from("• handoff into existing metrics and volume-browser TUIs"), + Line::from("• logs and deployment flows"), + ]; + + frame.render_widget( + Paragraph::new(content) + .block(Block::default().borders(Borders::ALL).title("placeholder")) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index d6b4ab68b..5137ccdb9 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/main.rs b/src/main.rs index cea70ce0b..ea29cf42a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ commands!( bucket, completion, connect, + dash, delete, deploy, deployment, From c543d15a41f63a469f8e78a66cc9f2c9f3dc8429 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 20:46:20 +0100 Subject: [PATCH 02/21] dash-tui: add authenticataion layers --- src/commands/dash.rs | 194 +++++++++++++++++++++++++++++++- src/controllers/dash_tui/mod.rs | 97 ++++++++++++++++ 2 files changed, 289 insertions(+), 2 deletions(-) diff --git a/src/commands/dash.rs b/src/commands/dash.rs index 6b4e98632..20c24c24b 100644 --- a/src/commands/dash.rs +++ b/src/commands/dash.rs @@ -1,4 +1,18 @@ +use anyhow::anyhow; +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."; /// Open the Railway dashboard TUI #[derive(Parser)] @@ -16,11 +30,187 @@ 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)?; + let client = GQLClient::new_authorized(&configs).map_err(map_dashboard_client_auth_error)?; + let auth_mode = ensure_dashboard_login(&configs, &client).await?; - crate::controllers::dash_tui::run(crate::controllers::dash_tui::DashTuiParams { + 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, +) -> Result { + match validate_workspace_auth(configs, client).await { + Ok(()) => Ok(DashboardAuthMode::Workspace), + Err(error) if should_try_linked_project_auth(&error) => { + resolve_linked_project_auth(configs, client).await + } + 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, +) -> Result { + let linked_project = configs + .get_linked_project() + .await + .map_err(map_linked_project_auth_error)?; + + let environment_input = linked_project + .environment_name + .clone() + .or_else(|| linked_project.environment.clone()) + .ok_or_else(dashboard_login_error)?; + + let project = get_project(client, configs, linked_project.project.clone()) + .await + .map_err(map_linked_project_railway_error)?; + + if project.deleted_at.is_some() { + return Err(dashboard_login_error()); + } + + let environment = get_matched_environment(&project, environment_input) + .map_err(map_linked_project_auth_error)?; + + if environment.deleted_at.is_some() { + return Err(dashboard_login_error()); + } + + Ok(DashboardAuthMode::LinkedProject { + project_id: project.id, + environment_id: environment.id, + }) +} + +fn dashboard_login_error() -> anyhow::Error { + anyhow!(DASHBOARD_LOGIN_ERROR) +} + +fn map_dashboard_client_auth_error(error: RailwayError) -> anyhow::Error { + if should_map_auth_error_to_login(&error) { + dashboard_login_error() + } else { + error.into() + } +} + +fn map_linked_project_railway_error(error: RailwayError) -> anyhow::Error { + if should_map_linked_project_railway_error_to_login(&error) { + dashboard_login_error() + } else { + error.into() + } +} + +fn map_linked_project_auth_error(error: anyhow::Error) -> anyhow::Error { + if should_map_linked_project_anyhow_error_to_login(&error) { + dashboard_login_error() + } else { + error + } +} + +fn should_try_linked_project_auth(error: &RailwayError) -> bool { + should_map_auth_error_to_login(error) +} + +fn should_map_auth_error_to_login(error: &RailwayError) -> bool { + matches!( + error, + RailwayError::Unauthorized + | RailwayError::UnauthorizedLogin + | RailwayError::UnauthorizedToken(_) + | RailwayError::InvalidRailwayToken(_) + ) +} + +fn should_map_linked_project_railway_error_to_login(error: &RailwayError) -> bool { + should_map_auth_error_to_login(error) + || matches!( + error, + RailwayError::NoLinkedProject + | RailwayError::ProjectNotFound + | RailwayError::ProjectDeleted + | RailwayError::EnvironmentDeleted + | RailwayError::EnvironmentNotFound(_) + | RailwayError::EnvironmentRestricted(_) + ) +} + +fn should_map_linked_project_anyhow_error_to_login(error: &anyhow::Error) -> bool { + if let Some(error) = error.downcast_ref::() { + return should_map_linked_project_railway_error_to_login(error); + } + + let message = error.to_string(); + message.contains("No environment specified") + || message.contains("RAILWAY_ENVIRONMENT_ID cannot be set without RAILWAY_PROJECT_ID") +} + +#[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 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 linked_project_context_failures_map_to_login_error() { + assert!(should_map_linked_project_anyhow_error_to_login( + &RailwayError::NoLinkedProject.into() + )); + assert!(should_map_linked_project_anyhow_error_to_login( + &RailwayError::ProjectDeleted.into() + )); + assert!(should_map_linked_project_anyhow_error_to_login(&anyhow!( + "No environment specified. Set RAILWAY_ENVIRONMENT_ID" + ))); + } +} diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 5870758a9..1e29b618e 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -19,10 +19,20 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; +#[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(Clone, Debug)] @@ -147,6 +157,23 @@ fn render_placeholder(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { .as_deref() .unwrap_or("(linked/default environment)"); + let auth_mode_label = match &app.params.auth_mode { + DashboardAuthMode::Workspace => "workspace-capable auth → project cards entry", + DashboardAuthMode::LinkedProject { + project_id, + environment_id, + } => { + return render_linked_project_placeholder( + frame, + area, + requested_project, + requested_environment, + project_id, + environment_id, + ); + } + }; + let content = vec![ Line::from(Span::styled( "Dashboard shell is wired up.", @@ -158,6 +185,13 @@ fn render_placeholder(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { "It validates auth before opening the alternate screen and restores the terminal on exit.", ), Line::default(), + Line::from(vec![ + Span::styled( + "entry mode: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(auth_mode_label), + ]), Line::from(vec![ Span::styled( "requested project: ", @@ -187,3 +221,66 @@ fn render_placeholder(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { area, ); } + +fn render_linked_project_placeholder( + frame: &mut Frame<'_>, + area: Rect, + requested_project: &str, + requested_environment: &str, + project_id: &str, + environment_id: &str, +) { + let content = vec![ + Line::from(Span::styled( + "Dashboard shell is wired up.", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from("This placeholder was opened through project-scoped auth preflight."), + Line::from("Later phases will route straight into the linked project overview."), + Line::default(), + Line::from(vec![ + Span::styled( + "entry mode: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("project-scoped auth → linked project overview"), + ]), + Line::from(vec![ + Span::styled( + "validated project id: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(project_id), + ]), + Line::from(vec![ + Span::styled( + "validated environment id: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(environment_id), + ]), + Line::default(), + Line::from(vec![ + Span::styled( + "requested project: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(requested_project), + ]), + Line::from(vec![ + Span::styled( + "requested environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(requested_environment), + ]), + ]; + + frame.render_widget( + Paragraph::new(content) + .block(Block::default().borders(Borders::ALL).title("placeholder")) + .wrap(Wrap { trim: true }), + area, + ); +} From 6f20854ebcb736c8f053558070b8ce2340d6df16 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 21:15:10 +0100 Subject: [PATCH 03/21] dash-tui: implement the project cards screen --- src/commands/dash.rs | 322 ++++++++++--- src/controllers/dash_tui/data.rs | 88 ++++ src/controllers/dash_tui/mod.rs | 776 ++++++++++++++++++++++++++++--- 3 files changed, 1056 insertions(+), 130 deletions(-) create mode 100644 src/controllers/dash_tui/data.rs diff --git a/src/commands/dash.rs b/src/commands/dash.rs index 20c24c24b..8dbecf8cf 100644 --- a/src/commands/dash.rs +++ b/src/commands/dash.rs @@ -1,4 +1,4 @@ -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use reqwest::Client; use super::*; @@ -14,6 +14,12 @@ use crate::{ 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 { @@ -31,7 +37,7 @@ pub async fn command(args: Args) -> Result<()> { 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).await?; + let auth_mode = ensure_dashboard_login(&configs, &client, &args).await?; crate::controllers::dash_tui::run(DashTuiParams { project: args.project, @@ -44,11 +50,19 @@ pub async fn command(args: Args) -> Result<()> { 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(error) if should_try_linked_project_auth(&error) => { - resolve_linked_project_auth(configs, client).await + 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()), } @@ -72,31 +86,43 @@ async fn validate_workspace_auth( async fn resolve_linked_project_auth( configs: &Configs, client: &Client, -) -> Result { + 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(map_linked_project_auth_error)?; + .map_err(classify_linked_project_lookup_error)?; let environment_input = linked_project .environment_name .clone() .or_else(|| linked_project.environment.clone()) - .ok_or_else(dashboard_login_error)?; + .ok_or(LinkedProjectAuthPreflightError::MissingProjectScopedContext)?; let project = get_project(client, configs, linked_project.project.clone()) .await - .map_err(map_linked_project_railway_error)?; + .map_err(LinkedProjectAuthPreflightError::from)?; if project.deleted_at.is_some() { - return Err(dashboard_login_error()); + return Err(LinkedProjectAuthPreflightError::from( + RailwayError::ProjectDeleted, + )); } let environment = get_matched_environment(&project, environment_input) - .map_err(map_linked_project_auth_error)?; + .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(dashboard_login_error()); + return Err(LinkedProjectAuthPreflightError::from( + RailwayError::EnvironmentDeleted, + )); } Ok(DashboardAuthMode::LinkedProject { @@ -105,39 +131,96 @@ async fn resolve_linked_project_auth( }) } +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 map_dashboard_client_auth_error(error: RailwayError) -> anyhow::Error { - if should_map_auth_error_to_login(&error) { - dashboard_login_error() - } else { - error.into() +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_linked_project_railway_error(error: RailwayError) -> anyhow::Error { - if should_map_linked_project_railway_error_to_login(&error) { - dashboard_login_error() - } else { - error.into() +fn map_dashboard_client_auth_error(error: RailwayError) -> anyhow::Error { + match error { + RailwayError::Unauthorized => dashboard_login_error(), + other => other.into(), } } -fn map_linked_project_auth_error(error: anyhow::Error) -> anyhow::Error { - if should_map_linked_project_anyhow_error_to_login(&error) { - dashboard_login_error() +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 { - error + LinkedProjectAuthPreflightError::Other(error) } } -fn should_try_linked_project_auth(error: &RailwayError) -> bool { - should_map_auth_error_to_login(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 should_map_auth_error_to_login(error: &RailwayError) -> bool { +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 @@ -147,27 +230,16 @@ fn should_map_auth_error_to_login(error: &RailwayError) -> bool { ) } -fn should_map_linked_project_railway_error_to_login(error: &RailwayError) -> bool { - should_map_auth_error_to_login(error) - || matches!( - error, - RailwayError::NoLinkedProject - | RailwayError::ProjectNotFound - | RailwayError::ProjectDeleted - | RailwayError::EnvironmentDeleted - | RailwayError::EnvironmentNotFound(_) - | RailwayError::EnvironmentRestricted(_) - ) +impl From for LinkedProjectAuthPreflightError { + fn from(error: anyhow::Error) -> Self { + Self::Other(error) + } } -fn should_map_linked_project_anyhow_error_to_login(error: &anyhow::Error) -> bool { - if let Some(error) = error.downcast_ref::() { - return should_map_linked_project_railway_error_to_login(error); +impl From for LinkedProjectAuthPreflightError { + fn from(error: RailwayError) -> Self { + Self::Other(error.into()) } - - let message = error.to_string(); - message.contains("No environment specified") - || message.contains("RAILWAY_ENVIRONMENT_ID cannot be set without RAILWAY_PROJECT_ID") } #[cfg(test)] @@ -179,6 +251,15 @@ mod tests { 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)); @@ -202,15 +283,148 @@ mod tests { } #[test] - fn linked_project_context_failures_map_to_login_error() { - assert!(should_map_linked_project_anyhow_error_to_login( - &RailwayError::NoLinkedProject.into() + 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!(should_map_linked_project_anyhow_error_to_login( - &RailwayError::ProjectDeleted.into() + assert!(!has_incomplete_env_var_project_scope_from( + Some("proj_123".to_string()), + Some("env_123".to_string()) )); - assert!(should_map_linked_project_anyhow_error_to_login(&anyhow!( - "No environment specified. Set RAILWAY_ENVIRONMENT_ID" - ))); + 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/controllers/dash_tui/data.rs b/src/controllers/dash_tui/data.rs new file mode 100644 index 000000000..2ef5a5441 --- /dev/null +++ b/src/controllers/dash_tui/data.rs @@ -0,0 +1,88 @@ +use anyhow::Result; + +use crate::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, +} + +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) + } +} + +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) +} + +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(), + }, + } +} + +#[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")); + } +} diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 1e29b618e..9fb81d18b 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -1,10 +1,13 @@ +mod data; + 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, KeyEventKind, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }; use crossterm::execute; @@ -14,10 +17,19 @@ use crossterm::terminal::{ use futures_util::StreamExt; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; +use tokio::sync::mpsc; +use tokio::time::MissedTickBehavior; + +use self::data::{ProjectCard, load_project_cards}; + +const PROJECT_CARD_MIN_WIDTH: u16 = 30; +const PROJECT_CARD_HEIGHT: u16 = 7; +const PROJECT_CARD_GAP: u16 = 1; +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; #[derive(Clone, Debug, PartialEq, Eq)] pub enum DashboardAuthMode { @@ -38,11 +50,261 @@ pub struct DashTuiParams { #[derive(Clone, Debug)] struct DashApp { params: DashTuiParams, + screen: DashboardScreen, + project_preview: Option, + spinner_tick: usize, +} + +#[derive(Clone, Debug)] +enum DashboardScreen { + Projects(ProjectsScreenState), + LinkedProjectPlaceholder, +} + +#[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>, + }, } impl DashApp { fn new(params: DashTuiParams) -> Self { - Self { params } + let screen = match params.auth_mode { + DashboardAuthMode::Workspace => { + DashboardScreen::Projects(ProjectsScreenState::new(params.project.clone())) + } + DashboardAuthMode::LinkedProject { .. } => DashboardScreen::LinkedProjectPlaceholder, + }; + + Self { + params, + screen, + project_preview: None, + spinner_tick: 0, + } + } + + fn start_initial_load(&mut self, tx: &mpsc::UnboundedSender) { + if matches!(self.screen, DashboardScreen::Projects(_)) { + self.refresh_projects(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 handle_loader_event(&mut self, event: LoaderEvent) { + 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), + } + } + } + } + + fn handle_event( + &mut self, + event: Event, + terminal: &mut Terminal>, + tx: &mpsc::UnboundedSender, + ) -> bool { + match event { + Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + self.handle_key(key, terminal.size().unwrap_or_default().width, tx) + } + Event::Resize(_, _) => { + let _ = terminal.clear(); + false + } + _ => false, + } + } + + fn handle_key( + &mut self, + key: KeyEvent, + terminal_width: u16, + tx: &mpsc::UnboundedSender, + ) -> bool { + if matches!(key.code, KeyCode::Char('q')) + || (matches!(key.code, KeyCode::Char('c')) + && key.modifiers.contains(KeyModifiers::CONTROL)) + { + return true; + } + + if self.project_preview.is_some() { + if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) { + self.project_preview = None; + } + return false; + } + + match &mut self.screen { + DashboardScreen::Projects(state) => { + if state.filter_mode { + handle_projects_filter_input(state, key); + return false; + } + + let columns = project_grid_columns(terminal_width.saturating_sub(4)); + 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 => { + self.project_preview = state.selected_card().cloned(); + } + KeyCode::Char('/') => state.filter_mode = true, + KeyCode::Char('r') => self.refresh_projects(tx), + _ => {} + } + } + DashboardScreen::LinkedProjectPlaceholder => {} + } + + 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); + } } } @@ -80,18 +342,29 @@ pub async fn run(params: DashTuiParams) -> Result<()> { restore_terminal(); }); - let app = DashApp::new(params); + 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| render(frame, &app))?; tokio::select! { Some(Ok(event)) = events.next() => { - if should_quit(event, &mut terminal) { + if app.handle_event(event, &mut terminal, &loader_tx) { break; } } + Some(loader_event) = loader_rx.recv() => { + app.handle_loader_event(loader_event); + } + _ = tick.tick() => { + app.on_tick(); + } _ = tokio::signal::ctrl_c() => { break; } @@ -101,18 +374,27 @@ pub async fn run(params: DashTuiParams) -> Result<()> { Ok(()) } -fn should_quit(event: Event, terminal: &mut Terminal>) -> bool { - match event { - Event::Key(key) if key.kind == KeyEventKind::Press => { - matches!(key.code, KeyCode::Char('q')) - || (matches!(key.code, KeyCode::Char('c')) - && key.modifiers.contains(KeyModifiers::CONTROL)) +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(); } - Event::Resize(_, _) => { - let _ = terminal.clear(); - false + KeyCode::Delete => { + state.filter.clear(); + state.selected = 0; } - _ => false, + 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; + } + _ => {} } } @@ -125,99 +407,355 @@ fn render(frame: &mut Frame<'_>, app: &DashApp) { ]) .areas(area); + render_header(frame, header, app); + + match &app.screen { + DashboardScreen::Projects(state) => { + render_projects_screen(frame, body, app, state); + if let Some(card) = &app.project_preview { + render_project_preview(frame, centered_rect(body, 70, 65), card); + } + } + DashboardScreen::LinkedProjectPlaceholder => render_linked_project_placeholder( + frame, + body, + app.params.project.as_deref().unwrap_or("(linked project)"), + app.params + .environment + .as_deref() + .unwrap_or("(linked/default environment)"), + match &app.params.auth_mode { + DashboardAuthMode::LinkedProject { + project_id, + environment_id: _, + } => project_id, + DashboardAuthMode::Workspace => unreachable!(), + }, + match &app.params.auth_mode { + DashboardAuthMode::LinkedProject { + project_id: _, + environment_id, + } => environment_id, + DashboardAuthMode::Workspace => unreachable!(), + }, + ), + } + + render_footer(frame, footer, app); +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { + let subtitle = if app.project_preview.is_some() { + "project preview placeholder" + } else { + match &app.screen { + DashboardScreen::Projects(_) => "project cards", + DashboardScreen::LinkedProjectPlaceholder => "linked project placeholder", + } + }; + frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled( " Railway Dashboard ", Style::default().add_modifier(Modifier::BOLD), ), - Span::raw("phase 1 placeholder"), + Span::raw(subtitle), ])) .block(Block::default().borders(Borders::ALL).title("railway dash")), - header, + area, ); +} - render_placeholder(frame, body, app); +fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { + let text = if app.project_preview.is_some() { + "Esc back • q quit" + } else { + 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::LinkedProjectPlaceholder => { + "q quit • Ctrl-C quit • linked project overview coming next" + } + } + }; frame.render_widget( - Paragraph::new("q quit • Ctrl-C quit • project cards and service views coming next") + Paragraph::new(text) .block(Block::default().borders(Borders::ALL).title("controls")) .wrap(Wrap { trim: true }), - footer, + area, ); } -fn render_placeholder(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { +fn render_projects_screen( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectsScreenState, +) { frame.render_widget(Clear, area); - let requested_project = app.params.project.as_deref().unwrap_or("(linked project)"); - let requested_environment = app - .params - .environment - .as_deref() - .unwrap_or("(linked/default environment)"); - - let auth_mode_label = match &app.params.auth_mode { - DashboardAuthMode::Workspace => "workspace-capable auth → project cards entry", - DashboardAuthMode::LinkedProject { - project_id, - environment_id, - } => { - return render_linked_project_placeholder( - frame, - area, - requested_project, - requested_environment, - project_id, - environment_id, - ); - } - }; + let [status_area, grid_area] = + Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area); - let content = vec![ - Line::from(Span::styled( - "Dashboard shell is wired up.", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::default(), - Line::from("This is the initial TUI scaffold for `railway dash`."), - Line::from( - "It validates auth before opening the alternate screen and restores the terminal on exit.", - ), - Line::default(), + 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( - "entry mode: ", - Style::default().add_modifier(Modifier::BOLD), + format!("{}█", state.filter), + Style::default().fg(Color::Yellow), ), - Span::raw(auth_mode_label), - ]), + ]) + } else if state.filter.is_empty() { + Line::from(vec![ + Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw("/ to search projects"), + ]) + } else { + Line::from(vec![ + Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(state.filter.as_str(), Style::default().fg(Color::Yellow)), + ]) + }; + + let status_line = if let Some(error) = &state.error { Line::from(vec![ Span::styled( - "requested project: ", - Style::default().add_modifier(Modifier::BOLD), + "error: ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ), - Span::raw(requested_project), - ]), + 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} "), Style::default().fg(Color::Cyan)), + Span::raw(label), + ]) + } else { + Line::from(vec![ + Span::styled("selected: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(selected), + Span::raw(" • "), Span::styled( - "requested environment: ", - Style::default().add_modifier(Modifier::BOLD), + format!("{visible_count} visible / {} total", state.cards.len()), + Style::default().fg(Color::DarkGray), ), - Span::raw(requested_environment), - ]), - Line::default(), - Line::from("Planned next steps:"), - Line::from("• project cards / linked-project entry"), - Line::from("• project overview with service cards"), - Line::from("• handoff into existing metrics and volume-browser TUIs"), - Line::from("• logs and deployment flows"), - ]; + ]) + }; frame.render_widget( - Paragraph::new(content) - .block(Block::default().borders(Borders::ALL).title("placeholder")) + Paragraph::new(vec![filter_line, Line::default(), status_line]) + .block(Block::default().borders(Borders::ALL).title("projects")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_projects_grid(frame: &mut Frame<'_>, area: Rect, state: &ProjectsScreenState) { + let block = Block::default().borders(Borders::ALL).title("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(Style::default().fg(Color::Yellow)) + .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(Style::default().fg(Color::Cyan)) + .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(Style::default().fg(Color::Red)) .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(Style::default().fg(Color::DarkGray)) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let columns = project_grid_columns(inner.width).max(1); + let card_width = project_card_width(inner.width, columns); + let rows_per_page = project_rows_per_page(inner.height).max(1); + 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 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + let title_style = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD) + }; + + 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: ", Style::default().fg(Color::DarkGray)), + Span::raw(workspace_name), + ]), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(card.id.as_str()), + ) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_project_preview(frame: &mut Frame<'_>, area: Rect, card: &ProjectCard) { + let workspace_name = card.workspace_name.as_deref().unwrap_or("personal"); + + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "Project overview is the next phase.", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from(vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(card.name.as_str()), + ]), + Line::from(vec![ + Span::styled( + "project id: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(card.id.as_str()), + ]), + Line::from(vec![ + Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(workspace_name), + ]), + Line::from(vec![ + Span::styled("services: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(card.service_count.to_string()), + ]), + Line::from(vec![ + Span::styled( + "environments: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(card.environment_count.to_string()), + ]), + Line::default(), + Line::from("Phase 4 will open this project and resolve its selected environment."), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .title("project preview"), + ) + .wrap(Wrap { trim: true }), area, ); } @@ -284,3 +822,89 @@ fn render_linked_project_placeholder( area, ); } + +fn project_grid_columns(width: u16) -> usize { + let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; + ((width + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +fn project_card_width(width: u16, columns: usize) -> u16 { + let columns = columns.max(1) as u16; + let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); + width.saturating_sub(total_gaps) / columns +} + +fn project_rows_per_page(height: u16) -> usize { + let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; + ((height + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +fn pluralize(count: usize, singular: &str) -> String { + if count == 1 { + singular.to_string() + } else { + format!("{singular}s") + } +} + +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] +} + +#[cfg(test)] +mod tests { + use super::*; + + 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, + } + } + + #[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!(project_grid_columns(10), 1); + assert!(project_grid_columns(120) >= 1); + } +} From 781933c5ee69aedae00d9842e047109ee14e6138 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 21:51:37 +0100 Subject: [PATCH 04/21] dash-tui: create the project internal page --- src/controllers/dash_tui/data.rs | 265 ++++++++- src/controllers/dash_tui/mod.rs | 924 ++++++++++++++----------------- src/controllers/dash_tui/ui.rs | 877 +++++++++++++++++++++++++++++ 3 files changed, 1562 insertions(+), 504 deletions(-) create mode 100644 src/controllers/dash_tui/ui.rs diff --git a/src/controllers/dash_tui/data.rs b/src/controllers/dash_tui/data.rs index 2ef5a5441..6837d1395 100644 --- a/src/controllers/dash_tui/data.rs +++ b/src/controllers/dash_tui/data.rs @@ -1,6 +1,22 @@ -use anyhow::Result; +use anyhow::{Result, anyhow, bail}; +use chrono::{DateTime, Utc}; -use crate::workspace::{Project, workspaces}; +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 { @@ -11,6 +27,55 @@ pub struct ProjectCard { 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 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(); @@ -29,6 +94,15 @@ impl ProjectCard { } } +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(); @@ -47,6 +121,26 @@ pub async fn load_project_cards() -> Result> { Ok(cards) } +pub async fn load_dashboard_project(target: ProjectLoadTarget) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + 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 { @@ -66,6 +160,132 @@ fn project_card_from_project(project: Project, workspace_name: String) -> Projec } } +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()), + 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::*; @@ -85,4 +305,45 @@ mod tests { 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/mod.rs b/src/controllers/dash_tui/mod.rs index 9fb81d18b..ed715f884 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -1,4 +1,5 @@ mod data; +mod ui; use std::io::stdout; use std::panic; @@ -15,20 +16,17 @@ 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::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; -use ratatui::{Frame, Terminal}; +use ratatui::layout::Rect; use tokio::sync::mpsc; use tokio::time::MissedTickBehavior; -use self::data::{ProjectCard, load_project_cards}; +use self::data::{ + DashboardProject, DashboardService, ProjectCard, ProjectLoadTarget, load_dashboard_project, + load_project_cards, +}; -const PROJECT_CARD_MIN_WIDTH: u16 = 30; -const PROJECT_CARD_HEIGHT: u16 = 7; -const PROJECT_CARD_GAP: u16 = 1; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; #[derive(Clone, Debug, PartialEq, Eq)] @@ -51,14 +49,13 @@ pub struct DashTuiParams { struct DashApp { params: DashTuiParams, screen: DashboardScreen, - project_preview: Option, spinner_tick: usize, } #[derive(Clone, Debug)] enum DashboardScreen { Projects(ProjectsScreenState), - LinkedProjectPlaceholder, + Project(ProjectScreenState), } #[derive(Clone, Debug)] @@ -73,33 +70,70 @@ struct ProjectsScreenState { initial_selection_hint: Option, } +#[derive(Clone, Debug)] +struct ProjectScreenState { + target: ProjectLoadTarget, + project: Option, + selected_service: usize, + loading: bool, + error: Option, + current_request_id: u64, + return_to_projects: Option>, + environment_selector: Option, +} + +#[derive(Clone, Debug)] +struct EnvironmentSelectorState { + selected: usize, +} + enum LoaderEvent { ProjectsLoaded { request_id: u64, result: std::result::Result, String>, }, + ProjectLoaded { + request_id: u64, + result: std::result::Result, + }, } impl DashApp { fn new(params: DashTuiParams) -> Self { - let screen = match params.auth_mode { - DashboardAuthMode::Workspace => { - DashboardScreen::Projects(ProjectsScreenState::new(params.project.clone())) - } - DashboardAuthMode::LinkedProject { .. } => DashboardScreen::LinkedProjectPlaceholder, + 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(), + }, + Some(Box::new(ProjectsScreenState::new(Some(project_id.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()), + }, + None, + )), }; Self { params, screen, - project_preview: None, spinner_tick: 0, } } fn start_initial_load(&mut self, tx: &mpsc::UnboundedSender) { - if matches!(self.screen, DashboardScreen::Projects(_)) { - self.refresh_projects(tx); + match self.screen { + DashboardScreen::Projects(_) => self.refresh_projects(tx), + DashboardScreen::Project(_) => self.refresh_project(tx), } } @@ -122,6 +156,109 @@ impl DashApp { }); } + 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 open_project( + &mut self, + project_id: String, + return_to_projects: Option>, + tx: &mpsc::UnboundedSender, + ) { + let environment_hint = self.params.environment.clone(); + self.screen = DashboardScreen::Project(ProjectScreenState::new( + ProjectLoadTarget { + project_id, + environment_hint, + }, + return_to_projects, + )); + self.refresh_project(tx); + } + + 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_to_projects(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Project(state) = &self.screen else { + return; + }; + + let restored = state + .return_to_projects + .as_ref() + .map(|projects| (**projects).clone()) + .or_else(|| match self.params.auth_mode { + DashboardAuthMode::Workspace => Some(ProjectsScreenState::new(Some( + state.target.project_id.clone(), + ))), + DashboardAuthMode::LinkedProject { .. } => None, + }); + + if let Some(restored) = restored { + self.screen = DashboardScreen::Projects(restored); + self.refresh_projects(tx); + } + } + fn handle_loader_event(&mut self, event: LoaderEvent) { match event { LoaderEvent::ProjectsLoaded { request_id, result } => { @@ -138,6 +275,20 @@ impl DashApp { 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), + } + } } } @@ -149,7 +300,8 @@ impl DashApp { ) -> bool { match event { Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { - self.handle_key(key, terminal.size().unwrap_or_default().width, tx) + let size = terminal.size().unwrap_or_default(); + self.handle_key(key, Rect::new(0, 0, size.width, size.height), tx) } Event::Resize(_, _) => { let _ = terminal.clear(); @@ -162,7 +314,7 @@ impl DashApp { fn handle_key( &mut self, key: KeyEvent, - terminal_width: u16, + terminal_area: Rect, tx: &mpsc::UnboundedSender, ) -> bool { if matches!(key.code, KeyCode::Char('q')) @@ -172,35 +324,111 @@ impl DashApp { return true; } - if self.project_preview.is_some() { - if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) { - self.project_preview = None; - } + if matches!( + self.screen, + DashboardScreen::Projects(ProjectsScreenState { + filter_mode: true, + .. + }) + ) && let DashboardScreen::Projects(state) = &mut self.screen + { + handle_projects_filter_input(state, key); return false; } + if let DashboardScreen::Project(state) = &mut self.screen + && state.environment_selector.is_some() + { + return self.handle_environment_selector_key(key, tx); + } + + let mut project_to_open: Option<(String, Box)> = None; + let mut should_refresh_projects = false; + let mut should_refresh_project = false; + let mut should_back_to_projects = false; + let mut should_open_environment_selector = false; + match &mut self.screen { DashboardScreen::Projects(state) => { - if state.filter_mode { - handle_projects_filter_input(state, key); - return false; - } - - let columns = project_grid_columns(terminal_width.saturating_sub(4)); + let columns = ui::project_navigation_columns(terminal_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 => { - self.project_preview = state.selected_card().cloned(); + if let Some(card) = state.selected_card().cloned() { + project_to_open = Some((card.id, Box::new(state.clone()))); + } } KeyCode::Char('/') => state.filter_mode = true, - KeyCode::Char('r') => self.refresh_projects(tx), + KeyCode::Char('r') => should_refresh_projects = true, _ => {} } } - DashboardScreen::LinkedProjectPlaceholder => {} + DashboardScreen::Project(state) => { + let columns = ui::service_navigation_columns(terminal_area); + match key.code { + KeyCode::Esc | KeyCode::Backspace => should_back_to_projects = true, + 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::Char('e') => should_open_environment_selector = true, + KeyCode::Char('r') => should_refresh_project = true, + _ => {} + } + } + } + + if let Some((project_id, return_to_projects)) = project_to_open { + self.open_project(project_id, Some(return_to_projects), tx); + } else if should_refresh_projects { + self.refresh_projects(tx); + } else if should_back_to_projects { + self.back_to_projects(tx); + } else if should_open_environment_selector { + self.open_environment_selector(); + } else if should_refresh_project { + self.refresh_project(tx); + } + + 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 @@ -308,6 +536,107 @@ impl ProjectsScreenState { } } +impl ProjectScreenState { + fn new( + target: ProjectLoadTarget, + return_to_projects: Option>, + ) -> Self { + Self { + target, + project: None, + selected_service: 0, + loading: false, + error: None, + current_request_id: 0, + return_to_projects, + environment_selector: None, + } + } + + fn selected_service(&self) -> Option<&DashboardService> { + self.project + .as_ref() + .and_then(|project| project.services.get(self.selected_service)) + } + + 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); + } + } + + fn set_error(&mut self, error: String) { + self.loading = false; + self.error = Some(error); + self.environment_selector = None; + self.clamp_selection(); + } + + fn move_left(&mut self) { + self.selected_service = self.selected_service.saturating_sub(1); + } + + 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); + } + } + + fn move_up(&mut self, columns: usize) { + self.selected_service = self.selected_service.saturating_sub(columns.max(1)); + } + + 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); + } + } + + 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(); + } + } + } + + 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); + } + } +} + fn setup_terminal() -> Result>> { enable_raw_mode()?; @@ -351,7 +680,7 @@ pub async fn run(params: DashTuiParams) -> Result<()> { tick.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { - terminal.draw(|frame| render(frame, &app))?; + terminal.draw(|frame| ui::render(frame, &app))?; tokio::select! { Some(Ok(event)) = events.next() => { @@ -398,471 +727,6 @@ fn handle_projects_filter_input(state: &mut ProjectsScreenState, key: KeyEvent) } } -fn render(frame: &mut Frame<'_>, app: &DashApp) { - let area = frame.area(); - let [header, body, footer] = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(12), - Constraint::Length(3), - ]) - .areas(area); - - render_header(frame, header, app); - - match &app.screen { - DashboardScreen::Projects(state) => { - render_projects_screen(frame, body, app, state); - if let Some(card) = &app.project_preview { - render_project_preview(frame, centered_rect(body, 70, 65), card); - } - } - DashboardScreen::LinkedProjectPlaceholder => render_linked_project_placeholder( - frame, - body, - app.params.project.as_deref().unwrap_or("(linked project)"), - app.params - .environment - .as_deref() - .unwrap_or("(linked/default environment)"), - match &app.params.auth_mode { - DashboardAuthMode::LinkedProject { - project_id, - environment_id: _, - } => project_id, - DashboardAuthMode::Workspace => unreachable!(), - }, - match &app.params.auth_mode { - DashboardAuthMode::LinkedProject { - project_id: _, - environment_id, - } => environment_id, - DashboardAuthMode::Workspace => unreachable!(), - }, - ), - } - - render_footer(frame, footer, app); -} - -fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { - let subtitle = if app.project_preview.is_some() { - "project preview placeholder" - } else { - match &app.screen { - DashboardScreen::Projects(_) => "project cards", - DashboardScreen::LinkedProjectPlaceholder => "linked project placeholder", - } - }; - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled( - " Railway Dashboard ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(subtitle), - ])) - .block(Block::default().borders(Borders::ALL).title("railway dash")), - area, - ); -} - -fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { - let text = if app.project_preview.is_some() { - "Esc back • q quit" - } else { - 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::LinkedProjectPlaceholder => { - "q quit • Ctrl-C quit • linked project overview coming next" - } - } - }; - - frame.render_widget( - Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("controls")) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_projects_screen( - frame: &mut Frame<'_>, - area: Rect, - app: &DashApp, - state: &ProjectsScreenState, -) { - frame.render_widget(Clear, area); - - let [status_area, grid_area] = - Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(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), - Style::default().fg(Color::Yellow), - ), - ]) - } else if state.filter.is_empty() { - Line::from(vec![ - Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw("/ to search projects"), - ]) - } else { - Line::from(vec![ - Span::styled("filter: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(state.filter.as_str(), Style::default().fg(Color::Yellow)), - ]) - }; - - let status_line = if let Some(error) = &state.error { - Line::from(vec![ - Span::styled( - "error: ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - 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} "), Style::default().fg(Color::Cyan)), - Span::raw(label), - ]) - } else { - Line::from(vec![ - Span::styled("selected: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(selected), - Span::raw(" • "), - Span::styled( - format!("{visible_count} visible / {} total", state.cards.len()), - Style::default().fg(Color::DarkGray), - ), - ]) - }; - - frame.render_widget( - Paragraph::new(vec![filter_line, Line::default(), status_line]) - .block(Block::default().borders(Borders::ALL).title("projects")) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_projects_grid(frame: &mut Frame<'_>, area: Rect, state: &ProjectsScreenState) { - let block = Block::default().borders(Borders::ALL).title("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(Style::default().fg(Color::Yellow)) - .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(Style::default().fg(Color::Cyan)) - .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(Style::default().fg(Color::Red)) - .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(Style::default().fg(Color::DarkGray)) - .wrap(Wrap { trim: true }), - inner, - ); - return; - } - - let columns = project_grid_columns(inner.width).max(1); - let card_width = project_card_width(inner.width, columns); - let rows_per_page = project_rows_per_page(inner.height).max(1); - 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 { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - }; - let title_style = if selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().add_modifier(Modifier::BOLD) - }; - - 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: ", Style::default().fg(Color::DarkGray)), - Span::raw(workspace_name), - ]), - ]) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(card.id.as_str()), - ) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_project_preview(frame: &mut Frame<'_>, area: Rect, card: &ProjectCard) { - let workspace_name = card.workspace_name.as_deref().unwrap_or("personal"); - - frame.render_widget(Clear, area); - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled( - "Project overview is the next phase.", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::default(), - Line::from(vec![ - Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(card.name.as_str()), - ]), - Line::from(vec![ - Span::styled( - "project id: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(card.id.as_str()), - ]), - Line::from(vec![ - Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(workspace_name), - ]), - Line::from(vec![ - Span::styled("services: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(card.service_count.to_string()), - ]), - Line::from(vec![ - Span::styled( - "environments: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(card.environment_count.to_string()), - ]), - Line::default(), - Line::from("Phase 4 will open this project and resolve its selected environment."), - ]) - .block( - Block::default() - .borders(Borders::ALL) - .title("project preview"), - ) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_linked_project_placeholder( - frame: &mut Frame<'_>, - area: Rect, - requested_project: &str, - requested_environment: &str, - project_id: &str, - environment_id: &str, -) { - let content = vec![ - Line::from(Span::styled( - "Dashboard shell is wired up.", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::default(), - Line::from("This placeholder was opened through project-scoped auth preflight."), - Line::from("Later phases will route straight into the linked project overview."), - Line::default(), - Line::from(vec![ - Span::styled( - "entry mode: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw("project-scoped auth → linked project overview"), - ]), - Line::from(vec![ - Span::styled( - "validated project id: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(project_id), - ]), - Line::from(vec![ - Span::styled( - "validated environment id: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(environment_id), - ]), - Line::default(), - Line::from(vec![ - Span::styled( - "requested project: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(requested_project), - ]), - Line::from(vec![ - Span::styled( - "requested environment: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(requested_environment), - ]), - ]; - - frame.render_widget( - Paragraph::new(content) - .block(Block::default().borders(Borders::ALL).title("placeholder")) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn project_grid_columns(width: u16) -> usize { - let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; - ((width + PROJECT_CARD_GAP) / stride).max(1) as usize -} - -fn project_card_width(width: u16, columns: usize) -> u16 { - let columns = columns.max(1) as u16; - let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); - width.saturating_sub(total_gaps) / columns -} - -fn project_rows_per_page(height: u16) -> usize { - let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; - ((height + PROJECT_CARD_GAP) / stride).max(1) as usize -} - -fn pluralize(count: usize, singular: &str) -> String { - if count == 1 { - singular.to_string() - } else { - format!("{singular}s") - } -} - -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] -} - #[cfg(test)] mod tests { use super::*; @@ -877,6 +741,34 @@ mod tests { } } + 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, + 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); @@ -904,7 +796,35 @@ mod tests { #[test] fn project_grid_has_at_least_one_column() { - assert_eq!(project_grid_columns(10), 1); - assert!(project_grid_columns(120) >= 1); + assert_eq!(ui::project_grid_columns(10), 1); + assert!(ui::project_grid_columns(120) >= 1); + } + + #[test] + fn service_grid_has_at_least_one_column() { + assert_eq!(ui::service_grid_columns(10), 1); + assert!(ui::service_grid_columns(120) >= 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()), + }, + None, + ); + 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") + ); } } diff --git a/src/controllers/dash_tui/ui.rs b/src/controllers/dash_tui/ui.rs new file mode 100644 index 000000000..d2c3b82d8 --- /dev/null +++ b/src/controllers/dash_tui/ui.rs @@ -0,0 +1,877 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +use super::{DashApp, DashboardAuthMode, DashboardScreen, ProjectScreenState, ProjectsScreenState}; +use crate::controllers::dash_tui::data::{DashboardService, ProjectCard}; + +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; + +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); + +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())) +} + +fn panel_border_style() -> Style { + Style::default().fg(RAILWAY_PANEL) +} + +fn panel_title_style() -> Style { + Style::default() + .fg(RAILWAY_PURPLE) + .add_modifier(Modifier::BOLD) +} + +fn hero_style() -> Style { + Style::default() + .fg(RAILWAY_LAVENDER) + .add_modifier(Modifier::BOLD) +} + +fn selected_border_style() -> Style { + Style::default().fg(RAILWAY_PINK) +} + +fn selected_title_style() -> Style { + Style::default() + .fg(RAILWAY_PINK) + .add_modifier(Modifier::BOLD) +} + +fn loading_style() -> Style { + Style::default().fg(RAILWAY_VIOLET) +} + +fn accent_style() -> Style { + Style::default().fg(RAILWAY_PINK) +} + +fn muted_style() -> Style { + Style::default().fg(RAILWAY_MUTED) +} + +fn error_style() -> Style { + Style::default() + .fg(RAILWAY_ERROR) + .add_modifier(Modifier::BOLD) +} + +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), + } + + render_footer(frame, footer, app); +} + +pub(super) fn project_navigation_columns(frame_area: Rect) -> usize { + project_grid_columns_for_area(projects_grid_area(frame_area)) +} + +pub(super) fn service_navigation_columns(frame_area: Rect) -> usize { + service_grid_columns_for_area(project_services_area(frame_area)) +} + +pub(super) fn project_grid_columns(width: u16) -> usize { + let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; + ((width + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +pub(super) fn service_grid_columns(width: u16) -> usize { + let stride = SERVICE_CARD_MIN_WIDTH + SERVICE_CARD_GAP; + ((width + SERVICE_CARD_GAP) / stride).max(1) as usize +} + +fn project_grid_columns_for_area(area: Rect) -> usize { + project_grid_columns(panel_inner_area(area).width).max(1) +} + +fn service_grid_columns_for_area(area: Rect) -> usize { + service_grid_columns(panel_inner_area(area).width).max(1) +} + +fn dashboard_sections(area: Rect) -> [Rect; 3] { + Layout::vertical([ + Constraint::Length(3), + Constraint::Min(12), + Constraint::Length(3), + ]) + .areas(area) +} + +fn projects_sections(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) +} + +fn project_sections(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) +} + +fn project_overview_sections(area: Rect) -> [Rect; 2] { + Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]).areas(area) +} + +fn projects_grid_area(frame_area: Rect) -> Rect { + let [_, body, _] = dashboard_sections(frame_area); + let [_, grid_area] = projects_sections(body); + grid_area +} + +fn project_services_area(frame_area: Rect) -> Rect { + let [_, body, _] = dashboard_sections(frame_area); + let [_, main_area] = project_sections(body); + let [diagram_area, _] = project_overview_sections(main_area); + diagram_area +} + +fn panel_inner_area(area: Rect) -> Rect { + panel_block("").inner(area) +} + +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", + }; + + 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 => { + "Esc back • e environments • arrows/ijkl move • r reload • q quit" + } + DashboardAuthMode::LinkedProject { .. } => { + "e environments • arrows/ijkl move • r reload • q quit" + } + }, + }; + + frame.render_widget( + Paragraph::new(text) + .style(muted_style()) + .block(panel_block("controls")) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn render_projects_screen( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectsScreenState, +) { + frame.render_widget(Clear, area); + + let [status_area, grid_area] = projects_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 = super::SPINNER_FRAMES[app.spinner_tick % super::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 = project_grid_columns_for_area(area); + let card_width = project_card_width(inner.width, columns); + let rows_per_page = project_rows_per_page(inner.height).max(1); + 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, + ); +} + +fn render_project_screen( + frame: &mut Frame<'_>, + area: Rect, + app: &DashApp, + state: &ProjectScreenState, +) { + frame.render_widget(Clear, area); + + let [status_area, main_area] = project_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...", + super::SPINNER_FRAMES[app.spinner_tick % super::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 = super::SPINNER_FRAMES[app.spinner_tick % super::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 = service_grid_columns_for_area(area); + let card_width = service_card_width(inner.width, columns); + let rows_per_page = service_rows_per_page(inner.height).max(1); + 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) { + let Some(project) = &state.project else { + 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 workspace_name = project.workspace_name.as_deref().unwrap_or("personal"); + let deployment_status = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.status.as_str()) + .unwrap_or("no deployment"); + let deployment_id = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()) + .unwrap_or("-"); + 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") + }; + let source = service + .source_repo + .as_deref() + .or(service.source_image.as_deref()) + .unwrap_or("none"); + + let lines = vec![ + Line::from(Span::styled(service.name.as_str(), hero_style())), + Line::default(), + Line::from(vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.name.as_str()), + ]), + Line::from(vec![ + Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(workspace_name), + ]), + Line::from(vec![ + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(project.selected_environment_name.as_str()), + ]), + Line::default(), + Line::from(vec![ + Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(deployment_status), + ]), + Line::from(vec![ + Span::styled( + "deployment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(deployment_id), + ]), + 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::from(vec![ + Span::styled("source: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(source), + ]), + Line::from(vec![ + Span::styled("cron: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(service.cron_schedule.as_deref().unwrap_or("none")), + ]), + Line::from(vec![ + Span::styled("command: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(service.start_command.as_deref().unwrap_or("none")), + ]), + Line::default(), + Line::from(Span::styled("domains", panel_title_style())), + Line::from(domains), + Line::default(), + Line::from(Span::styled("volumes", panel_title_style())), + Line::from(volumes), + ]; + + frame.render_widget( + Paragraph::new(lines) + .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, + ); +} + +fn render_centered_message( + frame: &mut Frame<'_>, + area: Rect, + message: &str, + color: Color, + title: &str, +) { + frame.render_widget( + Paragraph::new(message) + .style(Style::default().fg(color)) + .block(panel_block(title)) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn project_card_width(width: u16, columns: usize) -> u16 { + let columns = columns.max(1) as u16; + let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); + width.saturating_sub(total_gaps) / columns +} + +fn project_rows_per_page(height: u16) -> usize { + let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; + ((height + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +fn service_card_width(width: u16, columns: usize) -> u16 { + let columns = columns.max(1) as u16; + let total_gaps = SERVICE_CARD_GAP.saturating_mul(columns.saturating_sub(1)); + width.saturating_sub(total_gaps) / columns +} + +fn service_rows_per_page(height: u16) -> usize { + let stride = SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP; + ((height + SERVICE_CARD_GAP) / stride).max(1) as usize +} + +fn pluralize(count: usize, singular: &str) -> String { + if count == 1 { + singular.to_string() + } else { + format!("{singular}s") + } +} + +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] +} From cc47d27de5d7be621cfd4a94d6443df987926eb5 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 21:58:20 +0100 Subject: [PATCH 05/21] dash-tui: fix restore reload --- src/controllers/dash_tui/mod.rs | 95 ++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index ed715f884..e7bb7a463 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -78,10 +78,18 @@ struct ProjectScreenState { loading: bool, error: Option, current_request_id: u64, - return_to_projects: Option>, + return_to_projects: Option, environment_selector: Option, } +#[derive(Clone, Debug)] +enum ProjectsBackNavigation { + Restore(Box), + Reload { + initial_selection_hint: Option, + }, +} + #[derive(Clone, Debug)] struct EnvironmentSelectorState { selected: usize, @@ -107,7 +115,9 @@ impl DashApp { project_id: project_id.clone(), environment_hint: params.environment.clone(), }, - Some(Box::new(ProjectsScreenState::new(Some(project_id.clone())))), + Some(ProjectsBackNavigation::Reload { + initial_selection_hint: Some(project_id.clone()), + }), )), None => DashboardScreen::Projects(ProjectsScreenState::new(None)), }, @@ -179,7 +189,7 @@ impl DashApp { fn open_project( &mut self, project_id: String, - return_to_projects: Option>, + return_to_projects: Option, tx: &mpsc::UnboundedSender, ) { let environment_hint = self.params.environment.clone(); @@ -242,20 +252,28 @@ impl DashApp { return; }; - let restored = state + let navigation = state .return_to_projects - .as_ref() - .map(|projects| (**projects).clone()) + .clone() .or_else(|| match self.params.auth_mode { - DashboardAuthMode::Workspace => Some(ProjectsScreenState::new(Some( - state.target.project_id.clone(), - ))), + DashboardAuthMode::Workspace => Some(ProjectsBackNavigation::Reload { + initial_selection_hint: Some(state.target.project_id.clone()), + }), DashboardAuthMode::LinkedProject { .. } => None, }); - if let Some(restored) = restored { - self.screen = DashboardScreen::Projects(restored); - self.refresh_projects(tx); + match navigation { + Some(ProjectsBackNavigation::Restore(restored)) => { + self.screen = DashboardScreen::Projects(*restored); + } + Some(ProjectsBackNavigation::Reload { + initial_selection_hint, + }) => { + self.screen = + DashboardScreen::Projects(ProjectsScreenState::new(initial_selection_hint)); + self.refresh_projects(tx); + } + None => {} } } @@ -342,7 +360,7 @@ impl DashApp { return self.handle_environment_selector_key(key, tx); } - let mut project_to_open: Option<(String, Box)> = None; + let mut project_to_open: Option<(String, ProjectsBackNavigation)> = None; let mut should_refresh_projects = false; let mut should_refresh_project = false; let mut should_back_to_projects = false; @@ -358,7 +376,10 @@ impl DashApp { KeyCode::Right | KeyCode::Char('l') => state.move_right(), KeyCode::Enter => { if let Some(card) = state.selected_card().cloned() { - project_to_open = Some((card.id, Box::new(state.clone()))); + project_to_open = Some(( + card.id, + ProjectsBackNavigation::Restore(Box::new(state.clone())), + )); } } KeyCode::Char('/') => state.filter_mode = true, @@ -537,10 +558,7 @@ impl ProjectsScreenState { } impl ProjectScreenState { - fn new( - target: ProjectLoadTarget, - return_to_projects: Option>, - ) -> Self { + fn new(target: ProjectLoadTarget, return_to_projects: Option) -> Self { Self { target, project: None, @@ -827,4 +845,45 @@ mod tests { 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()), + }, + Some(ProjectsBackNavigation::Restore(Box::new(restored.clone()))), + )), + spinner_tick: 0, + }; + + let (tx, _rx) = mpsc::unbounded_channel(); + app.back_to_projects(&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(_) => panic!("expected projects screen after backing out"), + } + } } From b939d711cc06e5f015b4e6e3ab1c2f8a70c8238c Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:02:18 +0100 Subject: [PATCH 06/21] dash-tui: better action handling --- src/controllers/dash_tui/mod.rs | 124 +++++++++++++++++++------------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index e7bb7a463..9203e47ba 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -106,6 +106,18 @@ enum LoaderEvent { }, } +enum HandleKeyAction { + None, + OpenProject { + project_id: String, + return_to_projects: ProjectsBackNavigation, + }, + RefreshProjects, + RefreshProject, + BackToProjects, + OpenEnvironmentSelector, +} + impl DashApp { fn new(params: DashTuiParams) -> Self { let screen = match ¶ms.auth_mode { @@ -360,58 +372,23 @@ impl DashApp { return self.handle_environment_selector_key(key, tx); } - let mut project_to_open: Option<(String, ProjectsBackNavigation)> = None; - let mut should_refresh_projects = false; - let mut should_refresh_project = false; - let mut should_back_to_projects = false; - let mut should_open_environment_selector = false; - - match &mut self.screen { + let action = match &mut self.screen { DashboardScreen::Projects(state) => { - let columns = ui::project_navigation_columns(terminal_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().cloned() { - project_to_open = Some(( - card.id, - ProjectsBackNavigation::Restore(Box::new(state.clone())), - )); - } - } - KeyCode::Char('/') => state.filter_mode = true, - KeyCode::Char('r') => should_refresh_projects = true, - _ => {} - } - } - DashboardScreen::Project(state) => { - let columns = ui::service_navigation_columns(terminal_area); - match key.code { - KeyCode::Esc | KeyCode::Backspace => should_back_to_projects = true, - 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::Char('e') => should_open_environment_selector = true, - KeyCode::Char('r') => should_refresh_project = true, - _ => {} - } + handle_projects_screen_key(state, key, terminal_area) } - } + DashboardScreen::Project(state) => handle_project_screen_key(state, key, terminal_area), + }; - if let Some((project_id, return_to_projects)) = project_to_open { - self.open_project(project_id, Some(return_to_projects), tx); - } else if should_refresh_projects { - self.refresh_projects(tx); - } else if should_back_to_projects { - self.back_to_projects(tx); - } else if should_open_environment_selector { - self.open_environment_selector(); - } else if should_refresh_project { - self.refresh_project(tx); + match action { + HandleKeyAction::None => {} + HandleKeyAction::OpenProject { + project_id, + return_to_projects, + } => self.open_project(project_id, Some(return_to_projects), tx), + HandleKeyAction::RefreshProjects => self.refresh_projects(tx), + HandleKeyAction::RefreshProject => self.refresh_project(tx), + HandleKeyAction::BackToProjects => self.back_to_projects(tx), + HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), } false @@ -721,6 +698,55 @@ pub async fn run(params: DashTuiParams) -> Result<()> { Ok(()) } +fn handle_projects_screen_key( + state: &mut ProjectsScreenState, + key: KeyEvent, + terminal_area: Rect, +) -> HandleKeyAction { + let columns = ui::project_navigation_columns(terminal_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().cloned() { + return HandleKeyAction::OpenProject { + project_id: card.id, + return_to_projects: ProjectsBackNavigation::Restore(Box::new(state.clone())), + }; + } + } + KeyCode::Char('/') => state.filter_mode = true, + KeyCode::Char('r') => return HandleKeyAction::RefreshProjects, + _ => {} + } + + HandleKeyAction::None +} + +fn handle_project_screen_key( + state: &mut ProjectScreenState, + key: KeyEvent, + terminal_area: Rect, +) -> HandleKeyAction { + let columns = ui::service_navigation_columns(terminal_area); + + match key.code { + KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::BackToProjects, + 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::Char('e') => return HandleKeyAction::OpenEnvironmentSelector, + KeyCode::Char('r') => return HandleKeyAction::RefreshProject, + _ => {} + } + + HandleKeyAction::None +} + fn handle_projects_filter_input(state: &mut ProjectsScreenState, key: KeyEvent) { match key.code { KeyCode::Esc => state.filter_mode = false, From ef701a5a9f6edd3cbaabef2481b6b9deecff53be Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:04:53 +0100 Subject: [PATCH 07/21] dash-tui: move spinner --- src/controllers/dash_tui/mod.rs | 4 +--- src/controllers/dash_tui/ui.rs | 7 ++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 9203e47ba..ffb003e69 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -27,8 +27,6 @@ use self::data::{ load_project_cards, }; -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - #[derive(Clone, Debug, PartialEq, Eq)] pub enum DashboardAuthMode { Workspace, @@ -433,7 +431,7 @@ impl DashApp { } fn on_tick(&mut self) { - self.spinner_tick = (self.spinner_tick + 1) % SPINNER_FRAMES.len(); + self.spinner_tick = (self.spinner_tick + 1) % ui::SPINNER_FRAMES.len(); } } diff --git a/src/controllers/dash_tui/ui.rs b/src/controllers/dash_tui/ui.rs index d2c3b82d8..f7cb22fe3 100644 --- a/src/controllers/dash_tui/ui.rs +++ b/src/controllers/dash_tui/ui.rs @@ -13,6 +13,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) const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const RAILWAY_VIOLET: Color = Color::Rgb(127, 86, 217); const RAILWAY_PURPLE: Color = Color::Rgb(155, 107, 255); @@ -225,7 +226,7 @@ fn render_projects_status( .map(|card| card.name.as_str()) .unwrap_or("none"); let visible_count = state.visible_indices().len(); - let spinner = super::SPINNER_FRAMES[app.spinner_tick % super::SPINNER_FRAMES.len()]; + let spinner = SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()]; let filter_line = if state.filter_mode { Line::from(vec![ @@ -422,7 +423,7 @@ fn render_project_screen( main_area, &format!( "{} Loading project overview...", - super::SPINNER_FRAMES[app.spinner_tick % super::SPINNER_FRAMES.len()] + SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()] ), RAILWAY_VIOLET, "project overview", @@ -459,7 +460,7 @@ fn render_project_status( app: &DashApp, state: &ProjectScreenState, ) { - let spinner = super::SPINNER_FRAMES[app.spinner_tick % super::SPINNER_FRAMES.len()]; + let spinner = SPINNER_FRAMES[app.spinner_tick % SPINNER_FRAMES.len()]; let title_line = if let Some(project) = &state.project { Line::from(vec![ From aaefd436e014e5fa3e1d18c5e49ab3c3dd001817 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:16:21 +0100 Subject: [PATCH 08/21] dash-tui: refactor our project code --- src/controllers/dash_tui/mod.rs | 152 ++------------------------- src/controllers/dash_tui/project.rs | 155 ++++++++++++++++++++++++++++ src/controllers/dash_tui/ui.rs | 3 +- 3 files changed, 163 insertions(+), 147 deletions(-) create mode 100644 src/controllers/dash_tui/project.rs diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index ffb003e69..93cc76b5c 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -1,4 +1,5 @@ mod data; +mod project; mod ui; use std::io::stdout; @@ -23,8 +24,10 @@ use tokio::sync::mpsc; use tokio::time::MissedTickBehavior; use self::data::{ - DashboardProject, DashboardService, ProjectCard, ProjectLoadTarget, load_dashboard_project, - load_project_cards, + DashboardProject, ProjectCard, ProjectLoadTarget, load_dashboard_project, load_project_cards, +}; +use self::project::{ + EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -68,31 +71,6 @@ struct ProjectsScreenState { initial_selection_hint: Option, } -#[derive(Clone, Debug)] -struct ProjectScreenState { - target: ProjectLoadTarget, - project: Option, - selected_service: usize, - loading: bool, - error: Option, - current_request_id: u64, - return_to_projects: Option, - environment_selector: Option, -} - -#[derive(Clone, Debug)] -enum ProjectsBackNavigation { - Restore(Box), - Reload { - initial_selection_hint: Option, - }, -} - -#[derive(Clone, Debug)] -struct EnvironmentSelectorState { - selected: usize, -} - enum LoaderEvent { ProjectsLoaded { request_id: u64, @@ -532,104 +510,6 @@ impl ProjectsScreenState { } } -impl ProjectScreenState { - fn new(target: ProjectLoadTarget, return_to_projects: Option) -> Self { - Self { - target, - project: None, - selected_service: 0, - loading: false, - error: None, - current_request_id: 0, - return_to_projects, - environment_selector: None, - } - } - - fn selected_service(&self) -> Option<&DashboardService> { - self.project - .as_ref() - .and_then(|project| project.services.get(self.selected_service)) - } - - 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); - } - } - - fn set_error(&mut self, error: String) { - self.loading = false; - self.error = Some(error); - self.environment_selector = None; - self.clamp_selection(); - } - - fn move_left(&mut self) { - self.selected_service = self.selected_service.saturating_sub(1); - } - - 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); - } - } - - fn move_up(&mut self, columns: usize) { - self.selected_service = self.selected_service.saturating_sub(columns.max(1)); - } - - 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); - } - } - - 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(); - } - } - } - - 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); - } - } -} - fn setup_terminal() -> Result>> { enable_raw_mode()?; @@ -724,27 +604,6 @@ fn handle_projects_screen_key( HandleKeyAction::None } -fn handle_project_screen_key( - state: &mut ProjectScreenState, - key: KeyEvent, - terminal_area: Rect, -) -> HandleKeyAction { - let columns = ui::service_navigation_columns(terminal_area); - - match key.code { - KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::BackToProjects, - 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::Char('e') => return HandleKeyAction::OpenEnvironmentSelector, - KeyCode::Char('r') => return HandleKeyAction::RefreshProject, - _ => {} - } - - HandleKeyAction::None -} - fn handle_projects_filter_input(state: &mut ProjectsScreenState, key: KeyEvent) { match key.code { KeyCode::Esc => state.filter_mode = false, @@ -772,6 +631,7 @@ fn handle_projects_filter_input(state: &mut ProjectsScreenState, key: KeyEvent) #[cfg(test)] mod tests { use super::*; + use crate::controllers::dash_tui::data::DashboardService; fn card(id: &str, name: &str) -> ProjectCard { ProjectCard { diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs new file mode 100644 index 000000000..fd6b8a204 --- /dev/null +++ b/src/controllers/dash_tui/project.rs @@ -0,0 +1,155 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::Rect; + +use super::{HandleKeyAction, ProjectsScreenState, 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) return_to_projects: Option, + pub(in crate::controllers::dash_tui) environment_selector: Option, +} + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) enum ProjectsBackNavigation { + Restore(Box), + Reload { + initial_selection_hint: 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, + return_to_projects: Option, + ) -> Self { + Self { + target, + project: None, + selected_service: 0, + loading: false, + error: None, + current_request_id: 0, + return_to_projects, + 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 columns = ui::service_navigation_columns(terminal_area); + + match key.code { + KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::BackToProjects, + 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::Char('e') => return HandleKeyAction::OpenEnvironmentSelector, + KeyCode::Char('r') => return HandleKeyAction::RefreshProject, + _ => {} + } + + HandleKeyAction::None +} diff --git a/src/controllers/dash_tui/ui.rs b/src/controllers/dash_tui/ui.rs index f7cb22fe3..3d98f2d17 100644 --- a/src/controllers/dash_tui/ui.rs +++ b/src/controllers/dash_tui/ui.rs @@ -4,7 +4,8 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; -use super::{DashApp, DashboardAuthMode, DashboardScreen, ProjectScreenState, ProjectsScreenState}; +use super::project::ProjectScreenState; +use super::{DashApp, DashboardAuthMode, DashboardScreen, ProjectsScreenState}; use crate::controllers::dash_tui::data::{DashboardService, ProjectCard}; pub(super) const PROJECT_CARD_MIN_WIDTH: u16 = 30; From 8dba175fcf7dfe32f404bbb0dbb412a1d9999a92 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:30:49 +0100 Subject: [PATCH 09/21] dash-tui: show service info screen --- src/controllers/dash_tui/data.rs | 2 + src/controllers/dash_tui/mod.rs | 84 ++++++++++- src/controllers/dash_tui/project.rs | 1 + src/controllers/dash_tui/service.rs | 57 ++++++++ src/controllers/dash_tui/ui.rs | 211 +++++++++++++++++++++++++++- 5 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 src/controllers/dash_tui/service.rs diff --git a/src/controllers/dash_tui/data.rs b/src/controllers/dash_tui/data.rs index 6837d1395..907f419cd 100644 --- a/src/controllers/dash_tui/data.rs +++ b/src/controllers/dash_tui/data.rs @@ -63,6 +63,7 @@ pub struct DashboardService { 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, } @@ -258,6 +259,7 @@ fn map_dashboard_project( .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, } diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 93cc76b5c..d1e882d51 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -1,5 +1,6 @@ mod data; mod project; +mod service; mod ui; use std::io::stdout; @@ -29,6 +30,7 @@ use self::data::{ use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; +use self::service::{ServiceScreenState, handle_service_screen_key}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum DashboardAuthMode { @@ -57,6 +59,7 @@ struct DashApp { enum DashboardScreen { Projects(ProjectsScreenState), Project(ProjectScreenState), + Service(ServiceScreenState), } #[derive(Clone, Debug)] @@ -88,9 +91,11 @@ enum HandleKeyAction { project_id: String, return_to_projects: ProjectsBackNavigation, }, + OpenSelectedService, RefreshProjects, RefreshProject, BackToProjects, + BackToProject, OpenEnvironmentSelector, } @@ -132,6 +137,7 @@ impl DashApp { match self.screen { DashboardScreen::Projects(_) => self.refresh_projects(tx), DashboardScreen::Project(_) => self.refresh_project(tx), + DashboardScreen::Service(_) => {} } } @@ -191,6 +197,18 @@ impl DashApp { self.refresh_project(tx); } + fn open_selected_service(&mut self) { + let DashboardScreen::Project(state) = &self.screen else { + return; + }; + + let Some(service_screen) = ServiceScreenState::from_project(state) else { + return; + }; + + self.screen = DashboardScreen::Service(service_screen); + } + fn open_environment_selector(&mut self) { let DashboardScreen::Project(state) = &mut self.screen else { return; @@ -235,6 +253,14 @@ impl DashApp { self.refresh_project(tx); } + fn back_to_project(&mut self) { + let DashboardScreen::Service(state) = &self.screen else { + return; + }; + + self.screen = DashboardScreen::Project((*state.return_to_project).clone()); + } + fn back_to_projects(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Project(state) = &self.screen else { return; @@ -353,6 +379,7 @@ impl DashApp { handle_projects_screen_key(state, key, terminal_area) } DashboardScreen::Project(state) => handle_project_screen_key(state, key, terminal_area), + DashboardScreen::Service(_) => handle_service_screen_key(key), }; match action { @@ -361,9 +388,11 @@ impl DashApp { project_id, return_to_projects, } => self.open_project(project_id, Some(return_to_projects), tx), + HandleKeyAction::OpenSelectedService => self.open_selected_service(), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), HandleKeyAction::BackToProjects => self.back_to_projects(tx), + HandleKeyAction::BackToProject => self.back_to_project(), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), } @@ -654,6 +683,7 @@ mod tests { source_repo: None, source_image: None, cron_schedule: None, + next_cron_run_at: None, start_command: None, volume_mounts: Vec::new(), } @@ -767,7 +797,59 @@ mod tests { assert_eq!(state.current_request_id, 7); assert!(!state.loading); } - DashboardScreen::Project(_) => panic!("expected projects screen after backing out"), + DashboardScreen::Project(_) | DashboardScreen::Service(_) => { + 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()), + }, + None, + ); + 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()), + spinner_tick: 0, + }; + + app.open_selected_service(); + + 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(_) => { + panic!("expected service screen after opening selected service") + } + } + + app.back_to_project(); + + match app.screen { + DashboardScreen::Project(state) => { + assert_eq!( + state.selected_service().map(|service| service.id.as_str()), + Some("svc_2") + ); + } + DashboardScreen::Projects(_) | DashboardScreen::Service(_) => { + panic!("expected project screen after backing out of service detail") + } } } } diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs index fd6b8a204..267747e1e 100644 --- a/src/controllers/dash_tui/project.rs +++ b/src/controllers/dash_tui/project.rs @@ -146,6 +146,7 @@ pub(in crate::controllers::dash_tui) fn handle_project_screen_key( 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('e') => return HandleKeyAction::OpenEnvironmentSelector, KeyCode::Char('r') => return HandleKeyAction::RefreshProject, _ => {} diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs new file mode 100644 index 000000000..8ca819815 --- /dev/null +++ b/src/controllers/dash_tui/service.rs @@ -0,0 +1,57 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use super::{DashboardProject, HandleKeyAction}; +use crate::controllers::dash_tui::{data::DashboardService, project::ProjectScreenState}; + +#[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) return_to_project: Box, +} + +#[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, +} + +impl ServiceScreenState { + pub(in crate::controllers::dash_tui) fn from_project( + state: &ProjectScreenState, + ) -> Option { + let project = state.project.as_ref()?; + let service = state.selected_service()?.clone(); + + Some(Self { + detail: service_detail_from_project(project, service), + return_to_project: Box::new(state.clone()), + }) + } +} + +pub(in crate::controllers::dash_tui) fn handle_service_screen_key( + key: KeyEvent, +) -> HandleKeyAction { + match key.code { + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackToProject, + _ => 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, + } +} diff --git a/src/controllers/dash_tui/ui.rs b/src/controllers/dash_tui/ui.rs index 3d98f2d17..5bbf6cf27 100644 --- a/src/controllers/dash_tui/ui.rs +++ b/src/controllers/dash_tui/ui.rs @@ -1,3 +1,4 @@ +use chrono_humanize::HumanTime; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -5,6 +6,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use super::project::ProjectScreenState; +use super::service::ServiceScreenState; use super::{DashApp, DashboardAuthMode, DashboardScreen, ProjectsScreenState}; use crate::controllers::dash_tui::data::{DashboardService, ProjectCard}; @@ -83,6 +85,7 @@ pub(super) fn render(frame: &mut Frame<'_>, app: &DashApp) { 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), } render_footer(frame, footer, app); @@ -160,6 +163,7 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { "project overview • environment selector" } DashboardScreen::Project(_) => "project overview", + DashboardScreen::Service(_) => "service detail", }; frame.render_widget( @@ -185,12 +189,15 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } DashboardScreen::Project(_) => match app.params.auth_mode { DashboardAuthMode::Workspace => { - "Esc back • e environments • arrows/ijkl move • r reload • q quit" + "Enter service • Esc back • e environments • arrows/ijkl move • r reload • q quit" } DashboardAuthMode::LinkedProject { .. } => { - "e environments • arrows/ijkl move • r reload • q quit" + "Enter service • e environments • arrows/ijkl move • r reload • q quit" } }, + DashboardScreen::Service(_) => { + "Esc back • q quit • deployments/metrics/logs/files/redeploy coming next" + } }; frame.render_widget( @@ -766,6 +773,206 @@ fn render_project_summary(frame: &mut Frame<'_>, area: Rect, state: &ProjectScre ); } +fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let [status_area, main_area] = project_sections(area); + let [details_area, resources_area] = project_overview_sections(main_area); + + render_service_status(frame, status_area, state); + render_service_overview(frame, details_area, state); + render_service_resources(frame, resources_area, state); +} + +fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let workspace_name = state.detail.workspace_name.as_deref().unwrap_or("personal"); + + 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::default(), + 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), + ]), + ]) + .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("up next", panel_title_style())), + Line::from(Span::styled("d deployments", muted_style())), + Line::from(Span::styled("m metrics", muted_style())), + Line::from(Span::styled("l logs", muted_style())), + Line::from(Span::styled("v/f volume browser", muted_style())), + Line::from(Span::styled("R redeploy latest", muted_style())), + ]) + .block(panel_block("resources")) + .wrap(Wrap { trim: true }), + area, + ); +} + fn render_environment_selector(frame: &mut Frame<'_>, area: Rect, state: &ProjectScreenState) { frame.render_widget(Clear, area); From 062f5abdb0d4e6ffc9f055bc93ec9c727b53ace5 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:41:10 +0100 Subject: [PATCH 10/21] dash-tui: split out the tui code --- src/controllers/dash_tui/mod.rs | 12 +- src/controllers/dash_tui/ui.rs | 1086 ----------------------- src/controllers/dash_tui/ui/mod.rs | 269 ++++++ src/controllers/dash_tui/ui/project.rs | 426 +++++++++ src/controllers/dash_tui/ui/projects.rs | 218 +++++ src/controllers/dash_tui/ui/service.rs | 212 +++++ 6 files changed, 1136 insertions(+), 1087 deletions(-) delete mode 100644 src/controllers/dash_tui/ui.rs create mode 100644 src/controllers/dash_tui/ui/mod.rs create mode 100644 src/controllers/dash_tui/ui/project.rs create mode 100644 src/controllers/dash_tui/ui/projects.rs create mode 100644 src/controllers/dash_tui/ui/service.rs diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index d1e882d51..5aad0b612 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -21,6 +21,7 @@ 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; @@ -32,6 +33,15 @@ use self::project::{ }; use self::service::{ServiceScreenState, handle_service_screen_key}; +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, @@ -438,7 +448,7 @@ impl DashApp { } fn on_tick(&mut self) { - self.spinner_tick = (self.spinner_tick + 1) % ui::SPINNER_FRAMES.len(); + self.spinner_tick = (self.spinner_tick + 1) % SPINNER_FRAMES.len(); } } diff --git a/src/controllers/dash_tui/ui.rs b/src/controllers/dash_tui/ui.rs deleted file mode 100644 index 5bbf6cf27..000000000 --- a/src/controllers/dash_tui/ui.rs +++ /dev/null @@ -1,1086 +0,0 @@ -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::{Block, Borders, Clear, Paragraph, Wrap}; - -use super::project::ProjectScreenState; -use super::service::ServiceScreenState; -use super::{DashApp, DashboardAuthMode, DashboardScreen, ProjectsScreenState}; -use crate::controllers::dash_tui::data::{DashboardService, ProjectCard}; - -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) 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); - -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())) -} - -fn panel_border_style() -> Style { - Style::default().fg(RAILWAY_PANEL) -} - -fn panel_title_style() -> Style { - Style::default() - .fg(RAILWAY_PURPLE) - .add_modifier(Modifier::BOLD) -} - -fn hero_style() -> Style { - Style::default() - .fg(RAILWAY_LAVENDER) - .add_modifier(Modifier::BOLD) -} - -fn selected_border_style() -> Style { - Style::default().fg(RAILWAY_PINK) -} - -fn selected_title_style() -> Style { - Style::default() - .fg(RAILWAY_PINK) - .add_modifier(Modifier::BOLD) -} - -fn loading_style() -> Style { - Style::default().fg(RAILWAY_VIOLET) -} - -fn accent_style() -> Style { - Style::default().fg(RAILWAY_PINK) -} - -fn muted_style() -> Style { - Style::default().fg(RAILWAY_MUTED) -} - -fn error_style() -> Style { - Style::default() - .fg(RAILWAY_ERROR) - .add_modifier(Modifier::BOLD) -} - -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), - } - - render_footer(frame, footer, app); -} - -pub(super) fn project_navigation_columns(frame_area: Rect) -> usize { - project_grid_columns_for_area(projects_grid_area(frame_area)) -} - -pub(super) fn service_navigation_columns(frame_area: Rect) -> usize { - service_grid_columns_for_area(project_services_area(frame_area)) -} - -pub(super) fn project_grid_columns(width: u16) -> usize { - let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; - ((width + PROJECT_CARD_GAP) / stride).max(1) as usize -} - -pub(super) fn service_grid_columns(width: u16) -> usize { - let stride = SERVICE_CARD_MIN_WIDTH + SERVICE_CARD_GAP; - ((width + SERVICE_CARD_GAP) / stride).max(1) as usize -} - -fn project_grid_columns_for_area(area: Rect) -> usize { - project_grid_columns(panel_inner_area(area).width).max(1) -} - -fn service_grid_columns_for_area(area: Rect) -> usize { - service_grid_columns(panel_inner_area(area).width).max(1) -} - -fn dashboard_sections(area: Rect) -> [Rect; 3] { - Layout::vertical([ - Constraint::Length(3), - Constraint::Min(12), - Constraint::Length(3), - ]) - .areas(area) -} - -fn projects_sections(area: Rect) -> [Rect; 2] { - Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) -} - -fn project_sections(area: Rect) -> [Rect; 2] { - Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) -} - -fn project_overview_sections(area: Rect) -> [Rect; 2] { - Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]).areas(area) -} - -fn projects_grid_area(frame_area: Rect) -> Rect { - let [_, body, _] = dashboard_sections(frame_area); - let [_, grid_area] = projects_sections(body); - grid_area -} - -fn project_services_area(frame_area: Rect) -> Rect { - let [_, body, _] = dashboard_sections(frame_area); - let [_, main_area] = project_sections(body); - let [diagram_area, _] = project_overview_sections(main_area); - diagram_area -} - -fn panel_inner_area(area: Rect) -> Rect { - panel_block("").inner(area) -} - -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", - }; - - 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 • Esc back • e environments • arrows/ijkl move • r reload • q quit" - } - DashboardAuthMode::LinkedProject { .. } => { - "Enter service • e environments • arrows/ijkl move • r reload • q quit" - } - }, - DashboardScreen::Service(_) => { - "Esc back • q quit • deployments/metrics/logs/files/redeploy coming next" - } - }; - - frame.render_widget( - Paragraph::new(text) - .style(muted_style()) - .block(panel_block("controls")) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_projects_screen( - frame: &mut Frame<'_>, - area: Rect, - app: &DashApp, - state: &ProjectsScreenState, -) { - frame.render_widget(Clear, area); - - let [status_area, grid_area] = projects_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 = project_grid_columns_for_area(area); - let card_width = project_card_width(inner.width, columns); - let rows_per_page = project_rows_per_page(inner.height).max(1); - 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, - ); -} - -fn render_project_screen( - frame: &mut Frame<'_>, - area: Rect, - app: &DashApp, - state: &ProjectScreenState, -) { - frame.render_widget(Clear, area); - - let [status_area, main_area] = project_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 = service_grid_columns_for_area(area); - let card_width = service_card_width(inner.width, columns); - let rows_per_page = service_rows_per_page(inner.height).max(1); - 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) { - let Some(project) = &state.project else { - 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 workspace_name = project.workspace_name.as_deref().unwrap_or("personal"); - let deployment_status = service - .latest_deployment - .as_ref() - .map(|deployment| deployment.status.as_str()) - .unwrap_or("no deployment"); - let deployment_id = service - .latest_deployment - .as_ref() - .map(|deployment| deployment.id.as_str()) - .unwrap_or("-"); - 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") - }; - let source = service - .source_repo - .as_deref() - .or(service.source_image.as_deref()) - .unwrap_or("none"); - - let lines = vec![ - Line::from(Span::styled(service.name.as_str(), hero_style())), - Line::default(), - Line::from(vec![ - Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(project.name.as_str()), - ]), - Line::from(vec![ - Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(workspace_name), - ]), - Line::from(vec![ - Span::styled( - "environment: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(project.selected_environment_name.as_str()), - ]), - Line::default(), - Line::from(vec![ - Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(deployment_status), - ]), - Line::from(vec![ - Span::styled( - "deployment: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(deployment_id), - ]), - 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::from(vec![ - Span::styled("source: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(source), - ]), - Line::from(vec![ - Span::styled("cron: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(service.cron_schedule.as_deref().unwrap_or("none")), - ]), - Line::from(vec![ - Span::styled("command: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(service.start_command.as_deref().unwrap_or("none")), - ]), - Line::default(), - Line::from(Span::styled("domains", panel_title_style())), - Line::from(domains), - Line::default(), - Line::from(Span::styled("volumes", panel_title_style())), - Line::from(volumes), - ]; - - frame.render_widget( - Paragraph::new(lines) - .block(panel_block("selected service").border_style(selected_border_style())) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { - frame.render_widget(Clear, area); - - let [status_area, main_area] = project_sections(area); - let [details_area, resources_area] = project_overview_sections(main_area); - - render_service_status(frame, status_area, state); - render_service_overview(frame, details_area, state); - render_service_resources(frame, resources_area, state); -} - -fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { - let workspace_name = state.detail.workspace_name.as_deref().unwrap_or("personal"); - - 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::default(), - 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), - ]), - ]) - .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("up next", panel_title_style())), - Line::from(Span::styled("d deployments", muted_style())), - Line::from(Span::styled("m metrics", muted_style())), - Line::from(Span::styled("l logs", muted_style())), - Line::from(Span::styled("v/f volume browser", muted_style())), - Line::from(Span::styled("R redeploy latest", muted_style())), - ]) - .block(panel_block("resources")) - .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, - ); -} - -fn render_centered_message( - frame: &mut Frame<'_>, - area: Rect, - message: &str, - color: Color, - title: &str, -) { - frame.render_widget( - Paragraph::new(message) - .style(Style::default().fg(color)) - .block(panel_block(title)) - .wrap(Wrap { trim: true }), - area, - ); -} - -fn project_card_width(width: u16, columns: usize) -> u16 { - let columns = columns.max(1) as u16; - let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); - width.saturating_sub(total_gaps) / columns -} - -fn project_rows_per_page(height: u16) -> usize { - let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; - ((height + PROJECT_CARD_GAP) / stride).max(1) as usize -} - -fn service_card_width(width: u16, columns: usize) -> u16 { - let columns = columns.max(1) as u16; - let total_gaps = SERVICE_CARD_GAP.saturating_mul(columns.saturating_sub(1)); - width.saturating_sub(total_gaps) / columns -} - -fn service_rows_per_page(height: u16) -> usize { - let stride = SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP; - ((height + SERVICE_CARD_GAP) / stride).max(1) as usize -} - -fn pluralize(count: usize, singular: &str) -> String { - if count == 1 { - singular.to_string() - } else { - format!("{singular}s") - } -} - -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/mod.rs b/src/controllers/dash_tui/ui/mod.rs new file mode 100644 index 000000000..4f7c56211 --- /dev/null +++ b/src/controllers/dash_tui/ui/mod.rs @@ -0,0 +1,269 @@ +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::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), + } + + render_footer(frame, footer, app); +} + +pub(super) fn project_navigation_columns(frame_area: Rect) -> usize { + project_grid_columns_for_area(projects_grid_area(frame_area)) +} + +pub(super) fn service_navigation_columns(frame_area: Rect) -> usize { + service_grid_columns_for_area(project_services_area(frame_area)) +} + +pub(super) fn project_grid_columns(width: u16) -> usize { + let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; + ((width + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +pub(super) fn service_grid_columns(width: u16) -> usize { + let stride = SERVICE_CARD_MIN_WIDTH + SERVICE_CARD_GAP; + ((width + SERVICE_CARD_GAP) / stride).max(1) as usize +} + +fn project_grid_columns_for_area(area: Rect) -> usize { + project_grid_columns(panel_inner_area(area).width).max(1) +} + +fn service_grid_columns_for_area(area: Rect) -> usize { + service_grid_columns(panel_inner_area(area).width).max(1) +} + +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", + }; + + 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 • Esc back • e environments • arrows/ijkl move • r reload • q quit" + } + DashboardAuthMode::LinkedProject { .. } => { + "Enter service • e environments • arrows/ijkl move • r reload • q quit" + } + }, + DashboardScreen::Service(_) => { + "Esc back • q quit • deployments/metrics/logs/files/redeploy coming next" + } + }; + + 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 projects_sections(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) +} + +pub(super) fn project_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) +} + +fn projects_grid_area(frame_area: Rect) -> Rect { + let [_, body, _] = dashboard_sections(frame_area); + let [_, grid_area] = projects_sections(body); + grid_area +} + +fn project_services_area(frame_area: Rect) -> Rect { + let [_, body, _] = dashboard_sections(frame_area); + let [_, main_area] = project_sections(body); + let [diagram_area, _] = project_overview_sections(main_area); + diagram_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 panel_inner_area(area: Rect) -> Rect { + panel_block("").inner(area) +} + +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 project_card_width(width: u16, columns: usize) -> u16 { + let columns = columns.max(1) as u16; + let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); + width.saturating_sub(total_gaps) / columns +} + +pub(super) fn project_rows_per_page(height: u16) -> usize { + let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; + ((height + PROJECT_CARD_GAP) / stride).max(1) as usize +} + +pub(super) fn service_card_width(width: u16, columns: usize) -> u16 { + let columns = columns.max(1) as u16; + let total_gaps = SERVICE_CARD_GAP.saturating_mul(columns.saturating_sub(1)); + width.saturating_sub(total_gaps) / columns +} + +pub(super) fn service_rows_per_page(height: u16) -> usize { + let stride = SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP; + ((height + SERVICE_CARD_GAP) / stride).max(1) as usize +} + +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..b4e78edc5 --- /dev/null +++ b/src/controllers/dash_tui/ui/project.rs @@ -0,0 +1,426 @@ +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, + panel_title_style, project_overview_sections, project_sections, render_centered_message, + selected_border_style, selected_title_style, service_card_width, service_grid_columns, + service_rows_per_page, +}; +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] = project_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 = service_grid_columns(super::panel_inner_area(area).width).max(1); + let card_width = service_card_width(inner.width, columns); + let rows_per_page = service_rows_per_page(inner.height).max(1); + 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) { + let Some(project) = &state.project else { + 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 workspace_name = project.workspace_name.as_deref().unwrap_or("personal"); + let deployment_status = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.status.as_str()) + .unwrap_or("no deployment"); + let deployment_id = service + .latest_deployment + .as_ref() + .map(|deployment| deployment.id.as_str()) + .unwrap_or("-"); + 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") + }; + let source = service + .source_repo + .as_deref() + .or(service.source_image.as_deref()) + .unwrap_or("none"); + + let lines = vec![ + Line::from(Span::styled(service.name.as_str(), hero_style())), + Line::default(), + Line::from(vec![ + Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.name.as_str()), + ]), + Line::from(vec![ + Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(workspace_name), + ]), + Line::from(vec![ + Span::styled( + "environment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(project.selected_environment_name.as_str()), + ]), + Line::default(), + Line::from(vec![ + Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(deployment_status), + ]), + Line::from(vec![ + Span::styled( + "deployment: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(deployment_id), + ]), + 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::from(vec![ + Span::styled("source: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(source), + ]), + Line::from(vec![ + Span::styled("cron: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(service.cron_schedule.as_deref().unwrap_or("none")), + ]), + Line::from(vec![ + Span::styled("command: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(service.start_command.as_deref().unwrap_or("none")), + ]), + Line::default(), + Line::from(Span::styled("domains", panel_title_style())), + Line::from(domains), + Line::default(), + Line::from(Span::styled("volumes", panel_title_style())), + Line::from(volumes), + ]; + + frame.render_widget( + Paragraph::new(lines) + .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..2b78f51ba --- /dev/null +++ b/src/controllers/dash_tui/ui/projects.rs @@ -0,0 +1,218 @@ +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_card_width, project_grid_columns, project_rows_per_page, projects_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] = projects_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 = project_grid_columns(super::panel_inner_area(area).width).max(1); + let card_width = project_card_width(inner.width, columns); + let rows_per_page = project_rows_per_page(inner.height).max(1); + 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..c22e4cf35 --- /dev/null +++ b/src/controllers/dash_tui/ui/service.rs @@ -0,0 +1,212 @@ +use chrono_humanize::HumanTime; +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, muted_style, panel_block, panel_title_style, project_overview_sections, + project_sections, selected_border_style, +}; +use crate::controllers::dash_tui::service::ServiceScreenState; + +pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let [status_area, main_area] = project_sections(area); + let [details_area, resources_area] = project_overview_sections(main_area); + + render_service_status(frame, status_area, state); + render_service_overview(frame, details_area, state); + render_service_resources(frame, resources_area, state); +} + +fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let workspace_name = state.detail.workspace_name.as_deref().unwrap_or("personal"); + + 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::default(), + 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), + ]), + ]) + .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("up next", panel_title_style())), + Line::from(Span::styled("d deployments", muted_style())), + Line::from(Span::styled("m metrics", muted_style())), + Line::from(Span::styled("l logs", muted_style())), + Line::from(Span::styled("v/f volume browser", muted_style())), + Line::from(Span::styled("R redeploy latest", muted_style())), + ]) + .block(panel_block("resources")) + .wrap(Wrap { trim: true }), + area, + ); +} From 9a3f7609877bdca6d1fd89bb5d4ac430b8a5531e Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Fri, 22 May 2026 22:58:59 +0100 Subject: [PATCH 11/21] dash-tui: show deployments and redeploy --- src/commands/deployment.rs | 37 ++-- src/commands/redeploy.rs | 37 ++-- src/controllers/dash_tui/data.rs | 25 ++- src/controllers/dash_tui/mod.rs | 106 +++++++++- src/controllers/dash_tui/service.rs | 269 ++++++++++++++++++++++++- src/controllers/dash_tui/ui/service.rs | 226 ++++++++++++++++++++- src/controllers/deployment.rs | 86 ++++++++ 7 files changed, 712 insertions(+), 74 deletions(-) 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/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/controllers/dash_tui/data.rs b/src/controllers/dash_tui/data.rs index 907f419cd..83f37731a 100644 --- a/src/controllers/dash_tui/data.rs +++ b/src/controllers/dash_tui/data.rs @@ -1,5 +1,6 @@ use anyhow::{Result, anyhow, bail}; use chrono::{DateTime, Utc}; +use reqwest::Client; use crate::{ client::GQLClient, @@ -125,23 +126,41 @@ pub async fn load_project_cards() -> Result> { pub async fn load_dashboard_project(target: ProjectLoadTarget) -> Result { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; - let project = get_project(&client, &configs, target.project_id.clone()).await?; + 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?; + 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?; + get_environment_instances(client, configs, &project.id, &environment.id).await?; Ok(map_dashboard_project(project, environment, instances)) } +pub fn find_dashboard_service<'a>( + project: &'a DashboardProject, + service_id: &str, +) -> Option<&'a DashboardService> { + project + .services + .iter() + .find(|service| service.id == service_id) +} + fn project_card_from_project(project: Project, workspace_name: String) -> ProjectCard { match project { Project::External(project) => ProjectCard { diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 5aad0b612..471fd99a8 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -31,7 +31,10 @@ use self::data::{ use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; -use self::service::{ServiceScreenState, handle_service_screen_key}; +use self::service::{ + ServiceScreenLoadedData, ServiceScreenState, handle_service_screen_key, + load_service_screen_data, redeploy_service, +}; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const RAILWAY_VIOLET: Color = Color::Rgb(127, 86, 217); @@ -93,6 +96,13 @@ enum LoaderEvent { request_id: u64, result: std::result::Result, }, + ServiceLoaded { + request_id: u64, + result: std::result::Result, + }, + ServiceRedeployed { + result: std::result::Result, + }, } enum HandleKeyAction { @@ -104,6 +114,8 @@ enum HandleKeyAction { OpenSelectedService, RefreshProjects, RefreshProject, + RefreshService, + RedeployService, BackToProjects, BackToProject, OpenEnvironmentSelector, @@ -147,7 +159,7 @@ impl DashApp { match self.screen { DashboardScreen::Projects(_) => self.refresh_projects(tx), DashboardScreen::Project(_) => self.refresh_project(tx), - DashboardScreen::Service(_) => {} + DashboardScreen::Service(_) => self.refresh_service(tx), } } @@ -207,7 +219,7 @@ impl DashApp { self.refresh_project(tx); } - fn open_selected_service(&mut self) { + fn open_selected_service(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Project(state) = &self.screen else { return; }; @@ -217,6 +229,46 @@ impl DashApp { }; self.screen = DashboardScreen::Service(service_screen); + self.refresh_service(tx); + } + + fn refresh_service(&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 target = state.refresh_target(); + let service_id = state.service_id(); + let return_to_projects = state.return_to_projects_nav(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = load_service_screen_data(target, service_id, return_to_projects) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceLoaded { request_id, result }); + }); + } + + fn redeploy_service(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.loading = true; + state.error = None; + state.clear_toast(); + let detail = state.detail.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = redeploy_service(&detail) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceRedeployed { result }); + }); } fn open_environment_selector(&mut self) { @@ -301,7 +353,7 @@ impl DashApp { } } - fn handle_loader_event(&mut self, event: LoaderEvent) { + 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 { @@ -331,6 +383,39 @@ impl DashApp { Err(error) => state.set_error(error), } } + LoaderEvent::ServiceLoaded { request_id, result } => { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + if request_id != state.current_request_id { + return; + } + + match result { + Ok(data) => state.apply_loaded_data(data), + Err(error) => state.set_error(error), + } + } + LoaderEvent::ServiceRedeployed { result } => { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.loading = false; + match result { + Ok(deployment_id) => { + state.set_toast( + format!("Redeploy triggered for deployment {deployment_id}"), + false, + ); + self.refresh_service(tx); + } + Err(error) => { + state.set_toast(error, true); + } + } + } } } @@ -389,7 +474,7 @@ impl DashApp { handle_projects_screen_key(state, key, terminal_area) } DashboardScreen::Project(state) => handle_project_screen_key(state, key, terminal_area), - DashboardScreen::Service(_) => handle_service_screen_key(key), + DashboardScreen::Service(state) => handle_service_screen_key(state, key), }; match action { @@ -398,9 +483,11 @@ impl DashApp { project_id, return_to_projects, } => self.open_project(project_id, Some(return_to_projects), tx), - HandleKeyAction::OpenSelectedService => self.open_selected_service(), + HandleKeyAction::OpenSelectedService => self.open_selected_service(tx), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), + HandleKeyAction::RefreshService => self.refresh_service(tx), + HandleKeyAction::RedeployService => self.redeploy_service(tx), HandleKeyAction::BackToProjects => self.back_to_projects(tx), HandleKeyAction::BackToProject => self.back_to_project(), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), @@ -601,7 +688,7 @@ pub async fn run(params: DashTuiParams) -> Result<()> { } } Some(loader_event) = loader_rx.recv() => { - app.handle_loader_event(loader_event); + app.handle_loader_event(loader_event, &loader_tx); } _ = tick.tick() => { app.on_tick(); @@ -835,7 +922,10 @@ mod tests { spinner_tick: 0, }; - app.open_selected_service(); + app.screen = DashboardScreen::Service( + ServiceScreenState::from_project(&project_state) + .expect("expected selected service to exist"), + ); match &app.screen { DashboardScreen::Service(state) => { diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 8ca819815..5013ba346 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -1,12 +1,34 @@ +use anyhow::{Result, anyhow}; use crossterm::event::{KeyCode, KeyEvent}; -use super::{DashboardProject, HandleKeyAction}; -use crate::controllers::dash_tui::{data::DashboardService, project::ProjectScreenState}; +use super::{ + HandleKeyAction, + data::{ + DashboardProject, DashboardService, ProjectLoadTarget, find_dashboard_service, + load_dashboard_project_with_client, + }, + project::{ProjectScreenState, ProjectsBackNavigation}, +}; +use crate::{ + client::GQLClient, + commands::Configs, + controllers::deployment::{ + ServiceDeployment, fetch_service_deployments, redeploy_latest_service_deployment, + }, +}; + +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) return_to_project: Box, + pub(in crate::controllers::dash_tui) deployments: Vec, + 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) redeploy_confirmation: Option, + pub(in crate::controllers::dash_tui) toast: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -19,6 +41,24 @@ pub(in crate::controllers::dash_tui) struct ServiceDetail { pub(in crate::controllers::dash_tui) service: DashboardService, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) struct RedeployConfirmationState { + pub(in crate::controllers::dash_tui) 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, +} + +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) struct ServiceScreenLoadedData { + pub(in crate::controllers::dash_tui) detail: ServiceDetail, + pub(in crate::controllers::dash_tui) return_to_project: Box, + pub(in crate::controllers::dash_tui) deployments: Vec, +} + impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn from_project( state: &ProjectScreenState, @@ -29,15 +69,172 @@ impl ServiceScreenState { Some(Self { detail: service_detail_from_project(project, service), return_to_project: Box::new(state.clone()), + deployments: Vec::new(), + loading: false, + error: None, + current_request_id: 0, + redeploy_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_data( + &mut self, + data: ServiceScreenLoadedData, + ) { + self.detail = data.detail; + self.return_to_project = data.return_to_project; + self.deployments = data.deployments; + self.loading = false; + self.error = 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; + } + + fn latest_redeployable_deployment_id(&self) -> Result { + let latest = self + .detail + .service + .latest_deployment + .as_ref() + .ok_or_else(|| anyhow!("No deployment found for service"))?; + + if !latest.can_redeploy { + return Err(anyhow!( + "The latest deployment for service {} cannot be redeployed.", + self.detail.service.name + )); + } + + Ok(latest.id.clone()) + } + + pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { + match self.latest_redeployable_deployment_id() { + Ok(deployment_id) => { + self.redeploy_confirmation = Some(RedeployConfirmationState { deployment_id }); + self.clear_toast(); + } + Err(error) => { + self.set_toast(error.to_string(), true); + } + } + } + + pub(in crate::controllers::dash_tui) fn service_id(&self) -> String { + self.detail.service.id.clone() + } + + pub(in crate::controllers::dash_tui) fn refresh_target(&self) -> ProjectLoadTarget { + self.return_to_project.target.clone() + } + + pub(in crate::controllers::dash_tui) fn return_to_projects_nav( + &self, + ) -> Option { + self.return_to_project.return_to_projects.clone() + } +} + +pub(in crate::controllers::dash_tui) async fn load_service_screen_data( + target: ProjectLoadTarget, + service_id: String, + return_to_projects: Option, +) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let project = load_dashboard_project_with_client(&configs, &client, target.clone()).await?; + let service = find_dashboard_service(&project, &service_id) + .cloned() + .ok_or_else(|| anyhow!("Service does not exist in the selected environment"))?; + + let deployments = fetch_service_deployments( + &client, + &configs.get_backboard(), + &project.id, + &project.selected_environment_id, + &service_id, + SERVICE_DEPLOYMENTS_LIMIT, + ) + .await?; + + let mut project_state = ProjectScreenState::new(target, return_to_projects); + project_state.apply_loaded_project(project.clone()); + project_state.select_service_by_id(&service_id); + + Ok(ServiceScreenLoadedData { + detail: service_detail_from_project(&project, service), + return_to_project: Box::new(project_state), + deployments, + }) +} + +pub(in crate::controllers::dash_tui) async fn redeploy_service( + detail: &ServiceDetail, +) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + + redeploy_latest_service_deployment( + &client, + &configs, + &detail.project_id, + &detail.environment_id, + &detail.service.id, + &detail.service.name, + ) + .await } pub(in crate::controllers::dash_tui) fn handle_service_screen_key( + state: &mut ServiceScreenState, key: KeyEvent, ) -> HandleKeyAction { + if state.redeploy_confirmation.is_some() { + return match key.code { + KeyCode::Esc | KeyCode::Backspace => { + state.redeploy_confirmation = None; + HandleKeyAction::None + } + KeyCode::Enter => { + state.redeploy_confirmation = None; + HandleKeyAction::RedeployService + } + _ => HandleKeyAction::None, + }; + } + match key.code { KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackToProject, + KeyCode::Char('r') => HandleKeyAction::RefreshService, + KeyCode::Char('R') => { + state.open_redeploy_confirmation(); + HandleKeyAction::None + } _ => HandleKeyAction::None, } } @@ -55,3 +252,71 @@ fn service_detail_from_project( service, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::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(), + }, + } + } + + #[test] + fn uppercase_r_opens_confirmation_for_redeployable_latest_deployment() { + let mut state = ServiceScreenState { + detail: detail(true), + return_to_project: Box::new(ProjectScreenState::new( + ProjectLoadTarget { + project_id: "proj_123".to_string(), + environment_hint: Some("production".to_string()), + }, + None, + )), + deployments: Vec::new(), + loading: false, + error: None, + current_request_id: 0, + redeploy_confirmation: None, + toast: None, + }; + + let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('R'))); + + assert!(matches!(action, HandleKeyAction::None)); + assert_eq!( + state + .redeploy_confirmation + .as_ref() + .map(|c| c.deployment_id.as_str()), + Some("dep_123") + ); + } +} diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index c22e4cf35..b5f53a865 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -1,30 +1,88 @@ use chrono_humanize::HumanTime; use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph, Wrap}; use super::{ - hero_style, muted_style, panel_block, panel_title_style, project_overview_sections, - project_sections, selected_border_style, + centered_rect, error_style, hero_style, loading_style, muted_style, panel_block, + panel_title_style, project_sections, selected_border_style, +}; +use crate::{ + commands::queries::deployments::DeploymentStatus, + controllers::{dash_tui::service::ServiceScreenState, deployment::ServiceDeployment}, }; -use crate::controllers::dash_tui::service::ServiceScreenState; pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { frame.render_widget(Clear, area); let [status_area, main_area] = project_sections(area); - let [details_area, resources_area] = project_overview_sections(main_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.redeploy_confirmation.is_some() { + render_redeploy_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![ @@ -34,7 +92,6 @@ fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScree Span::styled("id: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(state.detail.service.id.as_str()), ]), - Line::default(), Line::from(vec![ Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(state.detail.project_name.as_str()), @@ -48,6 +105,7 @@ fn render_service_status(frame: &mut Frame<'_>, area: Rect, state: &ServiceScree Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(workspace_name), ]), + state_line, ]) .block(panel_block("service")) .wrap(Wrap { trim: true }), @@ -198,15 +256,161 @@ fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceSc Line::from(Span::styled("volumes", panel_title_style())), Line::from(volumes), Line::default(), - Line::from(Span::styled("up next", panel_title_style())), - Line::from(Span::styled("d deployments", muted_style())), + Line::from(Span::styled("actions", panel_title_style())), + Line::from(Span::styled("r refresh service", muted_style())), + Line::from(Span::styled("R redeploy latest", muted_style())), Line::from(Span::styled("m metrics", muted_style())), Line::from(Span::styled("l logs", muted_style())), - Line::from(Span::styled("v/f volume browser", muted_style())), - Line::from(Span::styled("R redeploy latest", muted_style())), ]) .block(panel_block("resources")) .wrap(Wrap { trim: true }), area, ); } + +fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + let block = 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( + "latest shown first", + muted_style(), + )])]; + lines.push(Line::default()); + + for deployment in &state.deployments { + let is_current = current_deployment_id == Some(deployment.id.as_str()); + let status_style = deployment_style(deployment, is_current); + let age = HumanTime::from(deployment.created_at).to_string(); + + lines.push(Line::from(vec![ + 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(), + 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) -> Style { + 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(), + } + } +} + +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_redeploy_confirmation(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { + frame.render_widget(Clear, area); + + let deployment_id = state + .redeploy_confirmation + .as_ref() + .map(|confirmation| confirmation.deployment_id.as_str()) + .unwrap_or("unknown"); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled("Confirm redeploy", 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("redeploy").border_style(selected_border_style())) + .wrap(Wrap { trim: true }), + area, + ); +} diff --git a/src/controllers/deployment.rs b/src/controllers/deployment.rs index d259c2eda..68e14f4cc 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,90 @@ 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_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." + ); + } + + let response = post_graphql::( + client, + configs.get_backboard(), + mutations::deployment_redeploy::Variables { + id: latest.id.clone(), + }, + ) + .await?; + + Ok(response.deployment_redeploy.id) +} + // 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 { From 548b6dc935c2b51228948499ff5bbf8d66923469 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 03:07:41 +0100 Subject: [PATCH 12/21] dash-tui: deployment handling in services --- src/commands/restart.rs | 15 +- src/controllers/dash_tui/data.rs | 10 - src/controllers/dash_tui/mod.rs | 138 ++++++- src/controllers/dash_tui/service.rs | 387 ++++++++++++++---- src/controllers/dash_tui/ui/mod.rs | 2 +- src/controllers/dash_tui/ui/service.rs | 104 +++-- src/controllers/deployment.rs | 72 +++- src/gql/mutations/mod.rs | 9 + .../strings/DeploymentRollback.graphql | 3 + 9 files changed, 585 insertions(+), 155 deletions(-) create mode 100644 src/gql/mutations/strings/DeploymentRollback.graphql 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 index 83f37731a..1f30de693 100644 --- a/src/controllers/dash_tui/data.rs +++ b/src/controllers/dash_tui/data.rs @@ -151,16 +151,6 @@ pub async fn load_dashboard_project_with_client( Ok(map_dashboard_project(project, environment, instances)) } -pub fn find_dashboard_service<'a>( - project: &'a DashboardProject, - service_id: &str, -) -> Option<&'a DashboardService> { - project - .services - .iter() - .find(|service| service.id == service_id) -} - fn project_card_from_project(project: Project, workspace_name: String) -> ProjectCard { match project { Project::External(project) => ProjectCard { diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 471fd99a8..caf9c87db 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -32,8 +32,9 @@ use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; use self::service::{ - ServiceScreenLoadedData, ServiceScreenState, handle_service_screen_key, - load_service_screen_data, redeploy_service, + ServiceConfirmationState, ServiceScreenState, handle_service_screen_key, + load_service_deployments, redeploy_service as run_service_redeploy, + restart_service as run_service_restart, rollback_service_deployment as run_service_rollback, }; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -96,13 +97,19 @@ enum LoaderEvent { request_id: u64, result: std::result::Result, }, - ServiceLoaded { + ServiceDeploymentsLoaded { request_id: u64, - result: std::result::Result, + result: std::result::Result, String>, }, ServiceRedeployed { result: std::result::Result, }, + ServiceRestarted { + result: std::result::Result, + }, + ServiceRolledBack { + result: std::result::Result, + }, } enum HandleKeyAction { @@ -114,8 +121,9 @@ enum HandleKeyAction { OpenSelectedService, RefreshProjects, RefreshProject, - RefreshService, RedeployService, + RestartService, + RollbackDeployment, BackToProjects, BackToProject, OpenEnvironmentSelector, @@ -159,7 +167,7 @@ impl DashApp { match self.screen { DashboardScreen::Projects(_) => self.refresh_projects(tx), DashboardScreen::Project(_) => self.refresh_project(tx), - DashboardScreen::Service(_) => self.refresh_service(tx), + DashboardScreen::Service(_) => self.refresh_service_deployments(tx), } } @@ -229,26 +237,24 @@ impl DashApp { }; self.screen = DashboardScreen::Service(service_screen); - self.refresh_service(tx); + self.refresh_service_deployments(tx); } - fn refresh_service(&mut self, tx: &mpsc::UnboundedSender) { + 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 target = state.refresh_target(); - let service_id = state.service_id(); - let return_to_projects = state.return_to_projects_nav(); + let detail = state.detail.clone(); let tx = tx.clone(); tokio::spawn(async move { - let result = load_service_screen_data(target, service_id, return_to_projects) + let result = load_service_deployments(&detail) .await .map_err(|error| error.to_string()); - let _ = tx.send(LoaderEvent::ServiceLoaded { request_id, result }); + let _ = tx.send(LoaderEvent::ServiceDeploymentsLoaded { request_id, result }); }); } @@ -257,6 +263,12 @@ impl DashApp { return; }; + let deployment_id = match state.selected_confirmation().cloned() { + Some(ServiceConfirmationState::Redeploy { deployment_id }) => deployment_id, + _ => return, + }; + + state.confirmation = None; state.loading = true; state.error = None; state.clear_toast(); @@ -264,13 +276,64 @@ impl DashApp { let tx = tx.clone(); tokio::spawn(async move { - let result = redeploy_service(&detail) + let result = run_service_redeploy(&detail, &deployment_id) .await .map_err(|error| error.to_string()); let _ = tx.send(LoaderEvent::ServiceRedeployed { result }); }); } + fn restart_service(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + if !matches!( + state.selected_confirmation(), + Some(ServiceConfirmationState::Restart { .. }) + ) { + return; + } + + state.confirmation = None; + 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_restart(&detail) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceRestarted { result }); + }); + } + + fn rollback_service_deployment(&mut self, tx: &mpsc::UnboundedSender) { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + let deployment_id = match state.selected_confirmation().cloned() { + Some(ServiceConfirmationState::Rollback { deployment_id }) => deployment_id, + _ => return, + }; + + state.confirmation = None; + state.loading = true; + state.error = None; + state.clear_toast(); + let tx = tx.clone(); + + tokio::spawn(async move { + let result = run_service_rollback(&deployment_id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(LoaderEvent::ServiceRolledBack { result }); + }); + } + fn open_environment_selector(&mut self) { let DashboardScreen::Project(state) = &mut self.screen else { return; @@ -383,7 +446,7 @@ impl DashApp { Err(error) => state.set_error(error), } } - LoaderEvent::ServiceLoaded { request_id, result } => { + LoaderEvent::ServiceDeploymentsLoaded { request_id, result } => { let DashboardScreen::Service(state) = &mut self.screen else { return; }; @@ -393,7 +456,7 @@ impl DashApp { } match result { - Ok(data) => state.apply_loaded_data(data), + Ok(deployments) => state.apply_loaded_deployments(deployments), Err(error) => state.set_error(error), } } @@ -409,7 +472,45 @@ impl DashApp { format!("Redeploy triggered for deployment {deployment_id}"), false, ); - self.refresh_service(tx); + self.refresh_service_deployments(tx); + } + Err(error) => { + state.set_toast(error, true); + } + } + } + LoaderEvent::ServiceRestarted { result } => { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.loading = false; + match result { + Ok(deployment_id) => { + state.set_toast( + format!("Restart triggered for deployment {deployment_id}"), + false, + ); + self.refresh_service_deployments(tx); + } + Err(error) => { + state.set_toast(error, true); + } + } + } + LoaderEvent::ServiceRolledBack { result } => { + let DashboardScreen::Service(state) = &mut self.screen else { + return; + }; + + state.loading = false; + match result { + Ok(deployment_id) => { + state.set_toast( + format!("Rollback triggered to deployment {deployment_id}"), + false, + ); + self.refresh_service_deployments(tx); } Err(error) => { state.set_toast(error, true); @@ -486,8 +587,9 @@ impl DashApp { HandleKeyAction::OpenSelectedService => self.open_selected_service(tx), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), - HandleKeyAction::RefreshService => self.refresh_service(tx), HandleKeyAction::RedeployService => self.redeploy_service(tx), + HandleKeyAction::RestartService => self.restart_service(tx), + HandleKeyAction::RollbackDeployment => self.rollback_service_deployment(tx), HandleKeyAction::BackToProjects => self.back_to_projects(tx), HandleKeyAction::BackToProject => self.back_to_project(), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 5013ba346..8b024c896 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -1,19 +1,17 @@ use anyhow::{Result, anyhow}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::{ HandleKeyAction, - data::{ - DashboardProject, DashboardService, ProjectLoadTarget, find_dashboard_service, - load_dashboard_project_with_client, - }, - project::{ProjectScreenState, ProjectsBackNavigation}, + data::{DashboardProject, DashboardService}, + project::ProjectScreenState, }; use crate::{ client::GQLClient, commands::Configs, controllers::deployment::{ - ServiceDeployment, fetch_service_deployments, redeploy_latest_service_deployment, + ServiceDeployment, fetch_service_deployments, redeploy_deployment, + restart_latest_service_deployment, rollback_deployment, }, }; @@ -24,10 +22,12 @@ pub(in crate::controllers::dash_tui) struct ServiceScreenState { pub(in crate::controllers::dash_tui) detail: ServiceDetail, pub(in crate::controllers::dash_tui) return_to_project: Box, 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) redeploy_confirmation: Option, + pub(in crate::controllers::dash_tui) confirmation: Option, pub(in crate::controllers::dash_tui) toast: Option, } @@ -42,8 +42,16 @@ pub(in crate::controllers::dash_tui) struct ServiceDetail { } #[derive(Clone, Debug, PartialEq, Eq)] -pub(in crate::controllers::dash_tui) struct RedeployConfirmationState { - pub(in crate::controllers::dash_tui) deployment_id: String, +pub(in crate::controllers::dash_tui) enum ServiceFocus { + Overview, + Deployments, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) enum ServiceConfirmationState { + Redeploy { deployment_id: String }, + Restart { deployment_id: String }, + Rollback { deployment_id: String }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -52,13 +60,6 @@ pub(in crate::controllers::dash_tui) struct ServiceToast { pub(in crate::controllers::dash_tui) is_error: bool, } -#[derive(Clone, Debug)] -pub(in crate::controllers::dash_tui) struct ServiceScreenLoadedData { - pub(in crate::controllers::dash_tui) detail: ServiceDetail, - pub(in crate::controllers::dash_tui) return_to_project: Box, - pub(in crate::controllers::dash_tui) deployments: Vec, -} - impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn from_project( state: &ProjectScreenState, @@ -70,10 +71,12 @@ impl ServiceScreenState { detail: service_detail_from_project(project, service), return_to_project: Box::new(state.clone()), deployments: Vec::new(), + selected_deployment: 0, + focus: ServiceFocus::Overview, loading: false, error: None, current_request_id: 0, - redeploy_confirmation: None, + confirmation: None, toast: None, }) } @@ -84,15 +87,21 @@ impl ServiceScreenState { self.current_request_id += 1; } - pub(in crate::controllers::dash_tui) fn apply_loaded_data( + pub(in crate::controllers::dash_tui) fn apply_loaded_deployments( &mut self, - data: ServiceScreenLoadedData, + deployments: Vec, ) { - self.detail = data.detail; - self.return_to_project = data.return_to_project; - self.deployments = data.deployments; + let selected_deployment_id = self + .selected_deployment() + .map(|deployment| deployment.id.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); + } } pub(in crate::controllers::dash_tui) fn set_error(&mut self, error: String) { @@ -115,126 +124,241 @@ impl ServiceScreenState { self.toast = None; } - fn latest_redeployable_deployment_id(&self) -> Result { - let latest = self - .detail + 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 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() - .ok_or_else(|| anyhow!("No deployment found for service"))?; + .map(|deployment| deployment.id.clone()) + .ok_or_else(|| anyhow!("No deployment found for service")) + } - if !latest.can_redeploy { - return Err(anyhow!( - "The latest deployment for service {} cannot be redeployed.", - self.detail.service.name - )); + pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { + match self.selected_deployment() { + Some(deployment) => { + self.confirmation = Some(ServiceConfirmationState::Redeploy { + deployment_id: deployment.id.clone(), + }); + self.clear_toast(); + } + None => self.set_toast("No deployment selected.", true), } - - Ok(latest.id.clone()) } - pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { - match self.latest_redeployable_deployment_id() { + pub(in crate::controllers::dash_tui) fn open_restart_confirmation(&mut self) { + match self.latest_restartable_deployment_id() { Ok(deployment_id) => { - self.redeploy_confirmation = Some(RedeployConfirmationState { deployment_id }); + self.confirmation = Some(ServiceConfirmationState::Restart { deployment_id }); self.clear_toast(); } - Err(error) => { - self.set_toast(error.to_string(), true); + Err(error) => self.set_toast(error.to_string(), true), + } + } + + pub(in crate::controllers::dash_tui) fn open_rollback_confirmation(&mut self) { + match self.selected_deployment() { + Some(deployment) => { + self.confirmation = Some(ServiceConfirmationState::Rollback { + deployment_id: deployment.id.clone(), + }); + self.clear_toast(); } + None => self.set_toast("No deployment selected.", true), } } - pub(in crate::controllers::dash_tui) fn service_id(&self) -> String { - self.detail.service.id.clone() + 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 refresh_target(&self) -> ProjectLoadTarget { - self.return_to_project.target.clone() + 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); + } } - pub(in crate::controllers::dash_tui) fn return_to_projects_nav( + pub(in crate::controllers::dash_tui) fn selected_confirmation( &self, - ) -> Option { - self.return_to_project.return_to_projects.clone() + ) -> Option<&ServiceConfirmationState> { + self.confirmation.as_ref() } } -pub(in crate::controllers::dash_tui) async fn load_service_screen_data( - target: ProjectLoadTarget, - service_id: String, - return_to_projects: Option, -) -> Result { +pub(in crate::controllers::dash_tui) async fn load_service_deployments( + detail: &ServiceDetail, +) -> Result> { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; - let project = load_dashboard_project_with_client(&configs, &client, target.clone()).await?; - let service = find_dashboard_service(&project, &service_id) - .cloned() - .ok_or_else(|| anyhow!("Service does not exist in the selected environment"))?; - let deployments = fetch_service_deployments( + 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, - &project.selected_environment_id, - &service_id, + project_id, + environment_id, + service_id, SERVICE_DEPLOYMENTS_LIMIT, ) - .await?; + .await +} - let mut project_state = ProjectScreenState::new(target, return_to_projects); - project_state.apply_loaded_project(project.clone()); - project_state.select_service_by_id(&service_id); +pub(in crate::controllers::dash_tui) async fn redeploy_service( + _detail: &ServiceDetail, + deployment_id: &str, +) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; - Ok(ServiceScreenLoadedData { - detail: service_detail_from_project(&project, service), - return_to_project: Box::new(project_state), - deployments, - }) + redeploy_deployment(&client, &configs.get_backboard(), deployment_id).await } -pub(in crate::controllers::dash_tui) async fn redeploy_service( +pub(in crate::controllers::dash_tui) async fn restart_service( detail: &ServiceDetail, ) -> Result { let configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; - redeploy_latest_service_deployment( + restart_latest_service_deployment( &client, &configs, &detail.project_id, &detail.environment_id, &detail.service.id, - &detail.service.name, ) .await } +pub(in crate::controllers::dash_tui) async fn rollback_service_deployment( + deployment_id: &str, +) -> Result { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + + 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 state.redeploy_confirmation.is_some() { + if let Some(confirmation) = state.selected_confirmation().cloned() { return match key.code { KeyCode::Esc | KeyCode::Backspace => { - state.redeploy_confirmation = None; + state.confirmation = None; HandleKeyAction::None } - KeyCode::Enter => { - state.redeploy_confirmation = None; - HandleKeyAction::RedeployService - } + KeyCode::Enter => match confirmation { + ServiceConfirmationState::Redeploy { .. } => HandleKeyAction::RedeployService, + ServiceConfirmationState::Restart { .. } => HandleKeyAction::RestartService, + ServiceConfirmationState::Rollback { .. } => HandleKeyAction::RollbackDeployment, + }, _ => HandleKeyAction::None, }; } match key.code { KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackToProject, - KeyCode::Char('r') => HandleKeyAction::RefreshService, - KeyCode::Char('R') => { + 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::Char('r') => { + state.open_restart_confirmation(); + HandleKeyAction::None + } + KeyCode::Char('D') => { state.open_redeploy_confirmation(); HandleKeyAction::None } + KeyCode::Char('R') => { + state.open_rollback_confirmation(); + HandleKeyAction::None + } _ => HandleKeyAction::None, } } @@ -256,7 +380,10 @@ fn service_detail_from_project( #[cfg(test)] mod tests { use super::*; - use crate::controllers::dash_tui::data::DeploymentSummary; + use crate::{ + commands::queries::deployments::DeploymentStatus, + controllers::dash_tui::data::{DeploymentSummary, ProjectLoadTarget}, + }; use chrono::Utc; fn detail(can_redeploy: bool) -> ServiceDetail { @@ -289,9 +416,8 @@ mod tests { } } - #[test] - fn uppercase_r_opens_confirmation_for_redeployable_latest_deployment() { - let mut state = ServiceScreenState { + fn service_state() -> ServiceScreenState { + ServiceScreenState { detail: detail(true), return_to_project: Box::new(ProjectScreenState::new( ProjectLoadTarget { @@ -300,23 +426,102 @@ mod tests { }, None, )), - deployments: Vec::new(), + 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, - redeploy_confirmation: 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.selected_confirmation(), + Some(&ServiceConfirmationState::Redeploy { + 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.selected_confirmation(), + Some(&ServiceConfirmationState::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 - .redeploy_confirmation - .as_ref() - .map(|c| c.deployment_id.as_str()), - Some("dep_123") + state.selected_confirmation(), + Some(&ServiceConfirmationState::Rollback { + deployment_id: "dep_122".to_string() + }) ); } } diff --git a/src/controllers/dash_tui/ui/mod.rs b/src/controllers/dash_tui/ui/mod.rs index 4f7c56211..acef1c705 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -104,7 +104,7 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } }, DashboardScreen::Service(_) => { - "Esc back • q quit • deployments/metrics/logs/files/redeploy coming next" + "d/right/l focus deployments • Tab switch panes • arrows/ik scroll • r restart service • D redeploy selected • R rollback selected • Esc back • q quit" } }; diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index b5f53a865..7bc83ea70 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -7,11 +7,14 @@ use ratatui::widgets::{Clear, Paragraph, Wrap}; use super::{ centered_rect, error_style, hero_style, loading_style, muted_style, panel_block, - panel_title_style, project_sections, selected_border_style, + panel_title_style, project_sections, selected_border_style, selected_title_style, }; use crate::{ commands::queries::deployments::DeploymentStatus, - controllers::{dash_tui::service::ServiceScreenState, deployment::ServiceDeployment}, + controllers::{ + dash_tui::service::{ServiceConfirmationState, ServiceFocus, ServiceScreenState}, + deployment::ServiceDeployment, + }, }; pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { @@ -30,8 +33,8 @@ pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &S render_service_resources(frame, resources_area, state); render_service_deployments(frame, sidebar_area, state); - if state.redeploy_confirmation.is_some() { - render_redeploy_confirmation(frame, centered_rect(main_area, 60, 42), state); + if state.confirmation.is_some() { + render_service_confirmation(frame, centered_rect(main_area, 60, 42), state); } } @@ -257,10 +260,16 @@ fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceSc Line::from(volumes), Line::default(), Line::from(Span::styled("actions", panel_title_style())), - Line::from(Span::styled("r refresh service", muted_style())), - Line::from(Span::styled("R redeploy latest", muted_style())), - Line::from(Span::styled("m metrics", muted_style())), - Line::from(Span::styled("l logs", muted_style())), + Line::from(Span::styled("d focus deployments", 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 }), @@ -269,7 +278,12 @@ fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceSc } fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { - let block = panel_block("deployments"); + 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); @@ -313,24 +327,50 @@ fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &Service .map(|deployment| deployment.id.as_str()); let mut lines = vec![Line::from(vec![Span::styled( - "latest shown first", + if is_focused { + "deployments focused" + } else { + "press d to focus" + }, muted_style(), )])]; lines.push(Line::default()); - for deployment in &state.deployments { + 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 status_style = deployment_style(deployment, is_current); + 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(), - Style::default().add_modifier(Modifier::BOLD), + 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()); @@ -339,8 +379,13 @@ fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &Service frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), inner); } -fn deployment_style(deployment: &ServiceDeployment, is_current: bool) -> Style { - if is_current { +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) @@ -353,6 +398,12 @@ fn deployment_style(deployment: &ServiceDeployment, is_current: bool) -> Style { DeploymentStatus::SUCCESS => hero_style(), _ => loading_style(), } + }; + + if is_selected && is_focused { + base.add_modifier(Modifier::UNDERLINED) + } else { + base } } @@ -375,18 +426,25 @@ fn format_status(status: &DeploymentStatus) -> String { format!("{status:?}") } -fn render_redeploy_confirmation(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { +fn render_service_confirmation(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { frame.render_widget(Clear, area); - let deployment_id = state - .redeploy_confirmation - .as_ref() - .map(|confirmation| confirmation.deployment_id.as_str()) - .unwrap_or("unknown"); + let (title, deployment_id) = match state.selected_confirmation() { + Some(ServiceConfirmationState::Redeploy { deployment_id }) => { + ("Confirm redeploy", deployment_id.as_str()) + } + Some(ServiceConfirmationState::Restart { deployment_id }) => { + ("Confirm restart", deployment_id.as_str()) + } + Some(ServiceConfirmationState::Rollback { deployment_id }) => { + ("Confirm rollback", deployment_id.as_str()) + } + None => return, + }; frame.render_widget( Paragraph::new(vec![ - Line::from(Span::styled("Confirm redeploy", hero_style())), + Line::from(Span::styled(title, hero_style())), Line::default(), Line::from(vec![ Span::styled("service: ", Style::default().add_modifier(Modifier::BOLD)), @@ -409,7 +467,7 @@ fn render_redeploy_confirmation(frame: &mut Frame<'_>, area: Rect, state: &Servi Line::default(), Line::from(Span::styled("Enter confirm • Esc cancel", muted_style())), ]) - .block(panel_block("redeploy").border_style(selected_border_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 68e14f4cc..dd62189dc 100644 --- a/src/controllers/deployment.rs +++ b/src/controllers/deployment.rs @@ -88,6 +88,23 @@ pub async fn fetch_service_deployments( .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, @@ -111,16 +128,61 @@ pub async fn redeploy_latest_service_deployment( ); } - let response = post_graphql::( + 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, - configs.get_backboard(), - mutations::deployment_redeploy::Variables { - id: latest.id.clone(), + backboard, + mutations::deployment_restart::Variables { + id: deployment_id.to_string(), }, ) .await?; - Ok(response.deployment_redeploy.id) + 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 diff --git a/src/gql/mutations/mod.rs b/src/gql/mutations/mod.rs index b7ee7c893..858d58b11 100644 --- a/src/gql/mutations/mod.rs +++ b/src/gql/mutations/mod.rs @@ -141,6 +141,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) +} From 8f779531b98d2f6aeb5bc1a884f81a49b697f869 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 03:12:08 +0100 Subject: [PATCH 13/21] dash-tui: simplify code and reduce duplication --- src/controllers/dash_tui/mod.rs | 4 +- src/controllers/dash_tui/project.rs | 5 +- src/controllers/dash_tui/ui/mod.rs | 39 +------ src/controllers/dash_tui/ui/project.rs | 140 +++++++++--------------- src/controllers/dash_tui/ui/projects.rs | 8 +- src/controllers/dash_tui/ui/service.rs | 4 +- 6 files changed, 65 insertions(+), 135 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index caf9c87db..9cc68349d 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -809,7 +809,9 @@ fn handle_projects_screen_key( key: KeyEvent, terminal_area: Rect, ) -> HandleKeyAction { - let columns = ui::project_navigation_columns(terminal_area); + let [_, body, _] = ui::dashboard_sections(terminal_area); + let [_, grid_area] = ui::screen_sections(body); + let columns = ui::project_grid_columns(ui::panel_block("cards").inner(grid_area).width); match key.code { KeyCode::Up | KeyCode::Char('i') => state.move_up(columns), diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs index 267747e1e..4fbb8f41a 100644 --- a/src/controllers/dash_tui/project.rs +++ b/src/controllers/dash_tui/project.rs @@ -138,7 +138,10 @@ pub(in crate::controllers::dash_tui) fn handle_project_screen_key( key: KeyEvent, terminal_area: Rect, ) -> HandleKeyAction { - let columns = ui::service_navigation_columns(terminal_area); + 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_columns(ui::panel_block("services").inner(diagram_area).width); match key.code { KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::BackToProjects, diff --git a/src/controllers/dash_tui/ui/mod.rs b/src/controllers/dash_tui/ui/mod.rs index acef1c705..167f9d324 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -37,14 +37,6 @@ pub(super) fn render(frame: &mut Frame<'_>, app: &DashApp) { render_footer(frame, footer, app); } -pub(super) fn project_navigation_columns(frame_area: Rect) -> usize { - project_grid_columns_for_area(projects_grid_area(frame_area)) -} - -pub(super) fn service_navigation_columns(frame_area: Rect) -> usize { - service_grid_columns_for_area(project_services_area(frame_area)) -} - pub(super) fn project_grid_columns(width: u16) -> usize { let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; ((width + PROJECT_CARD_GAP) / stride).max(1) as usize @@ -55,14 +47,6 @@ pub(super) fn service_grid_columns(width: u16) -> usize { ((width + SERVICE_CARD_GAP) / stride).max(1) as usize } -fn project_grid_columns_for_area(area: Rect) -> usize { - project_grid_columns(panel_inner_area(area).width).max(1) -} - -fn service_grid_columns_for_area(area: Rect) -> usize { - service_grid_columns(panel_inner_area(area).width).max(1) -} - 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", @@ -126,11 +110,7 @@ pub(super) fn dashboard_sections(area: Rect) -> [Rect; 3] { .areas(area) } -pub(super) fn projects_sections(area: Rect) -> [Rect; 2] { - Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) -} - -pub(super) fn project_sections(area: Rect) -> [Rect; 2] { +pub(super) fn screen_sections(area: Rect) -> [Rect; 2] { Layout::vertical([Constraint::Length(4), Constraint::Min(8)]).areas(area) } @@ -138,19 +118,6 @@ pub(super) fn project_overview_sections(area: Rect) -> [Rect; 2] { Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]).areas(area) } -fn projects_grid_area(frame_area: Rect) -> Rect { - let [_, body, _] = dashboard_sections(frame_area); - let [_, grid_area] = projects_sections(body); - grid_area -} - -fn project_services_area(frame_area: Rect) -> Rect { - let [_, body, _] = dashboard_sections(frame_area); - let [_, main_area] = project_sections(body); - let [diagram_area, _] = project_overview_sections(main_area); - diagram_area -} - pub(super) fn panel_block<'a>(title: &'a str) -> Block<'a> { Block::default() .borders(Borders::ALL) @@ -202,10 +169,6 @@ pub(super) fn error_style() -> Style { .add_modifier(Modifier::BOLD) } -pub(super) fn panel_inner_area(area: Rect) -> Rect { - panel_block("").inner(area) -} - pub(super) fn render_centered_message( frame: &mut Frame<'_>, area: Rect, diff --git a/src/controllers/dash_tui/ui/project.rs b/src/controllers/dash_tui/ui/project.rs index b4e78edc5..5b40ff118 100644 --- a/src/controllers/dash_tui/ui/project.rs +++ b/src/controllers/dash_tui/ui/project.rs @@ -8,9 +8,8 @@ use super::super::{DashApp, RAILWAY_ERROR, RAILWAY_LAVENDER, RAILWAY_VIOLET, SPI 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, - panel_title_style, project_overview_sections, project_sections, render_centered_message, - selected_border_style, selected_title_style, service_card_width, service_grid_columns, - service_rows_per_page, + project_overview_sections, render_centered_message, screen_sections, selected_border_style, + selected_title_style, service_card_width, service_grid_columns, service_rows_per_page, }; use crate::controllers::dash_tui::data::DashboardService; use crate::controllers::dash_tui::project::ProjectScreenState; @@ -23,7 +22,7 @@ pub(super) fn render_project_screen( ) { frame.render_widget(Clear, area); - let [status_area, main_area] = project_sections(area); + let [status_area, main_area] = screen_sections(area); render_project_status(frame, status_area, app, state); @@ -170,9 +169,9 @@ fn render_project_diagram(frame: &mut Frame<'_>, area: Rect, state: &ProjectScre return; } - let columns = service_grid_columns(super::panel_inner_area(area).width).max(1); + let columns = service_grid_columns(inner.width); let card_width = service_card_width(inner.width, columns); - let rows_per_page = service_rows_per_page(inner.height).max(1); + let rows_per_page = service_rows_per_page(inner.height); 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; @@ -259,7 +258,7 @@ fn render_service_card( } fn render_project_summary(frame: &mut Frame<'_>, area: Rect, state: &ProjectScreenState) { - let Some(project) = &state.project else { + if state.project.is_none() { frame.render_widget( Paragraph::new("Project summary will appear once the overview loads.") .style(muted_style()) @@ -268,7 +267,7 @@ fn render_project_summary(frame: &mut Frame<'_>, area: Rect, state: &ProjectScre area, ); return; - }; + } let Some(service) = state.selected_service() else { frame.render_widget( @@ -281,96 +280,59 @@ fn render_project_summary(frame: &mut Frame<'_>, area: Rect, state: &ProjectScre return; }; - let workspace_name = project.workspace_name.as_deref().unwrap_or("personal"); - let deployment_status = service + let label_style = Style::default().add_modifier(Modifier::BOLD); + let status = service .latest_deployment .as_ref() .map(|deployment| deployment.status.as_str()) - .unwrap_or("no deployment"); - let deployment_id = service + .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("-"); - 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") - }; - let source = service - .source_repo - .as_deref() - .or(service.source_image.as_deref()) .unwrap_or("none"); - - let lines = vec![ - Line::from(Span::styled(service.name.as_str(), hero_style())), - Line::default(), - Line::from(vec![ - Span::styled("project: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(project.name.as_str()), - ]), - Line::from(vec![ - Span::styled("workspace: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(workspace_name), - ]), - Line::from(vec![ - Span::styled( - "environment: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(project.selected_environment_name.as_str()), - ]), - Line::default(), - Line::from(vec![ - Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(deployment_status), - ]), - Line::from(vec![ - Span::styled( - "deployment: ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(deployment_id), - ]), - 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::from(vec![ - Span::styled("source: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(source), - ]), - Line::from(vec![ - Span::styled("cron: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(service.cron_schedule.as_deref().unwrap_or("none")), - ]), - Line::from(vec![ - Span::styled("command: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(service.start_command.as_deref().unwrap_or("none")), - ]), - Line::default(), - Line::from(Span::styled("domains", panel_title_style())), - Line::from(domains), - Line::default(), - Line::from(Span::styled("volumes", panel_title_style())), - Line::from(volumes), - ]; + 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(lines) - .block(panel_block("selected service").border_style(selected_border_style())) - .wrap(Wrap { trim: true }), + 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, ); } diff --git a/src/controllers/dash_tui/ui/projects.rs b/src/controllers/dash_tui/ui/projects.rs index 2b78f51ba..a98c4de5f 100644 --- a/src/controllers/dash_tui/ui/projects.rs +++ b/src/controllers/dash_tui/ui/projects.rs @@ -8,7 +8,7 @@ use super::super::{DashApp, ProjectsScreenState, RAILWAY_LAVENDER, SPINNER_FRAME 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_card_width, project_grid_columns, project_rows_per_page, projects_sections, + project_card_width, project_grid_columns, project_rows_per_page, screen_sections, selected_border_style, selected_title_style, }; use crate::controllers::dash_tui::data::ProjectCard; @@ -21,7 +21,7 @@ pub(super) fn render_projects_screen( ) { frame.render_widget(Clear, area); - let [status_area, grid_area] = projects_sections(area); + let [status_area, grid_area] = screen_sections(area); render_projects_status(frame, status_area, app, state); render_projects_grid(frame, grid_area, state); @@ -147,9 +147,9 @@ fn render_projects_grid(frame: &mut Frame<'_>, area: Rect, state: &ProjectsScree return; } - let columns = project_grid_columns(super::panel_inner_area(area).width).max(1); + let columns = project_grid_columns(inner.width); let card_width = project_card_width(inner.width, columns); - let rows_per_page = project_rows_per_page(inner.height).max(1); + let rows_per_page = project_rows_per_page(inner.height); 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; diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index 7bc83ea70..d739cda03 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -7,7 +7,7 @@ use ratatui::widgets::{Clear, Paragraph, Wrap}; use super::{ centered_rect, error_style, hero_style, loading_style, muted_style, panel_block, - panel_title_style, project_sections, selected_border_style, selected_title_style, + panel_title_style, screen_sections, selected_border_style, selected_title_style, }; use crate::{ commands::queries::deployments::DeploymentStatus, @@ -20,7 +20,7 @@ use crate::{ pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { frame.render_widget(Clear, area); - let [status_area, main_area] = project_sections(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); From 06d00acb4341fa5a963fd6705d1fb30c8ca6c1fe Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 03:41:30 +0100 Subject: [PATCH 14/21] dash-tui: add log viewer --- src/commands/logs.rs | 210 +++++++++++++++++---- src/controllers/dash_tui/logs.rs | 280 ++++++++++++++++++++++++++++ src/controllers/dash_tui/mod.rs | 177 +++++++++++++++++- src/controllers/dash_tui/project.rs | 1 + src/controllers/dash_tui/ui/logs.rs | 122 ++++++++++++ src/controllers/dash_tui/ui/mod.rs | 11 +- src/util/logs.rs | 29 +++ 7 files changed, 781 insertions(+), 49 deletions(-) create mode 100644 src/controllers/dash_tui/logs.rs create mode 100644 src/controllers/dash_tui/ui/logs.rs 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/controllers/dash_tui/logs.rs b/src/controllers/dash_tui/logs.rs new file mode 100644 index 000000000..9daf8762e --- /dev/null +++ b/src/controllers/dash_tui/logs.rs @@ -0,0 +1,280 @@ +use std::collections::VecDeque; + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::{HandleKeyAction, project::ProjectScreenState}; +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) targets: Vec, + pub(in crate::controllers::dash_tui) return_to_project: Box, + 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 { + project_name: project.name.clone(), + environment_name: project.selected_environment_name.clone(), + targets, + return_to_project: Box::new(state.clone()), + 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() + } + + 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::BackToProject, + 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()), + }, + None, + ); + 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.targets.len(), 2); + assert_eq!(state.targets[0].service_name, "web"); + assert_eq!(state.targets[1].service_name, "worker"); + } + + #[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 index 9cc68349d..c3f354d5b 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -1,4 +1,5 @@ mod data; +mod logs; mod project; mod service; mod ui; @@ -28,6 +29,7 @@ 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, ProjectsBackNavigation, handle_project_screen_key, }; @@ -36,6 +38,11 @@ use self::service::{ load_service_deployments, redeploy_service as run_service_redeploy, restart_service as run_service_restart, rollback_service_deployment as run_service_rollback, }; +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); @@ -62,11 +69,12 @@ pub struct DashTuiParams { pub auth_mode: DashboardAuthMode, } -#[derive(Clone, Debug)] +#[derive(Debug)] struct DashApp { params: DashTuiParams, screen: DashboardScreen, spinner_tick: usize, + log_stream_task: Option>, } #[derive(Clone, Debug)] @@ -74,6 +82,7 @@ enum DashboardScreen { Projects(ProjectsScreenState), Project(ProjectScreenState), Service(ServiceScreenState), + Logs(LogsScreenState), } #[derive(Clone, Debug)] @@ -110,6 +119,18 @@ enum LoaderEvent { ServiceRolledBack { 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 { @@ -119,6 +140,7 @@ enum HandleKeyAction { return_to_projects: ProjectsBackNavigation, }, OpenSelectedService, + OpenProjectLogs, RefreshProjects, RefreshProject, RedeployService, @@ -160,6 +182,7 @@ impl DashApp { params, screen, spinner_tick: 0, + log_stream_task: None, } } @@ -168,6 +191,7 @@ impl DashApp { 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), } } @@ -240,6 +264,20 @@ impl DashApp { 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.screen = DashboardScreen::Logs(logs_screen); + self.start_logs_stream(tx); + } + fn refresh_service_deployments(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Service(state) = &mut self.screen else { return; @@ -258,6 +296,75 @@ impl DashApp { }); } + 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 redeploy_service(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Service(state) = &mut self.screen else { return; @@ -379,11 +486,16 @@ impl DashApp { } fn back_to_project(&mut self) { - let DashboardScreen::Service(state) = &self.screen else { - return; + let project_state = match &self.screen { + DashboardScreen::Service(state) => Some((*state.return_to_project).clone()), + DashboardScreen::Logs(state) => Some((*state.return_to_project).clone()), + DashboardScreen::Projects(_) | DashboardScreen::Project(_) => None, }; - self.screen = DashboardScreen::Project((*state.return_to_project).clone()); + if let Some(project_state) = project_state { + self.stop_logs_stream(); + self.screen = DashboardScreen::Project(project_state); + } } fn back_to_projects(&mut self, tx: &mpsc::UnboundedSender) { @@ -517,6 +629,45 @@ impl DashApp { } } } + 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); + } + } } } @@ -549,6 +700,7 @@ impl DashApp { || (matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL)) { + self.stop_logs_stream(); return true; } @@ -576,6 +728,7 @@ impl DashApp { } 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 { @@ -585,6 +738,7 @@ impl DashApp { return_to_projects, } => self.open_project(project_id, Some(return_to_projects), tx), HandleKeyAction::OpenSelectedService => self.open_selected_service(tx), + HandleKeyAction::OpenProjectLogs => self.open_project_logs(tx), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), HandleKeyAction::RedeployService => self.redeploy_service(tx), @@ -801,6 +955,7 @@ pub async fn run(params: DashTuiParams) -> Result<()> { } } + app.stop_logs_stream(); Ok(()) } @@ -983,6 +1138,7 @@ mod tests { Some(ProjectsBackNavigation::Restore(Box::new(restored.clone()))), )), spinner_tick: 0, + log_stream_task: None, }; let (tx, _rx) = mpsc::unbounded_channel(); @@ -998,7 +1154,9 @@ mod tests { assert_eq!(state.current_request_id, 7); assert!(!state.loading); } - DashboardScreen::Project(_) | DashboardScreen::Service(_) => { + DashboardScreen::Project(_) + | DashboardScreen::Service(_) + | DashboardScreen::Logs(_) => { panic!("expected projects screen after backing out") } } @@ -1024,6 +1182,7 @@ mod tests { }, screen: DashboardScreen::Project(project_state.clone()), spinner_tick: 0, + log_stream_task: None, }; app.screen = DashboardScreen::Service( @@ -1037,7 +1196,9 @@ mod tests { assert_eq!(state.detail.project_name, "api"); assert_eq!(state.detail.environment_name, "production"); } - DashboardScreen::Projects(_) | DashboardScreen::Project(_) => { + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Logs(_) => { panic!("expected service screen after opening selected service") } } @@ -1051,7 +1212,9 @@ mod tests { Some("svc_2") ); } - DashboardScreen::Projects(_) | DashboardScreen::Service(_) => { + DashboardScreen::Projects(_) + | DashboardScreen::Service(_) + | DashboardScreen::Logs(_) => { panic!("expected project screen after backing out of service detail") } } diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs index 4fbb8f41a..cc6a09ed0 100644 --- a/src/controllers/dash_tui/project.rs +++ b/src/controllers/dash_tui/project.rs @@ -150,6 +150,7 @@ pub(in crate::controllers::dash_tui) fn handle_project_screen_key( 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, _ => {} diff --git a/src/controllers/dash_tui/ui/logs.rs b/src/controllers/dash_tui/ui/logs.rs new file mode 100644 index 000000000..5e1864813 --- /dev/null +++ b/src/controllers/dash_tui/ui/logs.rs @@ -0,0 +1,122 @@ +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 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("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(" • following latest log lines across the environment"), + ]) + }; + + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("scope: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("environment logs", hero_style()), + Span::raw(" • "), + Span::styled("services: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(services), + ]), + Line::from(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()), + Span::raw(" • "), + Span::styled("buffer: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(state.lines.len().to_string(), muted_style()), + ]), + 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("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( + "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 { + "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 index 167f9d324..29c419fa5 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -1,3 +1,4 @@ +mod logs; mod project; mod projects; mod service; @@ -8,6 +9,7 @@ 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; @@ -32,6 +34,7 @@ pub(super) fn render(frame: &mut Frame<'_>, app: &DashApp) { 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); @@ -56,6 +59,7 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } DashboardScreen::Project(_) => "project overview", DashboardScreen::Service(_) => "service detail", + DashboardScreen::Logs(_) => "logs", }; frame.render_widget( @@ -81,15 +85,18 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } DashboardScreen::Project(_) => match app.params.auth_mode { DashboardAuthMode::Workspace => { - "Enter service • Esc back • e environments • arrows/ijkl move • r reload • q quit" + "Enter service • L logs • Esc back • e environments • arrows/ijkl move • r reload • q quit" } DashboardAuthMode::LinkedProject { .. } => { - "Enter service • e environments • arrows/ijkl move • r reload • q quit" + "Enter service • L logs • e environments • arrows/ijkl move • r reload • q quit" } }, DashboardScreen::Service(_) => { "d/right/l focus deployments • Tab switch panes • arrows/ik scroll • r restart service • D redeploy selected • R rollback selected • Esc back • q quit" } + DashboardScreen::Logs(_) => { + "Esc back • p pause/resume • g/G top/bottom • arrows/ik scroll • q quit" + } }; frame.render_widget( 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 From fc7db35706537b6d88d988b975614366c2446aba Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 03:51:02 +0100 Subject: [PATCH 15/21] dash-tui: simplify service actions --- src/controllers/dash_tui/mod.rs | 155 +++++++--------------------- src/controllers/dash_tui/service.rs | 119 ++++++++++++++------- 2 files changed, 119 insertions(+), 155 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index c3f354d5b..f76c0b8d4 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -34,9 +34,8 @@ use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; use self::service::{ - ServiceConfirmationState, ServiceScreenState, handle_service_screen_key, - load_service_deployments, redeploy_service as run_service_redeploy, - restart_service as run_service_restart, rollback_service_deployment as run_service_rollback, + ServiceAction, ServiceScreenState, handle_service_screen_key, load_service_deployments, + run_service_action, }; use crate::{ client::GQLClient, @@ -110,13 +109,8 @@ enum LoaderEvent { request_id: u64, result: std::result::Result, String>, }, - ServiceRedeployed { - result: std::result::Result, - }, - ServiceRestarted { - result: std::result::Result, - }, - ServiceRolledBack { + ServiceActionFinished { + action: ServiceAction, result: std::result::Result, }, LogsLoaded { @@ -143,9 +137,7 @@ enum HandleKeyAction { OpenProjectLogs, RefreshProjects, RefreshProject, - RedeployService, - RestartService, - RollbackDeployment, + RunServiceAction(ServiceAction), BackToProjects, BackToProject, OpenEnvironmentSelector, @@ -365,40 +357,23 @@ impl DashApp { } } - fn redeploy_service(&mut self, tx: &mpsc::UnboundedSender) { + fn start_service_action( + &mut self, + action: ServiceAction, + tx: &mpsc::UnboundedSender, + ) { let DashboardScreen::Service(state) = &mut self.screen else { return; }; - let deployment_id = match state.selected_confirmation().cloned() { - Some(ServiceConfirmationState::Redeploy { deployment_id }) => deployment_id, - _ => return, - }; - - state.confirmation = None; - 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_redeploy(&detail, &deployment_id) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(LoaderEvent::ServiceRedeployed { result }); - }); - } - - fn restart_service(&mut self, tx: &mpsc::UnboundedSender) { - let DashboardScreen::Service(state) = &mut self.screen else { + let Some(confirmed_action) = state + .selected_confirmation() + .map(ServiceAction::from_confirmation) + else { return; }; - if !matches!( - state.selected_confirmation(), - Some(ServiceConfirmationState::Restart { .. }) - ) { + if state.loading || confirmed_action != action { return; } @@ -410,35 +385,33 @@ impl DashApp { let tx = tx.clone(); tokio::spawn(async move { - let result = run_service_restart(&detail) + let result = run_service_action(&detail, &action) .await .map_err(|error| error.to_string()); - let _ = tx.send(LoaderEvent::ServiceRestarted { result }); + let _ = tx.send(LoaderEvent::ServiceActionFinished { action, result }); }); } - fn rollback_service_deployment(&mut self, tx: &mpsc::UnboundedSender) { + 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; }; - let deployment_id = match state.selected_confirmation().cloned() { - Some(ServiceConfirmationState::Rollback { deployment_id }) => deployment_id, - _ => return, - }; - - state.confirmation = None; - state.loading = true; - state.error = None; - state.clear_toast(); - let tx = tx.clone(); - - tokio::spawn(async move { - let result = run_service_rollback(&deployment_id) - .await - .map_err(|error| error.to_string()); - let _ = tx.send(LoaderEvent::ServiceRolledBack { result }); - }); + 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) { @@ -572,62 +545,8 @@ impl DashApp { Err(error) => state.set_error(error), } } - LoaderEvent::ServiceRedeployed { result } => { - let DashboardScreen::Service(state) = &mut self.screen else { - return; - }; - - state.loading = false; - match result { - Ok(deployment_id) => { - state.set_toast( - format!("Redeploy triggered for deployment {deployment_id}"), - false, - ); - self.refresh_service_deployments(tx); - } - Err(error) => { - state.set_toast(error, true); - } - } - } - LoaderEvent::ServiceRestarted { result } => { - let DashboardScreen::Service(state) = &mut self.screen else { - return; - }; - - state.loading = false; - match result { - Ok(deployment_id) => { - state.set_toast( - format!("Restart triggered for deployment {deployment_id}"), - false, - ); - self.refresh_service_deployments(tx); - } - Err(error) => { - state.set_toast(error, true); - } - } - } - LoaderEvent::ServiceRolledBack { result } => { - let DashboardScreen::Service(state) = &mut self.screen else { - return; - }; - - state.loading = false; - match result { - Ok(deployment_id) => { - state.set_toast( - format!("Rollback triggered to deployment {deployment_id}"), - false, - ); - self.refresh_service_deployments(tx); - } - Err(error) => { - state.set_toast(error, true); - } - } + 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 { @@ -741,9 +660,7 @@ impl DashApp { HandleKeyAction::OpenProjectLogs => self.open_project_logs(tx), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), - HandleKeyAction::RedeployService => self.redeploy_service(tx), - HandleKeyAction::RestartService => self.restart_service(tx), - HandleKeyAction::RollbackDeployment => self.rollback_service_deployment(tx), + HandleKeyAction::RunServiceAction(action) => self.start_service_action(action, tx), HandleKeyAction::BackToProjects => self.back_to_projects(tx), HandleKeyAction::BackToProject => self.back_to_project(), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 8b024c896..fa4fc25d7 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -54,12 +54,45 @@ pub(in crate::controllers::dash_tui) enum ServiceConfirmationState { Rollback { deployment_id: String }, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::controllers::dash_tui) enum ServiceAction { + Redeploy { deployment_id: String }, + Restart, + 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 from_confirmation( + confirmation: &ServiceConfirmationState, + ) -> Self { + match confirmation { + ServiceConfirmationState::Redeploy { deployment_id } => Self::Redeploy { + deployment_id: deployment_id.clone(), + }, + ServiceConfirmationState::Restart { .. } => Self::Restart, + ServiceConfirmationState::Rollback { deployment_id } => Self::Rollback { + deployment_id: deployment_id.clone(), + }, + } + } + + 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: &ProjectScreenState, @@ -263,39 +296,31 @@ async fn load_service_deployments_with_client( .await } -pub(in crate::controllers::dash_tui) async fn redeploy_service( - _detail: &ServiceDetail, - deployment_id: &str, -) -> Result { - let configs = Configs::new()?; - let client = GQLClient::new_authorized(&configs)?; - - redeploy_deployment(&client, &configs.get_backboard(), deployment_id).await -} - -pub(in crate::controllers::dash_tui) async fn restart_service( +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)?; - restart_latest_service_deployment( - &client, - &configs, - &detail.project_id, - &detail.environment_id, - &detail.service.id, - ) - .await -} - -pub(in crate::controllers::dash_tui) async fn rollback_service_deployment( - deployment_id: &str, -) -> Result { - let configs = Configs::new()?; - let client = GQLClient::new_authorized(&configs)?; - - rollback_deployment(&client, &configs.get_backboard(), deployment_id).await + 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( @@ -308,11 +333,9 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( state.confirmation = None; HandleKeyAction::None } - KeyCode::Enter => match confirmation { - ServiceConfirmationState::Redeploy { .. } => HandleKeyAction::RedeployService, - ServiceConfirmationState::Restart { .. } => HandleKeyAction::RestartService, - ServiceConfirmationState::Rollback { .. } => HandleKeyAction::RollbackDeployment, - }, + KeyCode::Enter => { + HandleKeyAction::RunServiceAction(ServiceAction::from_confirmation(&confirmation)) + } _ => HandleKeyAction::None, }; } @@ -347,15 +370,15 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( state.focus_deployments(); HandleKeyAction::None } - KeyCode::Char('r') => { + KeyCode::Char('r') if !state.loading => { state.open_restart_confirmation(); HandleKeyAction::None } - KeyCode::Char('D') => { + KeyCode::Char('D') if !state.loading => { state.open_redeploy_confirmation(); HandleKeyAction::None } - KeyCode::Char('R') => { + KeyCode::Char('R') if !state.loading => { state.open_rollback_confirmation(); HandleKeyAction::None } @@ -524,4 +547,28 @@ mod tests { }) ); } + + #[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.selected_confirmation(), None); + } } From 90d8743c38c18c5bf6fed854ad95943bb34ee76d Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 04:04:39 +0100 Subject: [PATCH 16/21] dash-tui: service level logs --- src/controllers/dash_tui/logs.rs | 84 ++++++++++++++++++++--- src/controllers/dash_tui/mod.rs | 95 +++++++++++++++++++++++++- src/controllers/dash_tui/service.rs | 10 +++ src/controllers/dash_tui/ui/logs.rs | 80 ++++++++++++++++------ src/controllers/dash_tui/ui/mod.rs | 2 +- src/controllers/dash_tui/ui/service.rs | 1 + 6 files changed, 240 insertions(+), 32 deletions(-) diff --git a/src/controllers/dash_tui/logs.rs b/src/controllers/dash_tui/logs.rs index 9daf8762e..63590d4a1 100644 --- a/src/controllers/dash_tui/logs.rs +++ b/src/controllers/dash_tui/logs.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use crossterm::event::{KeyCode, KeyEvent}; -use super::{HandleKeyAction, project::ProjectScreenState}; +use super::{HandleKeyAction, project::ProjectScreenState, service::ServiceScreenState}; use crate::commands::logs::DeployLogTarget; const LOG_BUFFER_LIMIT: usize = 2_000; @@ -12,8 +12,9 @@ const LOG_SCROLL_STEP: usize = 8; 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) return_to_project: Box, + pub(in crate::controllers::dash_tui) return_to: LogsBackNavigation, pub(in crate::controllers::dash_tui) lines: VecDeque, pub(in crate::controllers::dash_tui) loading: bool, pub(in crate::controllers::dash_tui) error: Option, @@ -22,6 +23,12 @@ pub(in crate::controllers::dash_tui) struct LogsScreenState { pub(in crate::controllers::dash_tui) current_request_id: u64, } +#[derive(Clone, Debug)] +pub(in crate::controllers::dash_tui) enum LogsBackNavigation { + Project(Box), + Service(Box), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::controllers::dash_tui) struct LoadedLogs { pub(in crate::controllers::dash_tui) lines: Vec, @@ -47,18 +54,58 @@ impl LogsScreenState { .collect(); targets.sort_by(|a, b| a.service_name.cmp(&b.service_name)); - Some(Self { - project_name: project.name.clone(), - environment_name: project.selected_environment_name.clone(), + Some(Self::new( + project.name.clone(), + project.selected_environment_name.clone(), + None, + targets, + LogsBackNavigation::Project(Box::new(state.clone())), + )) + } + + 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, + LogsBackNavigation::Service(Box::new(state.clone())), + ) + } + + fn new( + project_name: String, + environment_name: String, + service_name: Option, + targets: Vec, + return_to: LogsBackNavigation, + ) -> Self { + Self { + project_name, + environment_name, + service_name, targets, - return_to_project: Box::new(state.clone()), + return_to, 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) { @@ -131,6 +178,10 @@ impl LogsScreenState { 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(); @@ -146,7 +197,7 @@ pub(in crate::controllers::dash_tui) fn handle_logs_screen_key( key: KeyEvent, ) -> HandleKeyAction { match key.code { - KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackToProject, + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackFromLogs, KeyCode::Up | KeyCode::Char('i') => { state.scroll_up(1); HandleKeyAction::None @@ -256,11 +307,28 @@ mod tests { 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"); diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index f76c0b8d4..e14d3142e 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -29,7 +29,7 @@ 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::logs::{LoadedLogs, LogsBackNavigation, LogsScreenState, handle_logs_screen_key}; use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; @@ -135,11 +135,13 @@ enum HandleKeyAction { }, OpenSelectedService, OpenProjectLogs, + OpenServiceLogs, RefreshProjects, RefreshProject, RunServiceAction(ServiceAction), BackToProjects, BackToProject, + BackFromLogs, OpenEnvironmentSelector, } @@ -270,6 +272,18 @@ impl DashApp { 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.screen = DashboardScreen::Logs(logs_screen); + self.start_logs_stream(tx); + } + fn refresh_service_deployments(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Service(state) = &mut self.screen else { return; @@ -461,8 +475,9 @@ impl DashApp { fn back_to_project(&mut self) { let project_state = match &self.screen { DashboardScreen::Service(state) => Some((*state.return_to_project).clone()), - DashboardScreen::Logs(state) => Some((*state.return_to_project).clone()), - DashboardScreen::Projects(_) | DashboardScreen::Project(_) => None, + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Logs(_) => None, }; if let Some(project_state) = project_state { @@ -471,6 +486,27 @@ impl DashApp { } } + fn back_from_logs(&mut self) { + let previous_screen = match &self.screen { + DashboardScreen::Logs(state) => match &state.return_to { + LogsBackNavigation::Project(project_state) => { + Some(DashboardScreen::Project((**project_state).clone())) + } + LogsBackNavigation::Service(service_state) => { + Some(DashboardScreen::Service((**service_state).clone())) + } + }, + DashboardScreen::Projects(_) + | DashboardScreen::Project(_) + | DashboardScreen::Service(_) => None, + }; + + if let Some(previous_screen) = previous_screen { + self.stop_logs_stream(); + self.screen = previous_screen; + } + } + fn back_to_projects(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Project(state) = &self.screen else { return; @@ -658,11 +694,13 @@ impl DashApp { } => self.open_project(project_id, Some(return_to_projects), tx), HandleKeyAction::OpenSelectedService => self.open_selected_service(tx), HandleKeyAction::OpenProjectLogs => self.open_project_logs(tx), + HandleKeyAction::OpenServiceLogs => self.open_service_logs(tx), HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), HandleKeyAction::RunServiceAction(action) => self.start_service_action(action, tx), HandleKeyAction::BackToProjects => self.back_to_projects(tx), HandleKeyAction::BackToProject => self.back_to_project(), + HandleKeyAction::BackFromLogs => self.back_from_logs(), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), } @@ -1136,4 +1174,55 @@ mod tests { } } } + + #[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()), + }, + None, + ); + 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()), + spinner_tick: 0, + log_stream_task: None, + }; + + 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"), + } + + app.back_from_logs(); + + 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/service.rs b/src/controllers/dash_tui/service.rs index fa4fc25d7..a3d6d8a39 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -382,6 +382,7 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( state.open_rollback_confirmation(); HandleKeyAction::None } + KeyCode::Char('L') => HandleKeyAction::OpenServiceLogs, _ => HandleKeyAction::None, } } @@ -571,4 +572,13 @@ mod tests { assert!(matches!(action, HandleKeyAction::None)); assert_eq!(state.selected_confirmation(), 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)); + } } diff --git a/src/controllers/dash_tui/ui/logs.rs b/src/controllers/dash_tui/ui/logs.rs index 5e1864813..09a9cbf02 100644 --- a/src/controllers/dash_tui/ui/logs.rs +++ b/src/controllers/dash_tui/ui/logs.rs @@ -23,6 +23,11 @@ fn render_logs_status(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState 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)), @@ -31,7 +36,14 @@ fn render_logs_status(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState } else if state.loading && state.lines.is_empty() { Line::from(vec![ Span::styled("⠿ ", loading_style()), - Span::styled("Loading environment logs...", hero_style()), + Span::styled( + if state.is_service_scoped() { + "Loading service logs..." + } else { + "Loading environment logs..." + }, + hero_style(), + ), ]) } else if state.paused { Line::from(vec![ @@ -41,32 +53,51 @@ fn render_logs_status(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState } else { Line::from(vec![ Span::styled("streaming", hero_style()), - Span::raw(" • following latest log lines across the environment"), + 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("environment logs", hero_style()), + Span::styled(scope, hero_style()), Span::raw(" • "), Span::styled("services: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(services), ]), - Line::from(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()), - Span::raw(" • "), - Span::styled("buffer: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(state.lines.len().to_string(), muted_style()), - ]), + Line::from(detail_spans), activity_line, ]) .block(panel_block("logs")) @@ -76,15 +107,22 @@ fn render_logs_status(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState } fn render_logs_output(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState) { - let block = panel_block("environment deploy logs").border_style(selected_border_style()); + 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( - "Loading recent logs from the latest deployments in this environment...", - ) + 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, @@ -95,6 +133,8 @@ fn render_logs_output(frame: &mut Frame<'_>, area: Rect, state: &LogsScreenState 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." }; diff --git a/src/controllers/dash_tui/ui/mod.rs b/src/controllers/dash_tui/ui/mod.rs index 29c419fa5..9edc4e986 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -92,7 +92,7 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } }, DashboardScreen::Service(_) => { - "d/right/l focus deployments • Tab switch panes • arrows/ik scroll • r restart service • D redeploy selected • R rollback selected • Esc back • q quit" + "L logs • d/right/l focus deployments • Tab switch panes • arrows/ik scroll • r restart service • D redeploy selected • R rollback selected • Esc back • q quit" } DashboardScreen::Logs(_) => { "Esc back • p pause/resume • g/G top/bottom • arrows/ik scroll • q quit" diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index d739cda03..6cea2f102 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -260,6 +260,7 @@ fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceSc Line::from(volumes), Line::default(), Line::from(Span::styled("actions", panel_title_style())), + Line::from(Span::styled("L open filtered logs", muted_style())), Line::from(Span::styled("d focus deployments", muted_style())), Line::from(Span::styled("r restart current service", muted_style())), Line::from(Span::styled( From 56db4d3bbd3bdc69e59b4172daeee8482ba8db69 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 15:38:44 +0100 Subject: [PATCH 17/21] dash-tui: show deployment manager --- src/controllers/dash_tui/mod.rs | 156 ++++++++++++++++++++++--- src/controllers/dash_tui/service.rs | 105 ++++++++++++++++- src/controllers/dash_tui/ui/mod.rs | 2 +- src/controllers/dash_tui/ui/service.rs | 122 ++++++++++++++++++- 4 files changed, 365 insertions(+), 20 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index e14d3142e..41ce603fd 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -8,7 +8,7 @@ use std::io::stdout; use std::panic; use std::time::Duration; -use anyhow::Result; +use anyhow::{Result, anyhow}; use crossterm::cursor::{Hide, Show}; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind, @@ -34,13 +34,19 @@ use self::project::{ EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, }; use self::service::{ - ServiceAction, ServiceScreenState, handle_service_screen_key, load_service_deployments, - run_service_action, + ServiceAction, ServiceDetail, ServiceScreenState, 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}, + commands::{Configs, metrics::Sections}, + controllers::{ + db_stats, + metrics::get_volume_metrics, + project::{find_service_instance, get_environment_instances}, + }, + resources::is_database_service, }; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -136,6 +142,7 @@ enum HandleKeyAction { OpenSelectedService, OpenProjectLogs, OpenServiceLogs, + OpenServiceMetrics, RefreshProjects, RefreshProject, RunServiceAction(ServiceAction), @@ -284,6 +291,37 @@ impl DashApp { 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_service_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; @@ -392,6 +430,7 @@ impl DashApp { } state.confirmation = None; + state.close_deployment_dialog(); state.loading = true; state.error = None; state.clear_toast(); @@ -626,37 +665,39 @@ impl DashApp { } } - fn handle_event( + async fn handle_event( &mut self, event: Event, terminal: &mut Terminal>, tx: &mpsc::UnboundedSender, - ) -> bool { + ) -> 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), tx) + self.handle_key(key, Rect::new(0, 0, size.width, size.height), terminal, tx) + .await } Event::Resize(_, _) => { let _ = terminal.clear(); - false + Ok(false) } - _ => false, + _ => Ok(false), } } - fn handle_key( + async fn handle_key( &mut self, key: KeyEvent, terminal_area: Rect, + terminal: &mut Terminal>, tx: &mpsc::UnboundedSender, - ) -> bool { + ) -> Result { if matches!(key.code, KeyCode::Char('q')) || (matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL)) { self.stop_logs_stream(); - return true; + return Ok(true); } if matches!( @@ -668,13 +709,13 @@ impl DashApp { ) && let DashboardScreen::Projects(state) = &mut self.screen { handle_projects_filter_input(state, key); - return false; + return Ok(false); } if let DashboardScreen::Project(state) = &mut self.screen && state.environment_selector.is_some() { - return self.handle_environment_selector_key(key, tx); + return Ok(self.handle_environment_selector_key(key, tx)); } let action = match &mut self.screen { @@ -695,6 +736,7 @@ impl DashApp { 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), @@ -704,7 +746,7 @@ impl DashApp { HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), } - false + Ok(false) } fn handle_environment_selector_key( @@ -847,6 +889,88 @@ impl ProjectsScreenState { } } +async fn build_service_metrics_tui_params( + detail: &ServiceDetail, +) -> Result { + 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 is_db = is_database_service(source_image); + let db_type = detect_database_type(source_image); + let sections = Sections { + cpu: true, + memory: true, + network: true, + volume: true, + http: true, + has_explicit_filter: false, + }; + + if !detail.service.active_in_environment { + return Err(anyhow!( + "Service `{}` is not active in environment `{}`.", + detail.service.name, + detail.environment_name + )); + } + + 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.clone(), + is_db, + 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: if db_type.is_some() { + service_instance.map(|instance| instance.id.clone()) + } else { + None + }, + db_stats_preflight_error: if db_type.is_some() { + db_stats::preflight_db_stats_ssh().err() + } else { + None + }, + }) +} + +fn detect_database_type( + source_image: Option<&str>, +) -> Option { + let img = source_image?.to_ascii_lowercase(); + if img.contains("postgres") || img.contains("postgis") || img.contains("timescale") { + Some(crate::controllers::database::DatabaseType::PostgreSQL) + } else if img.contains("redis") || img.contains("valkey") { + Some(crate::controllers::database::DatabaseType::Redis) + } else if img.contains("mongo") { + Some(crate::controllers::database::DatabaseType::MongoDB) + } else if img.contains("mysql") || img.contains("mariadb") { + Some(crate::controllers::database::DatabaseType::MySQL) + } else { + None + } +} + fn setup_terminal() -> Result>> { enable_raw_mode()?; @@ -894,7 +1018,7 @@ pub async fn run(params: DashTuiParams) -> Result<()> { tokio::select! { Some(Ok(event)) = events.next() => { - if app.handle_event(event, &mut terminal, &loader_tx) { + if app.handle_event(event, &mut terminal, &loader_tx).await? { break; } } diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index a3d6d8a39..72d89ec95 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -27,6 +27,7 @@ pub(in crate::controllers::dash_tui) struct ServiceScreenState { 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, } @@ -109,6 +110,7 @@ impl ServiceScreenState { loading: false, error: None, current_request_id: 0, + deployment_dialog: None, confirmation: None, toast: None, }) @@ -127,6 +129,7 @@ impl ServiceScreenState { 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; @@ -135,6 +138,15 @@ impl ServiceScreenState { 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) { @@ -163,6 +175,13 @@ impl ServiceScreenState { 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); } @@ -199,7 +218,7 @@ impl ServiceScreenState { } pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { - match self.selected_deployment() { + match self.active_deployment() { Some(deployment) => { self.confirmation = Some(ServiceConfirmationState::Redeploy { deployment_id: deployment.id.clone(), @@ -221,7 +240,7 @@ impl ServiceScreenState { } pub(in crate::controllers::dash_tui) fn open_rollback_confirmation(&mut self) { - match self.selected_deployment() { + match self.active_deployment() { Some(deployment) => { self.confirmation = Some(ServiceConfirmationState::Rollback { deployment_id: deployment.id.clone(), @@ -232,6 +251,20 @@ impl ServiceScreenState { } } + 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, @@ -260,6 +293,11 @@ impl ServiceScreenState { ) -> Option<&ServiceConfirmationState> { self.confirmation.as_ref() } + + 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( @@ -340,6 +378,24 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( }; } + 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::BackToProject, KeyCode::Up | KeyCode::Char('i') if matches!(state.focus, ServiceFocus::Deployments) => { @@ -370,6 +426,10 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( 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 @@ -383,6 +443,7 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( HandleKeyAction::None } KeyCode::Char('L') => HandleKeyAction::OpenServiceLogs, + KeyCode::Char('m') => HandleKeyAction::OpenServiceMetrics, _ => HandleKeyAction::None, } } @@ -469,6 +530,7 @@ mod tests { loading: false, error: None, current_request_id: 0, + deployment_dialog: None, confirmation: None, toast: None, } @@ -517,6 +579,36 @@ mod tests { ); } + #[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.selected_confirmation(), + Some(&ServiceConfirmationState::Rollback { + deployment_id: "dep_122".to_string() + }) + ); + } + #[test] fn lowercase_r_opens_restart_confirmation_for_latest_deployment() { let mut state = service_state(); @@ -581,4 +673,13 @@ mod tests { 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/mod.rs b/src/controllers/dash_tui/ui/mod.rs index 9edc4e986..a90ce44ff 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -92,7 +92,7 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &DashApp) { } }, DashboardScreen::Service(_) => { - "L logs • d/right/l focus deployments • Tab switch panes • arrows/ik scroll • r restart service • D redeploy selected • R rollback selected • Esc back • q quit" + "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" diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index 6cea2f102..9dd15684e 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -4,6 +4,7 @@ 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, @@ -33,6 +34,10 @@ pub(super) fn render_service_screen(frame: &mut Frame<'_>, area: Rect, state: &S 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); } @@ -260,8 +265,13 @@ fn render_service_resources(frame: &mut Frame<'_>, area: Rect, state: &ServiceSc 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", @@ -329,7 +339,7 @@ fn render_service_deployments(frame: &mut Frame<'_>, area: Rect, state: &Service let mut lines = vec![Line::from(vec![Span::styled( if is_focused { - "deployments focused" + "deployments focused • Enter view details" } else { "press d to focus" }, @@ -427,6 +437,116 @@ 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); From 04a26ac9a7bd3c73cb3e3c6b10400a1567ceebc3 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 15:48:34 +0100 Subject: [PATCH 18/21] dash-tui: simplify the screen state management --- src/controllers/dash_tui/logs.rs | 24 +--- src/controllers/dash_tui/mod.rs | 201 ++++++++++------------------ src/controllers/dash_tui/project.rs | 19 +-- src/controllers/dash_tui/service.rs | 16 +-- 4 files changed, 79 insertions(+), 181 deletions(-) diff --git a/src/controllers/dash_tui/logs.rs b/src/controllers/dash_tui/logs.rs index 63590d4a1..351cd7528 100644 --- a/src/controllers/dash_tui/logs.rs +++ b/src/controllers/dash_tui/logs.rs @@ -14,7 +14,6 @@ pub(in crate::controllers::dash_tui) struct LogsScreenState { 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) return_to: LogsBackNavigation, pub(in crate::controllers::dash_tui) lines: VecDeque, pub(in crate::controllers::dash_tui) loading: bool, pub(in crate::controllers::dash_tui) error: Option, @@ -23,12 +22,6 @@ pub(in crate::controllers::dash_tui) struct LogsScreenState { pub(in crate::controllers::dash_tui) current_request_id: u64, } -#[derive(Clone, Debug)] -pub(in crate::controllers::dash_tui) enum LogsBackNavigation { - Project(Box), - Service(Box), -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::controllers::dash_tui) struct LoadedLogs { pub(in crate::controllers::dash_tui) lines: Vec, @@ -59,7 +52,6 @@ impl LogsScreenState { project.selected_environment_name.clone(), None, targets, - LogsBackNavigation::Project(Box::new(state.clone())), )) } @@ -82,7 +74,6 @@ impl LogsScreenState { state.detail.environment_name.clone(), Some(state.detail.service.name.clone()), targets, - LogsBackNavigation::Service(Box::new(state.clone())), ) } @@ -91,14 +82,12 @@ impl LogsScreenState { environment_name: String, service_name: Option, targets: Vec, - return_to: LogsBackNavigation, ) -> Self { Self { project_name, environment_name, service_name, targets, - return_to, lines: VecDeque::new(), loading: false, error: None, @@ -197,7 +186,7 @@ pub(in crate::controllers::dash_tui) fn handle_logs_screen_key( key: KeyEvent, ) -> HandleKeyAction { match key.code { - KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackFromLogs, + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::Back, KeyCode::Up | KeyCode::Char('i') => { state.scroll_up(1); HandleKeyAction::None @@ -241,13 +230,10 @@ mod tests { }; fn project_state() -> ProjectScreenState { - let mut state = ProjectScreenState::new( - ProjectLoadTarget { - project_id: "proj_123".to_string(), - environment_hint: Some("production".to_string()), - }, - None, - ); + 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(), diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 41ce603fd..e1393d45c 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -29,10 +29,8 @@ use tokio::time::MissedTickBehavior; use self::data::{ DashboardProject, ProjectCard, ProjectLoadTarget, load_dashboard_project, load_project_cards, }; -use self::logs::{LoadedLogs, LogsBackNavigation, LogsScreenState, handle_logs_screen_key}; -use self::project::{ - EnvironmentSelectorState, ProjectScreenState, ProjectsBackNavigation, handle_project_screen_key, -}; +use self::logs::{LoadedLogs, LogsScreenState, handle_logs_screen_key}; +use self::project::{EnvironmentSelectorState, ProjectScreenState, handle_project_screen_key}; use self::service::{ ServiceAction, ServiceDetail, ServiceScreenState, handle_service_screen_key, load_service_deployments, run_service_action, @@ -78,6 +76,7 @@ pub struct DashTuiParams { struct DashApp { params: DashTuiParams, screen: DashboardScreen, + history: Vec, spinner_tick: usize, log_stream_task: Option>, } @@ -135,10 +134,7 @@ enum LoaderEvent { enum HandleKeyAction { None, - OpenProject { - project_id: String, - return_to_projects: ProjectsBackNavigation, - }, + OpenProject { project_id: String }, OpenSelectedService, OpenProjectLogs, OpenServiceLogs, @@ -146,9 +142,7 @@ enum HandleKeyAction { RefreshProjects, RefreshProject, RunServiceAction(ServiceAction), - BackToProjects, - BackToProject, - BackFromLogs, + Back, OpenEnvironmentSelector, } @@ -156,32 +150,27 @@ 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 { + Some(project_id) => { + DashboardScreen::Project(ProjectScreenState::new(ProjectLoadTarget { project_id: project_id.clone(), environment_hint: params.environment.clone(), - }, - Some(ProjectsBackNavigation::Reload { - initial_selection_hint: Some(project_id.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()), - }, - None, - )), + } => 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, } @@ -235,20 +224,19 @@ impl DashApp { }); } - fn open_project( - &mut self, - project_id: String, - return_to_projects: Option, - tx: &mpsc::UnboundedSender, - ) { + 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.screen = DashboardScreen::Project(ProjectScreenState::new( + self.push_screen(DashboardScreen::Project(ProjectScreenState::new( ProjectLoadTarget { project_id, environment_hint, }, - return_to_projects, - )); + ))); self.refresh_project(tx); } @@ -261,7 +249,7 @@ impl DashApp { return; }; - self.screen = DashboardScreen::Service(service_screen); + self.push_screen(DashboardScreen::Service(service_screen)); self.refresh_service_deployments(tx); } @@ -275,7 +263,7 @@ impl DashApp { }; self.stop_logs_stream(); - self.screen = DashboardScreen::Logs(logs_screen); + self.push_screen(DashboardScreen::Logs(logs_screen)); self.start_logs_stream(tx); } @@ -287,7 +275,7 @@ impl DashApp { let logs_screen = LogsScreenState::from_service(state); self.stop_logs_stream(); - self.screen = DashboardScreen::Logs(logs_screen); + self.push_screen(DashboardScreen::Logs(logs_screen)); self.start_logs_stream(tx); } @@ -511,69 +499,25 @@ impl DashApp { self.refresh_project(tx); } - fn back_to_project(&mut self) { - let project_state = match &self.screen { - DashboardScreen::Service(state) => Some((*state.return_to_project).clone()), - DashboardScreen::Projects(_) - | DashboardScreen::Project(_) - | DashboardScreen::Logs(_) => None, - }; - - if let Some(project_state) = project_state { + fn back(&mut self, tx: &mpsc::UnboundedSender) { + if let Some(screen) = self.history.pop() { self.stop_logs_stream(); - self.screen = DashboardScreen::Project(project_state); - } - } - - fn back_from_logs(&mut self) { - let previous_screen = match &self.screen { - DashboardScreen::Logs(state) => match &state.return_to { - LogsBackNavigation::Project(project_state) => { - Some(DashboardScreen::Project((**project_state).clone())) - } - LogsBackNavigation::Service(service_state) => { - Some(DashboardScreen::Service((**service_state).clone())) - } - }, - DashboardScreen::Projects(_) - | DashboardScreen::Project(_) - | DashboardScreen::Service(_) => None, - }; - - if let Some(previous_screen) = previous_screen { - self.stop_logs_stream(); - self.screen = previous_screen; + self.screen = screen; + return; } - } - fn back_to_projects(&mut self, tx: &mpsc::UnboundedSender) { let DashboardScreen::Project(state) = &self.screen else { return; }; + let project_id = state.target.project_id.clone(); - let navigation = state - .return_to_projects - .clone() - .or_else(|| match self.params.auth_mode { - DashboardAuthMode::Workspace => Some(ProjectsBackNavigation::Reload { - initial_selection_hint: Some(state.target.project_id.clone()), - }), - DashboardAuthMode::LinkedProject { .. } => None, - }); - - match navigation { - Some(ProjectsBackNavigation::Restore(restored)) => { - self.screen = DashboardScreen::Projects(*restored); - } - Some(ProjectsBackNavigation::Reload { - initial_selection_hint, - }) => { - self.screen = - DashboardScreen::Projects(ProjectsScreenState::new(initial_selection_hint)); - self.refresh_projects(tx); - } - None => {} + 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) { @@ -729,10 +673,7 @@ impl DashApp { match action { HandleKeyAction::None => {} - HandleKeyAction::OpenProject { - project_id, - return_to_projects, - } => self.open_project(project_id, Some(return_to_projects), tx), + 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), @@ -740,9 +681,7 @@ impl DashApp { HandleKeyAction::RefreshProjects => self.refresh_projects(tx), HandleKeyAction::RefreshProject => self.refresh_project(tx), HandleKeyAction::RunServiceAction(action) => self.start_service_action(action, tx), - HandleKeyAction::BackToProjects => self.back_to_projects(tx), - HandleKeyAction::BackToProject => self.back_to_project(), - HandleKeyAction::BackFromLogs => self.back_from_logs(), + HandleKeyAction::Back => self.back(tx), HandleKeyAction::OpenEnvironmentSelector => self.open_environment_selector(), } @@ -1053,10 +992,9 @@ fn handle_projects_screen_key( 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().cloned() { + if let Some(card) = state.selected_card() { return HandleKeyAction::OpenProject { - project_id: card.id, - return_to_projects: ProjectsBackNavigation::Restore(Box::new(state.clone())), + project_id: card.id.clone(), }; } } @@ -1175,13 +1113,10 @@ mod tests { #[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()), - }, - None, - ); + 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; @@ -1209,19 +1144,17 @@ mod tests { environment: None, auth_mode: DashboardAuthMode::Workspace, }, - screen: DashboardScreen::Project(ProjectScreenState::new( - ProjectLoadTarget { - project_id: "proj_two".to_string(), - environment_hint: Some("production".to_string()), - }, - Some(ProjectsBackNavigation::Restore(Box::new(restored.clone()))), - )), + 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_to_projects(&tx); + app.back(&tx); match app.screen { DashboardScreen::Projects(state) => { @@ -1243,13 +1176,10 @@ mod tests { #[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()), - }, - None, - ); + 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; @@ -1260,10 +1190,13 @@ mod tests { 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"), @@ -1282,7 +1215,8 @@ mod tests { } } - app.back_to_project(); + let (tx, _rx) = mpsc::unbounded_channel(); + app.back(&tx); match app.screen { DashboardScreen::Project(state) => { @@ -1301,13 +1235,10 @@ mod tests { #[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()), - }, - None, - ); + 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; @@ -1321,10 +1252,13 @@ mod tests { 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 { @@ -1337,7 +1271,8 @@ mod tests { | DashboardScreen::Service(_) => panic!("expected logs screen"), } - app.back_from_logs(); + let (tx, _rx) = mpsc::unbounded_channel(); + app.back(&tx); match app.screen { DashboardScreen::Service(state) => { diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs index cc6a09ed0..a15e87f66 100644 --- a/src/controllers/dash_tui/project.rs +++ b/src/controllers/dash_tui/project.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; -use super::{HandleKeyAction, ProjectsScreenState, ui}; +use super::{HandleKeyAction, ui}; use crate::controllers::dash_tui::data::{DashboardProject, DashboardService, ProjectLoadTarget}; #[derive(Clone, Debug)] @@ -12,28 +12,16 @@ pub(in crate::controllers::dash_tui) struct ProjectScreenState { 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) return_to_projects: Option, pub(in crate::controllers::dash_tui) environment_selector: Option, } -#[derive(Clone, Debug)] -pub(in crate::controllers::dash_tui) enum ProjectsBackNavigation { - Restore(Box), - Reload { - initial_selection_hint: 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, - return_to_projects: Option, - ) -> Self { + pub(in crate::controllers::dash_tui) fn new(target: ProjectLoadTarget) -> Self { Self { target, project: None, @@ -41,7 +29,6 @@ impl ProjectScreenState { loading: false, error: None, current_request_id: 0, - return_to_projects, environment_selector: None, } } @@ -144,7 +131,7 @@ pub(in crate::controllers::dash_tui) fn handle_project_screen_key( let columns = ui::service_grid_columns(ui::panel_block("services").inner(diagram_area).width); match key.code { - KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::BackToProjects, + 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(), diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 72d89ec95..13d7fa9a3 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -4,7 +4,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::{ HandleKeyAction, data::{DashboardProject, DashboardService}, - project::ProjectScreenState, }; use crate::{ client::GQLClient, @@ -20,7 +19,6 @@ 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) return_to_project: Box, 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, @@ -96,14 +94,13 @@ impl ServiceAction { impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn from_project( - state: &ProjectScreenState, + 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), - return_to_project: Box::new(state.clone()), deployments: Vec::new(), selected_deployment: 0, focus: ServiceFocus::Overview, @@ -397,7 +394,7 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( } match key.code { - KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::BackToProject, + KeyCode::Esc | KeyCode::Backspace => HandleKeyAction::Back, KeyCode::Up | KeyCode::Char('i') if matches!(state.focus, ServiceFocus::Deployments) => { state.move_deployment_up(); HandleKeyAction::None @@ -467,7 +464,7 @@ mod tests { use super::*; use crate::{ commands::queries::deployments::DeploymentStatus, - controllers::dash_tui::data::{DeploymentSummary, ProjectLoadTarget}, + controllers::dash_tui::data::DeploymentSummary, }; use chrono::Utc; @@ -504,13 +501,6 @@ mod tests { fn service_state() -> ServiceScreenState { ServiceScreenState { detail: detail(true), - return_to_project: Box::new(ProjectScreenState::new( - ProjectLoadTarget { - project_id: "proj_123".to_string(), - environment_hint: Some("production".to_string()), - }, - None, - )), deployments: vec![ ServiceDeployment { id: "dep_123".to_string(), From 408b0832886ad24fa825320e47a16347fe92d5b5 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 15:54:04 +0100 Subject: [PATCH 19/21] dash-tui: de-duplicate state actions --- src/controllers/dash_tui/mod.rs | 9 +--- src/controllers/dash_tui/service.rs | 67 ++++++++------------------ src/controllers/dash_tui/ui/service.rs | 16 ++---- 3 files changed, 25 insertions(+), 67 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index e1393d45c..ccaf734c9 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -406,14 +406,7 @@ impl DashApp { return; }; - let Some(confirmed_action) = state - .selected_confirmation() - .map(ServiceAction::from_confirmation) - else { - return; - }; - - if state.loading || confirmed_action != action { + if state.loading || state.confirmation.as_ref() != Some(&action) { return; } diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 13d7fa9a3..3534cd140 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -26,7 +26,7 @@ pub(in crate::controllers::dash_tui) struct ServiceScreenState { 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) confirmation: Option, pub(in crate::controllers::dash_tui) toast: Option, } @@ -46,17 +46,10 @@ pub(in crate::controllers::dash_tui) enum ServiceFocus { Deployments, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(in crate::controllers::dash_tui) enum ServiceConfirmationState { - Redeploy { deployment_id: String }, - Restart { deployment_id: String }, - Rollback { deployment_id: String }, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::controllers::dash_tui) enum ServiceAction { Redeploy { deployment_id: String }, - Restart, + Restart { deployment_id: String }, Rollback { deployment_id: String }, } @@ -67,24 +60,10 @@ pub(in crate::controllers::dash_tui) struct ServiceToast { } impl ServiceAction { - pub(in crate::controllers::dash_tui) fn from_confirmation( - confirmation: &ServiceConfirmationState, - ) -> Self { - match confirmation { - ServiceConfirmationState::Redeploy { deployment_id } => Self::Redeploy { - deployment_id: deployment_id.clone(), - }, - ServiceConfirmationState::Restart { .. } => Self::Restart, - ServiceConfirmationState::Rollback { deployment_id } => Self::Rollback { - deployment_id: deployment_id.clone(), - }, - } - } - 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::Restart { .. } => format!("Restart triggered for deployment {deployment_id}"), Self::Rollback { .. } => { format!("Rollback triggered to deployment {deployment_id}") } @@ -217,7 +196,7 @@ impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn open_redeploy_confirmation(&mut self) { match self.active_deployment() { Some(deployment) => { - self.confirmation = Some(ServiceConfirmationState::Redeploy { + self.confirmation = Some(ServiceAction::Redeploy { deployment_id: deployment.id.clone(), }); self.clear_toast(); @@ -229,7 +208,7 @@ impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn open_restart_confirmation(&mut self) { match self.latest_restartable_deployment_id() { Ok(deployment_id) => { - self.confirmation = Some(ServiceConfirmationState::Restart { deployment_id }); + self.confirmation = Some(ServiceAction::Restart { deployment_id }); self.clear_toast(); } Err(error) => self.set_toast(error.to_string(), true), @@ -239,7 +218,7 @@ impl ServiceScreenState { pub(in crate::controllers::dash_tui) fn open_rollback_confirmation(&mut self) { match self.active_deployment() { Some(deployment) => { - self.confirmation = Some(ServiceConfirmationState::Rollback { + self.confirmation = Some(ServiceAction::Rollback { deployment_id: deployment.id.clone(), }); self.clear_toast(); @@ -285,12 +264,6 @@ impl ServiceScreenState { } } - pub(in crate::controllers::dash_tui) fn selected_confirmation( - &self, - ) -> Option<&ServiceConfirmationState> { - self.confirmation.as_ref() - } - fn active_deployment(&self) -> Option<&ServiceDeployment> { self.dialog_deployment() .or_else(|| self.selected_deployment()) @@ -342,7 +315,7 @@ pub(in crate::controllers::dash_tui) async fn run_service_action( ServiceAction::Redeploy { deployment_id } => { redeploy_deployment(&client, &configs.get_backboard(), deployment_id).await } - ServiceAction::Restart => { + ServiceAction::Restart { .. } => { restart_latest_service_deployment( &client, &configs, @@ -362,15 +335,13 @@ pub(in crate::controllers::dash_tui) fn handle_service_screen_key( state: &mut ServiceScreenState, key: KeyEvent, ) -> HandleKeyAction { - if let Some(confirmation) = state.selected_confirmation().cloned() { + if let Some(confirmation) = state.confirmation.clone() { return match key.code { KeyCode::Esc | KeyCode::Backspace => { state.confirmation = None; HandleKeyAction::None } - KeyCode::Enter => { - HandleKeyAction::RunServiceAction(ServiceAction::from_confirmation(&confirmation)) - } + KeyCode::Enter => HandleKeyAction::RunServiceAction(confirmation), _ => HandleKeyAction::None, }; } @@ -562,8 +533,8 @@ mod tests { assert!(matches!(action, HandleKeyAction::None)); assert_eq!( - state.selected_confirmation(), - Some(&ServiceConfirmationState::Redeploy { + state.confirmation.as_ref(), + Some(&ServiceAction::Redeploy { deployment_id: "dep_122".to_string() }) ); @@ -592,8 +563,8 @@ mod tests { assert!(matches!(action, HandleKeyAction::None)); assert_eq!( - state.selected_confirmation(), - Some(&ServiceConfirmationState::Rollback { + state.confirmation.as_ref(), + Some(&ServiceAction::Rollback { deployment_id: "dep_122".to_string() }) ); @@ -607,8 +578,8 @@ mod tests { assert!(matches!(action, HandleKeyAction::None)); assert_eq!( - state.selected_confirmation(), - Some(&ServiceConfirmationState::Restart { + state.confirmation.as_ref(), + Some(&ServiceAction::Restart { deployment_id: "dep_123".to_string() }) ); @@ -624,8 +595,8 @@ mod tests { assert!(matches!(action, HandleKeyAction::None)); assert_eq!( - state.selected_confirmation(), - Some(&ServiceConfirmationState::Rollback { + state.confirmation.as_ref(), + Some(&ServiceAction::Rollback { deployment_id: "dep_122".to_string() }) ); @@ -640,7 +611,7 @@ mod tests { assert!(matches!( action, - HandleKeyAction::RunServiceAction(ServiceAction::Restart) + HandleKeyAction::RunServiceAction(ServiceAction::Restart { .. }) )); } @@ -652,7 +623,7 @@ mod tests { let action = handle_service_screen_key(&mut state, KeyEvent::from(KeyCode::Char('r'))); assert!(matches!(action, HandleKeyAction::None)); - assert_eq!(state.selected_confirmation(), None); + assert_eq!(state.confirmation.as_ref(), None); } #[test] diff --git a/src/controllers/dash_tui/ui/service.rs b/src/controllers/dash_tui/ui/service.rs index 9dd15684e..18d7df702 100644 --- a/src/controllers/dash_tui/ui/service.rs +++ b/src/controllers/dash_tui/ui/service.rs @@ -13,7 +13,7 @@ use super::{ use crate::{ commands::queries::deployments::DeploymentStatus, controllers::{ - dash_tui::service::{ServiceConfirmationState, ServiceFocus, ServiceScreenState}, + dash_tui::service::{ServiceAction, ServiceFocus, ServiceScreenState}, deployment::ServiceDeployment, }, }; @@ -550,16 +550,10 @@ fn truncate_multiline(text: &str, max_lines: usize, max_chars: usize) -> String fn render_service_confirmation(frame: &mut Frame<'_>, area: Rect, state: &ServiceScreenState) { frame.render_widget(Clear, area); - let (title, deployment_id) = match state.selected_confirmation() { - Some(ServiceConfirmationState::Redeploy { deployment_id }) => { - ("Confirm redeploy", deployment_id.as_str()) - } - Some(ServiceConfirmationState::Restart { deployment_id }) => { - ("Confirm restart", deployment_id.as_str()) - } - Some(ServiceConfirmationState::Rollback { deployment_id }) => { - ("Confirm rollback", deployment_id.as_str()) - } + 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, }; From 66a7f43ffe63488df375b3d51a13d1140d80f9c4 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 15:58:30 +0100 Subject: [PATCH 20/21] dash-tui: simplify the metrics calculation of ui --- src/controllers/dash_tui/mod.rs | 10 ++--- src/controllers/dash_tui/project.rs | 2 +- src/controllers/dash_tui/ui/mod.rs | 52 ++++++++++++------------- src/controllers/dash_tui/ui/project.rs | 6 +-- src/controllers/dash_tui/ui/projects.rs | 7 +--- 5 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index ccaf734c9..4020e4d60 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -977,7 +977,7 @@ fn handle_projects_screen_key( ) -> HandleKeyAction { let [_, body, _] = ui::dashboard_sections(terminal_area); let [_, grid_area] = ui::screen_sections(body); - let columns = ui::project_grid_columns(ui::panel_block("cards").inner(grid_area).width); + 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), @@ -1094,14 +1094,14 @@ mod tests { #[test] fn project_grid_has_at_least_one_column() { - assert_eq!(ui::project_grid_columns(10), 1); - assert!(ui::project_grid_columns(120) >= 1); + 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_columns(10), 1); - assert!(ui::service_grid_columns(120) >= 1); + 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] diff --git a/src/controllers/dash_tui/project.rs b/src/controllers/dash_tui/project.rs index a15e87f66..7c311b5b8 100644 --- a/src/controllers/dash_tui/project.rs +++ b/src/controllers/dash_tui/project.rs @@ -128,7 +128,7 @@ pub(in crate::controllers::dash_tui) fn handle_project_screen_key( 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_columns(ui::panel_block("services").inner(diagram_area).width); + let (columns, _, _) = ui::service_grid_metrics(ui::panel_block("services").inner(diagram_area)); match key.code { KeyCode::Esc | KeyCode::Backspace => return HandleKeyAction::Back, diff --git a/src/controllers/dash_tui/ui/mod.rs b/src/controllers/dash_tui/ui/mod.rs index a90ce44ff..1e18b8cf7 100644 --- a/src/controllers/dash_tui/ui/mod.rs +++ b/src/controllers/dash_tui/ui/mod.rs @@ -40,14 +40,32 @@ pub(super) fn render(frame: &mut Frame<'_>, app: &DashApp) { render_footer(frame, footer, app); } -pub(super) fn project_grid_columns(width: u16) -> usize { - let stride = PROJECT_CARD_MIN_WIDTH + PROJECT_CARD_GAP; - ((width + PROJECT_CARD_GAP) / stride).max(1) as usize +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_columns(width: u16) -> usize { - let stride = SERVICE_CARD_MIN_WIDTH + SERVICE_CARD_GAP; - ((width + SERVICE_CARD_GAP) / stride).max(1) as usize +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) { @@ -192,28 +210,6 @@ pub(super) fn render_centered_message( ); } -pub(super) fn project_card_width(width: u16, columns: usize) -> u16 { - let columns = columns.max(1) as u16; - let total_gaps = PROJECT_CARD_GAP.saturating_mul(columns.saturating_sub(1)); - width.saturating_sub(total_gaps) / columns -} - -pub(super) fn project_rows_per_page(height: u16) -> usize { - let stride = PROJECT_CARD_HEIGHT + PROJECT_CARD_GAP; - ((height + PROJECT_CARD_GAP) / stride).max(1) as usize -} - -pub(super) fn service_card_width(width: u16, columns: usize) -> u16 { - let columns = columns.max(1) as u16; - let total_gaps = SERVICE_CARD_GAP.saturating_mul(columns.saturating_sub(1)); - width.saturating_sub(total_gaps) / columns -} - -pub(super) fn service_rows_per_page(height: u16) -> usize { - let stride = SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP; - ((height + SERVICE_CARD_GAP) / stride).max(1) as usize -} - pub(super) fn pluralize(count: usize, singular: &str) -> String { if count == 1 { singular.to_string() diff --git a/src/controllers/dash_tui/ui/project.rs b/src/controllers/dash_tui/ui/project.rs index 5b40ff118..7eaf10c48 100644 --- a/src/controllers/dash_tui/ui/project.rs +++ b/src/controllers/dash_tui/ui/project.rs @@ -9,7 +9,7 @@ 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_card_width, service_grid_columns, service_rows_per_page, + selected_title_style, service_grid_metrics, }; use crate::controllers::dash_tui::data::DashboardService; use crate::controllers::dash_tui::project::ProjectScreenState; @@ -169,9 +169,7 @@ fn render_project_diagram(frame: &mut Frame<'_>, area: Rect, state: &ProjectScre return; } - let columns = service_grid_columns(inner.width); - let card_width = service_card_width(inner.width, columns); - let rows_per_page = service_rows_per_page(inner.height); + 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; diff --git a/src/controllers/dash_tui/ui/projects.rs b/src/controllers/dash_tui/ui/projects.rs index a98c4de5f..cc1348911 100644 --- a/src/controllers/dash_tui/ui/projects.rs +++ b/src/controllers/dash_tui/ui/projects.rs @@ -8,8 +8,7 @@ use super::super::{DashApp, ProjectsScreenState, RAILWAY_LAVENDER, SPINNER_FRAME 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_card_width, project_grid_columns, project_rows_per_page, screen_sections, - selected_border_style, selected_title_style, + project_grid_metrics, screen_sections, selected_border_style, selected_title_style, }; use crate::controllers::dash_tui::data::ProjectCard; @@ -147,9 +146,7 @@ fn render_projects_grid(frame: &mut Frame<'_>, area: Rect, state: &ProjectsScree return; } - let columns = project_grid_columns(inner.width); - let card_width = project_card_width(inner.width, columns); - let rows_per_page = project_rows_per_page(inner.height); + 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; From bf16c599dfa45fa5d4e247426d99632ef72d4868 Mon Sep 17 00:00:00 2001 From: Arnav Gupta Date: Sat, 23 May 2026 19:35:04 +0100 Subject: [PATCH 21/21] dash-tui: common db detection with metrics --- src/commands/metrics.rs | 19 +----- src/controllers/dash_tui/mod.rs | 96 ++--------------------------- src/controllers/dash_tui/service.rs | 73 ++++++++++++++++++++-- src/resources.rs | 17 ++++- 4 files changed, 90 insertions(+), 115 deletions(-) diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index 04b0d908d..61d654e4d 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/controllers/dash_tui/mod.rs b/src/controllers/dash_tui/mod.rs index 4020e4d60..14de1e50e 100644 --- a/src/controllers/dash_tui/mod.rs +++ b/src/controllers/dash_tui/mod.rs @@ -8,7 +8,7 @@ use std::io::stdout; use std::panic; use std::time::Duration; -use anyhow::{Result, anyhow}; +use anyhow::Result; use crossterm::cursor::{Hide, Show}; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind, @@ -32,19 +32,13 @@ use self::data::{ use self::logs::{LoadedLogs, LogsScreenState, handle_logs_screen_key}; use self::project::{EnvironmentSelectorState, ProjectScreenState, handle_project_screen_key}; use self::service::{ - ServiceAction, ServiceDetail, ServiceScreenState, handle_service_screen_key, + 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}, - commands::{Configs, metrics::Sections}, - controllers::{ - db_stats, - metrics::get_volume_metrics, - project::{find_service_instance, get_environment_instances}, - }, - resources::is_database_service, }; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -287,7 +281,7 @@ impl DashApp { return Ok(()); }; - let params = match build_service_metrics_tui_params(&state.detail).await { + let params = match build_metrics_tui_params(&state.detail).await { Ok(params) => params, Err(error) => { if let DashboardScreen::Service(state) = &mut self.screen { @@ -821,88 +815,6 @@ impl ProjectsScreenState { } } -async fn build_service_metrics_tui_params( - detail: &ServiceDetail, -) -> Result { - 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 is_db = is_database_service(source_image); - let db_type = detect_database_type(source_image); - let sections = Sections { - cpu: true, - memory: true, - network: true, - volume: true, - http: true, - has_explicit_filter: false, - }; - - if !detail.service.active_in_environment { - return Err(anyhow!( - "Service `{}` is not active in environment `{}`.", - detail.service.name, - detail.environment_name - )); - } - - 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.clone(), - is_db, - 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: if db_type.is_some() { - service_instance.map(|instance| instance.id.clone()) - } else { - None - }, - db_stats_preflight_error: if db_type.is_some() { - db_stats::preflight_db_stats_ssh().err() - } else { - None - }, - }) -} - -fn detect_database_type( - source_image: Option<&str>, -) -> Option { - let img = source_image?.to_ascii_lowercase(); - if img.contains("postgres") || img.contains("postgis") || img.contains("timescale") { - Some(crate::controllers::database::DatabaseType::PostgreSQL) - } else if img.contains("redis") || img.contains("valkey") { - Some(crate::controllers::database::DatabaseType::Redis) - } else if img.contains("mongo") { - Some(crate::controllers::database::DatabaseType::MongoDB) - } else if img.contains("mysql") || img.contains("mariadb") { - Some(crate::controllers::database::DatabaseType::MySQL) - } else { - None - } -} - fn setup_terminal() -> Result>> { enable_raw_mode()?; diff --git a/src/controllers/dash_tui/service.rs b/src/controllers/dash_tui/service.rs index 3534cd140..841157da5 100644 --- a/src/controllers/dash_tui/service.rs +++ b/src/controllers/dash_tui/service.rs @@ -7,11 +7,17 @@ use super::{ }; use crate::{ client::GQLClient, - commands::Configs, - controllers::deployment::{ - ServiceDeployment, fetch_service_deployments, redeploy_deployment, - restart_latest_service_deployment, rollback_deployment, + 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; @@ -304,6 +310,65 @@ async fn load_service_deployments_with_client( .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, 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> {