diff --git a/crates/redisctl/src/commands/enterprise/cluster_impl.rs b/crates/redisctl/src/commands/enterprise/cluster_impl.rs index 3c951ebd..bc9899f0 100644 --- a/crates/redisctl/src/commands/enterprise/cluster_impl.rs +++ b/crates/redisctl/src/commands/enterprise/cluster_impl.rs @@ -15,6 +15,7 @@ use redis_enterprise::license::LicenseHandler; use redis_enterprise::nodes::NodeHandler; use redis_enterprise::ocsp::OcspHandler; use redis_enterprise::shards::ShardHandler; +use tabled::{Table, settings::Style}; use super::utils::*; @@ -33,7 +34,72 @@ pub async fn get_cluster( let info = handler.info().await?; let info_json = serde_json::to_value(info).context("Failed to serialize cluster info")?; let data = handle_output(info_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_cluster_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } + Ok(()) +} + +/// Print cluster detail in key-value format +fn print_cluster_detail(data: &serde_json::Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("Name", "name"), + ("Status", "status"), + ("Rack Aware", "rack_aware"), + ("License Expired", "license_expired"), + ("Software Version", "software_version"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + serde_json::Value::Null => continue, + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + // Node count + if let Some(nodes) = data.get("nodes").and_then(|v| v.as_array()) { + rows.push(DetailRow { + field: "Nodes".to_string(), + value: nodes.len().to_string(), + }); + } + + // Memory + if let Some(total) = data.get("total_memory").and_then(|v| v.as_u64()) { + rows.push(DetailRow { + field: "Total Memory".to_string(), + value: format_bytes(total), + }); + } + if let Some(used) = data.get("used_memory").and_then(|v| v.as_u64()) { + rows.push(DetailRow { + field: "Used Memory".to_string(), + value: format_bytes(used), + }); + } + + if rows.is_empty() { + println!("No cluster information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); Ok(()) } diff --git a/crates/redisctl/src/commands/enterprise/database_impl.rs b/crates/redisctl/src/commands/enterprise/database_impl.rs index d80ee9e0..33fcab7d 100644 --- a/crates/redisctl/src/commands/enterprise/database_impl.rs +++ b/crates/redisctl/src/commands/enterprise/database_impl.rs @@ -6,6 +6,7 @@ use std::time::Duration; use indicatif::{ProgressBar, ProgressStyle}; use serde_json::Value; +use tabled::{Table, Tabled, settings::Style}; use crate::cli::OutputFormat; use crate::commands::cloud::async_utils::AsyncOperationArgs; @@ -14,6 +15,184 @@ use crate::error::{RedisCtlError, Result as CliResult}; use super::utils::*; +/// Database row for clean table display +#[derive(Tabled)] +struct DatabaseRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "NAME")] + name: String, + #[tabled(rename = "STATUS")] + status: String, + #[tabled(rename = "MEMORY")] + memory: String, + #[tabled(rename = "SHARDS")] + shards: String, + #[tabled(rename = "REPL")] + replication: String, + #[tabled(rename = "ENDPOINT")] + endpoint: String, + #[tabled(rename = "PERSIST")] + persistence: String, +} + +/// Extract endpoint from database JSON +fn extract_endpoint(db: &Value) -> String { + // Try endpoints[0].dns_name:port or endpoints[0].addr[0]:port + if let Some(endpoints) = db.get("endpoints").and_then(|v| v.as_array()) + && let Some(ep) = endpoints.first() + { + let host = ep + .get("dns_name") + .and_then(|v| v.as_str()) + .or_else(|| { + ep.get("addr") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + }) + .unwrap_or(""); + let port = ep + .get("port") + .and_then(|v| v.as_u64()) + .map(|p| p.to_string()) + .unwrap_or_default(); + if !host.is_empty() && !port.is_empty() { + return format!("{}:{}", host, port); + } else if !host.is_empty() { + return host.to_string(); + } + } + // Fallback: try top-level port + if let Some(port) = db.get("port").and_then(|v| v.as_u64()) { + return format!(":{}", port); + } + "-".to_string() +} + +/// Print databases in clean table format +fn print_databases_table(data: &Value) -> CliResult<()> { + let databases = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No databases found"); + return Ok(()); + } + }; + + if databases.is_empty() { + println!("No databases found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for db in &databases { + let memory_size = db + .get("memory_size") + .and_then(|v| v.as_u64()) + .map(format_bytes) + .unwrap_or_else(|| "-".to_string()); + + rows.push(DatabaseRow { + uid: extract_field(db, "uid", "-"), + name: truncate_string(&extract_field(db, "name", "-"), 25), + status: format_status(extract_field(db, "status", "unknown")), + memory: memory_size, + shards: extract_field(db, "shards_count", "-"), + replication: if db + .get("replication") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + "yes".to_string() + } else { + "no".to_string() + }, + endpoint: truncate_string(&extract_endpoint(db), 30), + persistence: extract_field(db, "data_persistence", "-"), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print database detail in key-value format +fn print_database_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Name", "name"), + ("Status", "status"), + ("Type", "type"), + ("Port", "port"), + ("Replication", "replication"), + ("Data Persistence", "data_persistence"), + ("Eviction Policy", "eviction_policy"), + ("Shards Count", "shards_count"), + ("Shards Placement", "shards_placement"), + ("Proxy Policy", "proxy_policy"), + ("OSS Cluster", "oss_cluster"), + ("Version", "version"), + ("Created Time", "created_time"), + ("Last Changed Time", "last_changed_time"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + // Memory size with formatting + if let Some(mem) = data.get("memory_size").and_then(|v| v.as_u64()) { + rows.push(DetailRow { + field: "Memory Size".to_string(), + value: format_bytes(mem), + }); + } + + // Used memory if available + if let Some(used) = data.get("used_memory").and_then(|v| v.as_u64()) { + rows.push(DetailRow { + field: "Used Memory".to_string(), + value: format_bytes(used), + }); + } + + // Endpoint + let endpoint = extract_endpoint(data); + if endpoint != "-" { + rows.push(DetailRow { + field: "Endpoint".to_string(), + value: endpoint, + }); + } + + if rows.is_empty() { + println!("No database information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + /// Parse a module spec string into (name, version, args). /// Format: `name[@version][:args]` fn parse_module_spec(spec: &str) -> (&str, Option<&str>, Option<&str>) { @@ -48,7 +227,11 @@ pub async fn list_databases( .map_err(RedisCtlError::from)?; let data = handle_output(response, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_databases_table(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -67,7 +250,11 @@ pub async fn get_database( .map_err(RedisCtlError::from)?; let data = handle_output(response, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_database_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } diff --git a/crates/redisctl/src/commands/enterprise/module_impl.rs b/crates/redisctl/src/commands/enterprise/module_impl.rs index 94019b24..6065b596 100644 --- a/crates/redisctl/src/commands/enterprise/module_impl.rs +++ b/crates/redisctl/src/commands/enterprise/module_impl.rs @@ -5,14 +5,32 @@ use crate::error::RedisCtlError; use crate::cli::OutputFormat; use crate::commands::enterprise::module::ModuleCommands; +use crate::commands::enterprise::utils::{ + DetailRow, extract_field, output_with_pager, resolve_auto, truncate_string, +}; use crate::connection::ConnectionManager; use crate::error::Result as CliResult; use anyhow::Context; use redis_enterprise::ModuleHandler; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fs; use std::io::{Read, Write}; use std::path::Path; +use tabled::{Table, Tabled, settings::Style}; + +/// Module row for clean table display +#[derive(Tabled)] +struct ModuleRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "MODULE")] + module_name: String, + #[tabled(rename = "VERSION")] + version: String, + #[tabled(rename = "DISPLAY")] + display_name: String, +} pub async fn handle_module_commands( conn_mgr: &ConnectionManager, @@ -89,7 +107,11 @@ async fn handle_list( modules_json }; - crate::commands::enterprise::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_modules_table(&output_data)?; + } else { + crate::commands::enterprise::utils::print_formatted_output(output_data, output_format)?; + } Ok(()) } @@ -188,7 +210,110 @@ async fn handle_get( module_json }; - crate::commands::enterprise::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_module_detail(&output_data)?; + } else { + crate::commands::enterprise::utils::print_formatted_output(output_data, output_format)?; + } + Ok(()) +} + +/// Print modules in clean table format +fn print_modules_table(data: &Value) -> CliResult<()> { + let modules = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No modules found"); + return Ok(()); + } + }; + + if modules.is_empty() { + println!("No modules found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for module in &modules { + rows.push(ModuleRow { + uid: extract_field(module, "uid", "-"), + module_name: truncate_string(&extract_field(module, "module_name", "-"), 25), + version: extract_field( + module, + "semantic_version", + &extract_field(module, "version", "-"), + ), + display_name: truncate_string(&extract_field(module, "display_name", "-"), 25), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print module detail in key-value format +fn print_module_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Module Name", "module_name"), + ("Display Name", "display_name"), + ("Version", "version"), + ("Semantic Version", "semantic_version"), + ("Min Redis Version", "min_redis_version"), + ("Description", "description"), + ("Author", "author"), + ("Email", "email"), + ("Homepage", "homepage"), + ("License", "license"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + // Capabilities + if let Some(caps) = data.get("capabilities").and_then(|v| v.as_array()) { + let cap_strs: Vec<&str> = caps.iter().filter_map(|v| v.as_str()).collect(); + if !cap_strs.is_empty() { + rows.push(DetailRow { + field: "Capabilities".to_string(), + value: cap_strs.join(", "), + }); + } + } + + // Commands count + if let Some(cmds) = data.get("commands").and_then(|v| v.as_array()) { + rows.push(DetailRow { + field: "Commands".to_string(), + value: cmds.len().to_string(), + }); + } + + if rows.is_empty() { + println!("No module information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); Ok(()) } diff --git a/crates/redisctl/src/commands/enterprise/node_impl.rs b/crates/redisctl/src/commands/enterprise/node_impl.rs index d80cbf07..b4917059 100644 --- a/crates/redisctl/src/commands/enterprise/node_impl.rs +++ b/crates/redisctl/src/commands/enterprise/node_impl.rs @@ -7,9 +7,28 @@ use crate::connection::ConnectionManager; use crate::error::Result as CliResult; use anyhow::Context; use redis_enterprise::nodes::NodeHandler; +use serde_json::Value; +use tabled::{Table, Tabled, settings::Style}; use super::utils::*; +/// Node row for clean table display +#[derive(Tabled)] +struct NodeRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "ADDRESS")] + addr: String, + #[tabled(rename = "STATUS")] + status: String, + #[tabled(rename = "SHARDS")] + shards: String, + #[tabled(rename = "MEMORY")] + memory: String, + #[tabled(rename = "RACK")] + rack_id: String, +} + // Node Operations pub async fn list_nodes( @@ -23,7 +42,11 @@ pub async fn list_nodes( let nodes = handler.list().await?; let nodes_json = serde_json::to_value(nodes).context("Failed to serialize nodes")?; let data = handle_output(nodes_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_nodes_table(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -39,7 +62,98 @@ pub async fn get_node( let node = handler.get(id).await?; let node_json = serde_json::to_value(node).context("Failed to serialize node")?; let data = handle_output(node_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_node_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } + Ok(()) +} + +/// Print nodes in clean table format +fn print_nodes_table(data: &Value) -> CliResult<()> { + let nodes = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No nodes found"); + return Ok(()); + } + }; + + if nodes.is_empty() { + println!("No nodes found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for node in &nodes { + let total_mem = node.get("total_memory").and_then(|v| v.as_u64()); + let memory = total_mem + .map(format_bytes) + .unwrap_or_else(|| "-".to_string()); + + rows.push(NodeRow { + uid: extract_field(node, "uid", "-"), + addr: extract_field(node, "addr", "-"), + status: format_status(extract_field(node, "status", "unknown")), + shards: extract_field(node, "shard_count", "-"), + memory, + rack_id: extract_field(node, "rack_id", "-"), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print node detail in key-value format +fn print_node_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Address", "addr"), + ("Status", "status"), + ("Rack ID", "rack_id"), + ("OS Version", "os_version"), + ("Software Version", "software_version"), + ("Shard Count", "shard_count"), + ("Accept Servers", "accept_servers"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + if let Some(mem) = data.get("total_memory").and_then(|v| v.as_u64()) { + rows.push(DetailRow { + field: "Total Memory".to_string(), + value: format_bytes(mem), + }); + } + + if rows.is_empty() { + println!("No node information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); Ok(()) } diff --git a/crates/redisctl/src/commands/enterprise/proxy.rs b/crates/redisctl/src/commands/enterprise/proxy.rs index b3df7d76..d0f4da1c 100644 --- a/crates/redisctl/src/commands/enterprise/proxy.rs +++ b/crates/redisctl/src/commands/enterprise/proxy.rs @@ -1,8 +1,27 @@ +use crate::cli::OutputFormat; +use crate::commands::enterprise::utils::{ + DetailRow, extract_field, format_status, output_with_pager, resolve_auto, +}; +use crate::connection::ConnectionManager; use crate::error::RedisCtlError; +use crate::error::Result as CliResult; use anyhow::Context; use clap::Subcommand; - -use crate::{cli::OutputFormat, connection::ConnectionManager, error::Result as CliResult}; +use serde_json::Value; +use tabled::{Table, Tabled, settings::Style}; + +/// Proxy row for clean table display +#[derive(Tabled)] +struct ProxyRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "STATUS")] + status: String, + #[tabled(rename = "THREADS")] + threads: String, + #[tabled(rename = "MAX CONN")] + max_connections: String, +} #[allow(dead_code)] pub async fn handle_proxy_command( @@ -114,7 +133,11 @@ async fn handle_proxy_command_impl( response }; - super::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_proxies_table(&output_data)?; + } else { + super::utils::print_formatted_output(output_data, output_format)?; + } } ProxyCommands::Get { uid } => { let response: serde_json::Value = client @@ -128,7 +151,11 @@ async fn handle_proxy_command_impl( response }; - super::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_proxy_detail(&output_data)?; + } else { + super::utils::print_formatted_output(output_data, output_format)?; + } } ProxyCommands::Update { uid, @@ -204,6 +231,76 @@ async fn handle_proxy_command_impl( Ok(()) } +/// Print proxies in clean table format +fn print_proxies_table(data: &Value) -> CliResult<()> { + let proxies = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No proxies found"); + return Ok(()); + } + }; + + if proxies.is_empty() { + println!("No proxies found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for proxy in &proxies { + rows.push(ProxyRow { + uid: extract_field(proxy, "uid", "-"), + status: format_status(extract_field(proxy, "status", "unknown")), + threads: extract_field(proxy, "threads", "-"), + max_connections: extract_field(proxy, "max_connections", "-"), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print proxy detail in key-value format +fn print_proxy_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Status", "status"), + ("Threads", "threads"), + ("Max Connections", "max_connections"), + ("Enabled", "enabled"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + if rows.is_empty() { + println!("No proxy information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/redisctl/src/commands/enterprise/rbac_impl.rs b/crates/redisctl/src/commands/enterprise/rbac_impl.rs index 72bef1ca..7dc6df8e 100644 --- a/crates/redisctl/src/commands/enterprise/rbac_impl.rs +++ b/crates/redisctl/src/commands/enterprise/rbac_impl.rs @@ -10,9 +10,50 @@ use redis_enterprise::ldap_mappings::LdapMappingHandler; use redis_enterprise::redis_acls::{CreateRedisAclRequest, RedisAclHandler}; use redis_enterprise::roles::RolesHandler; use redis_enterprise::users::{AuthRequest, PasswordSet, UserHandler}; +use serde_json::Value; +use tabled::{Table, Tabled, settings::Style}; use super::utils::*; +/// User row for clean table display +#[derive(Tabled)] +struct UserRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "NAME")] + name: String, + #[tabled(rename = "EMAIL")] + email: String, + #[tabled(rename = "ROLE")] + role: String, + #[tabled(rename = "STATUS")] + status: String, + #[tabled(rename = "AUTH")] + auth_method: String, +} + +/// Role row for clean table display +#[derive(Tabled)] +struct RoleRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "NAME")] + name: String, + #[tabled(rename = "MANAGEMENT")] + management: String, +} + +/// ACL row for clean table display +#[derive(Tabled)] +struct AclRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "NAME")] + name: String, + #[tabled(rename = "ACL")] + acl: String, +} + // ============================================================================ // User Management Commands // ============================================================================ @@ -28,7 +69,11 @@ pub async fn list_users( let users = handler.list().await?; let users_json = serde_json::to_value(users).context("Failed to serialize users")?; let data = handle_output(users_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_users_table(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -53,7 +98,11 @@ pub async fn get_user( ); } let data = handle_output(user_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_user_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -363,7 +412,11 @@ pub async fn list_roles( let roles = handler.list().await?; let roles_json = serde_json::to_value(roles).context("Failed to serialize roles")?; let data = handle_output(roles_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_roles_table(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -379,7 +432,11 @@ pub async fn get_role( let role = handler.get(id).await?; let role_json = serde_json::to_value(role).context("Failed to serialize role")?; let data = handle_output(role_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_role_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -562,7 +619,11 @@ pub async fn list_acls( let acls = handler.list().await?; let acls_json = serde_json::to_value(acls).context("Failed to serialize ACLs")?; let data = handle_output(acls_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_acls_table(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -578,7 +639,11 @@ pub async fn get_acl( let acl = handler.get(id).await?; let acl_json = serde_json::to_value(acl).context("Failed to serialize ACL")?; let data = handle_output(acl_json, output_format, query)?; - print_formatted_output(data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_acl_detail(&data)?; + } else { + print_formatted_output(data, output_format)?; + } Ok(()) } @@ -993,3 +1058,246 @@ pub async fn revoke_all_user_sessions( println!("All sessions for user {} revoked", user_id); Ok(()) } + +// ============================================================================ +// Table formatting helpers +// ============================================================================ + +/// Print users in clean table format +fn print_users_table(data: &Value) -> CliResult<()> { + let users = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No users found"); + return Ok(()); + } + }; + + if users.is_empty() { + println!("No users found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for user in &users { + rows.push(UserRow { + uid: extract_field(user, "uid", "-"), + name: truncate_string(&extract_field(user, "name", "-"), 20), + email: truncate_string(&extract_field(user, "email", "-"), 30), + role: extract_field(user, "role", "-"), + status: format_status(extract_field(user, "status", "unknown")), + auth_method: extract_field(user, "auth_method", "-"), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print user detail in key-value format +fn print_user_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Name", "name"), + ("Email", "email"), + ("Role", "role"), + ("Status", "status"), + ("Auth Method", "auth_method"), + ("Email Alerts", "email_alerts"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + // Role UIDs + if let Some(role_uids) = data.get("role_uids").and_then(|v| v.as_array()) { + let uids: Vec = role_uids + .iter() + .filter_map(|v| v.as_u64().map(|n| n.to_string())) + .collect(); + if !uids.is_empty() { + rows.push(DetailRow { + field: "Role UIDs".to_string(), + value: uids.join(", "), + }); + } + } + + if rows.is_empty() { + println!("No user information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print roles in clean table format +fn print_roles_table(data: &Value) -> CliResult<()> { + let roles = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No roles found"); + return Ok(()); + } + }; + + if roles.is_empty() { + println!("No roles found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for role in &roles { + rows.push(RoleRow { + uid: extract_field(role, "uid", "-"), + name: truncate_string(&extract_field(role, "name", "-"), 30), + management: extract_field(role, "management", "-"), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print role detail in key-value format +fn print_role_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Name", "name"), + ("Management", "management"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + // Show nested data_access, bdb_roles, cluster_roles if present + for (label, key) in &[ + ("Data Access", "data_access"), + ("BDB Roles", "bdb_roles"), + ("Cluster Roles", "cluster_roles"), + ] { + if let Some(val) = data.get(*key) + && !val.is_null() + { + rows.push(DetailRow { + field: label.to_string(), + value: val.to_string(), + }); + } + } + + if rows.is_empty() { + println!("No role information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print ACLs in clean table format +fn print_acls_table(data: &Value) -> CliResult<()> { + let acls = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No ACLs found"); + return Ok(()); + } + }; + + if acls.is_empty() { + println!("No ACLs found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for acl in &acls { + rows.push(AclRow { + uid: extract_field(acl, "uid", "-"), + name: truncate_string(&extract_field(acl, "name", "-"), 25), + acl: truncate_string(&extract_field(acl, "acl", "-"), 50), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print ACL detail in key-value format +fn print_acl_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("Name", "name"), + ("ACL", "acl"), + ("Description", "description"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + if rows.is_empty() { + println!("No ACL information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} diff --git a/crates/redisctl/src/commands/enterprise/shard.rs b/crates/redisctl/src/commands/enterprise/shard.rs index ce92521e..f0caad19 100644 --- a/crates/redisctl/src/commands/enterprise/shard.rs +++ b/crates/redisctl/src/commands/enterprise/shard.rs @@ -1,9 +1,28 @@ -use crate::error::RedisCtlError; -use clap::Subcommand; - use crate::cli::OutputFormat; +use crate::commands::enterprise::utils::{ + DetailRow, extract_field, format_status, output_with_pager, resolve_auto, +}; use crate::connection::ConnectionManager; +use crate::error::RedisCtlError; use crate::error::Result as CliResult; +use clap::Subcommand; +use serde_json::Value; +use tabled::{Table, Tabled, settings::Style}; + +/// Shard row for clean table display +#[derive(Tabled)] +struct ShardRow { + #[tabled(rename = "UID")] + uid: String, + #[tabled(rename = "DB")] + bdb_uid: String, + #[tabled(rename = "NODE")] + node: String, + #[tabled(rename = "ROLE")] + role: String, + #[tabled(rename = "STATUS")] + status: String, +} #[derive(Debug, Clone, Subcommand)] pub enum ShardCommands { @@ -198,7 +217,11 @@ impl ShardCommands { } else { response }; - super::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_shards_table(&output_data)?; + } else { + super::utils::print_formatted_output(output_data, output_format)?; + } } ShardCommands::Get { uid } => { @@ -212,7 +235,11 @@ impl ShardCommands { } else { response }; - super::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_shard_detail(&output_data)?; + } else { + super::utils::print_formatted_output(output_data, output_format)?; + } } ShardCommands::ListByDatabase { bdb_uid } => { @@ -226,7 +253,11 @@ impl ShardCommands { } else { response }; - super::utils::print_formatted_output(output_data, output_format)?; + if matches!(resolve_auto(output_format), OutputFormat::Table) { + print_shards_table(&output_data)?; + } else { + super::utils::print_formatted_output(output_data, output_format)?; + } } ShardCommands::Failover { uid, force } => { @@ -466,3 +497,76 @@ pub async fn handle_shard_command( .execute(conn_mgr, profile_name, output_format, query) .await } + +/// Print shards in clean table format +fn print_shards_table(data: &Value) -> CliResult<()> { + let shards = match data { + Value::Array(arr) => arr.clone(), + _ => { + println!("No shards found"); + return Ok(()); + } + }; + + if shards.is_empty() { + println!("No shards found"); + return Ok(()); + } + + let mut rows = Vec::new(); + for shard in &shards { + rows.push(ShardRow { + uid: extract_field(shard, "uid", "-"), + bdb_uid: extract_field(shard, "bdb_uid", "-"), + node: extract_field(shard, "node_uid", &extract_field(shard, "node", "-")), + role: extract_field(shard, "role", "-"), + status: format_status(extract_field(shard, "status", "unknown")), + }); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} + +/// Print shard detail in key-value format +fn print_shard_detail(data: &Value) -> CliResult<()> { + let mut rows = Vec::new(); + + let fields = [ + ("UID", "uid"), + ("BDB UID", "bdb_uid"), + ("Node UID", "node_uid"), + ("Role", "role"), + ("Status", "status"), + ("Loading", "loading"), + ("Backup", "backup"), + ]; + + for (label, key) in &fields { + if let Some(val) = data.get(*key) { + let display = match val { + Value::Null => continue, + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => val.to_string(), + }; + rows.push(DetailRow { + field: label.to_string(), + value: display, + }); + } + } + + if rows.is_empty() { + println!("No shard information available"); + return Ok(()); + } + + let mut table = Table::new(&rows); + table.with(Style::blank()); + output_with_pager(&table.to_string()); + Ok(()) +} diff --git a/crates/redisctl/src/commands/enterprise/utils.rs b/crates/redisctl/src/commands/enterprise/utils.rs index 7195569d..29ed44e4 100644 --- a/crates/redisctl/src/commands/enterprise/utils.rs +++ b/crates/redisctl/src/commands/enterprise/utils.rs @@ -4,7 +4,10 @@ use anyhow::Context; use dialoguer::Confirm; use serde_json::Value; -pub use crate::output::{apply_jmespath, handle_output, print_formatted_output}; +pub use crate::commands::cloud::utils::{ + DetailRow, extract_field, format_memory_size, format_status, output_with_pager, truncate_string, +}; +pub use crate::output::{apply_jmespath, handle_output, print_formatted_output, resolve_auto}; /// Confirm an action with the user pub fn confirm_action(message: &str) -> CliResult { @@ -55,3 +58,11 @@ pub fn read_json_data(data: &str) -> CliResult { serde_json::from_str(&json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e).into()) } + +/// Format byte count as human-readable memory size. +/// +/// The RE API returns memory in bytes; this converts to GB and delegates +/// to `format_memory_size` for display. +pub fn format_bytes(bytes: u64) -> String { + format_memory_size(bytes as f64 / (1024.0 * 1024.0 * 1024.0)) +} diff --git a/crates/redisctl/src/output.rs b/crates/redisctl/src/output.rs index 6f63e272..4626ddd9 100644 --- a/crates/redisctl/src/output.rs +++ b/crates/redisctl/src/output.rs @@ -76,7 +76,7 @@ pub fn compile_jmespath( /// Resolve `Auto` format to a concrete format. /// /// `Auto` resolves to `Table` when stdout is a TTY, `Json` when piped. -fn resolve_auto(format: OutputFormat) -> OutputFormat { +pub fn resolve_auto(format: OutputFormat) -> OutputFormat { match format { OutputFormat::Auto => { if std::io::stdout().is_terminal() {