use crate::{
controllers::{
database::DatabaseType,
db_stats::{self, DatabaseStats},
environment::get_matched_environment,
metrics::{
FetchHttpMetricsParams, FetchProjectMetricsParams, FetchResourceMetricsParams,
HttpMetricsResult, ResourceMetricsResult, ServiceMetricsSummary, VolumeMetrics,
compute_sample_rate, fetch_http_metrics, fetch_project_metrics, fetch_resource_metrics,
find_metric, format_count, format_cpu, format_gb, format_mb, get_volume_metrics, pct,
utilization,
},
project::{
ensure_project_and_environment_exist, find_service_instance, get_environment_instances,
get_project,
},
},
resources::is_database_service,
util::{progress::create_spinner_if, time::parse_time},
};
use super::{
queries::{
self,
deployments::{DeploymentListInput, DeploymentStatus},
metrics::MetricMeasurement,
},
*,
};
#[derive(Parser)]
#[clap(
alias = "metric",
after_help = "Examples:
Quick overview:
railway metrics # All metrics for linked service (last 1h)
railway metrics -s my-api -e production # Specific service and environment
railway metrics --since 6h # Last 6 hours
Focus on specific metrics:
railway metrics --cpu --memory # Only CPU and memory
railway metrics --http # Only HTTP metrics
railway metrics --network # Only public network traffic
HTTP filtering (requires --http):
railway metrics --http --method POST --path /api/users # POST requests to a specific path
railway metrics --http --method GET # GET requests only
Raw time-series data (for deeper analysis):
railway metrics --raw --cpu # CPU data points in terminal
railway metrics --raw --json --cpu # CPU time-series as JSON
All services in the project:
railway metrics --all # Compact table across all services
railway metrics --all --json # All services as JSON
railway metrics --all --cpu --memory # Table with only CPU and memory
JSON output (for scripting and agents):
railway metrics --json # Compact summary as JSON
railway metrics --json --http --method POST # HTTP metrics for POST as JSON"
)]
pub struct Args {
#[clap(short, long, conflicts_with = "all")]
service: Option<String>,
#[clap(short = 'a', long, conflicts_with = "raw")]
all: bool,
#[clap(short, long)]
environment: Option<String>,
#[clap(long, short = 'S', default_value = "1h")]
since: String,
#[clap(long, short = 'U', conflicts_with = "watch")]
until: Option<String>,
#[clap(long)]
json: bool,
#[clap(long)]
cpu: bool,
#[clap(long)]
memory: bool,
#[clap(long)]
network: bool,
#[clap(long)]
volume: bool,
#[clap(long)]
http: bool,
#[clap(long)]
raw: bool,
#[clap(short = 'w', long, conflicts_with_all = ["json", "raw"])]
watch: bool,
#[clap(long, requires = "http", value_enum, ignore_case = true)]
method: Option<crate::commands::logs::HttpMethod>,
#[clap(long, requires = "http", value_name = "PATH")]
path: Option<String>,
}
#[derive(Clone)]
pub(crate) struct Sections {
pub(crate) cpu: bool,
pub(crate) memory: bool,
pub(crate) network: bool,
pub(crate) volume: bool,
pub(crate) http: bool,
pub(crate) has_explicit_filter: bool,
}
impl Sections {
pub(crate) fn from_args(args: &Args) -> Self {
let any_filter = args.cpu || args.memory || args.network || args.volume || args.http;
if any_filter {
Self {
cpu: args.cpu,
memory: args.memory,
network: args.network,
volume: args.volume,
http: args.http,
has_explicit_filter: true,
}
} else {
Self {
cpu: true,
memory: true,
network: true,
volume: true,
http: true,
has_explicit_filter: false,
}
}
}
pub(crate) fn needs_resource_metrics(&self) -> bool {
self.cpu || self.memory || self.network || self.volume
}
pub(crate) fn measurements(&self, include_disk: bool) -> Vec<MetricMeasurement> {
let mut m = Vec::new();
if self.cpu {
m.push(MetricMeasurement::CPU_USAGE);
m.push(MetricMeasurement::CPU_LIMIT);
}
if self.memory {
m.push(MetricMeasurement::MEMORY_USAGE_GB);
m.push(MetricMeasurement::MEMORY_LIMIT_GB);
}
if self.network {
m.push(MetricMeasurement::NETWORK_TX_GB);
m.push(MetricMeasurement::NETWORK_RX_GB);
}
if self.volume && include_disk {
m.push(MetricMeasurement::DISK_USAGE_GB);
}
m
}
}
fn should_include_db_stats(args: &Args, sections: &Sections, is_db: bool) -> bool {
is_db && !args.raw && !sections.has_explicit_filter
}
pub async fn command(args: Args) -> Result<()> {
let start_date = parse_time(&args.since)?;
let end_date = args.until.as_ref().map(|s| parse_time(s)).transpose()?;
if let Some(ref e) = end_date {
if &start_date >= e {
bail!("--since time must be before --until time");
}
}
let watch_since_label = if args.watch {
match crate::controllers::metrics_tui::normalize_time_range_label(&args.since) {
Some(label) => Some(label),
None => bail!(
"--watch supports --since values: {}",
crate::controllers::metrics_tui::supported_time_ranges_label()
),
}
} else {
None
};
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let backboard = configs.get_backboard();
let linked_project = configs.get_linked_project().await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
let environment = match args
.environment
.clone()
.or_else(|| linked_project.environment_name.clone())
.or_else(|| linked_project.environment.clone())
{
Some(environment) => environment,
None => linked_project.environment_id()?.to_string(),
};
let environment = get_matched_environment(&project, environment)?;
let environment_id = environment.id.clone();
let environment_name = environment.name.clone();
let environment_instances =
get_environment_instances(&client, &configs, &linked_project.project, &environment_id)
.await?;
let show_spinner = !args.json && !args.raw && !args.watch;
if args.all {
if args.raw {
bail!("--raw requires a specific service (--service).");
}
if args.watch {
let sections = Sections::from_args(&args);
return crate::controllers::metrics_tui::run_project(
crate::controllers::metrics_tui::ProjectTuiParams {
client: client.clone(),
backboard: backboard.clone(),
project_id: linked_project.project.clone(),
project: project.clone(),
environment_instances: environment_instances.clone(),
environment_id: environment_id.clone(),
environment_name: environment_name.clone(),
method: args.method.as_ref().map(|m| m.to_string()),
path: args.path.clone(),
since_label: watch_since_label
.clone()
.expect("watch time range was validated"),
sections,
},
)
.await;
}
let spinner = create_spinner_if(show_spinner, "Fetching metrics...".into());
let sections = Sections::from_args(&args);
let mut measurements = sections.measurements(false);
if measurements.is_empty() {
measurements.push(MetricMeasurement::CPU_USAGE);
}
let duration = end_date.unwrap_or_else(chrono::Utc::now) - start_date;
let sample_rate = compute_sample_rate(duration);
let window_label = format_window_label(&args.since, args.until.as_deref());
let mut services = fetch_project_metrics(
FetchProjectMetricsParams {
client: &client,
backboard: &backboard,
project_id: &linked_project.project,
environment_id: &environment_id,
start_date,
end_date,
measurements,
sample_rate_seconds: Some(sample_rate),
},
&environment_instances,
)
.await?;
if sections.volume {
for svc in &mut services {
svc.volumes = get_volume_metrics(&environment_instances, &svc.service_id);
}
}
if sections.http {
let method_filter = args.method.as_ref().map(|m| m.to_string());
let path_filter = args.path.clone();
let end = end_date.unwrap_or_else(chrono::Utc::now);
for svc in &mut services {
let service_instance =
find_service_instance(&environment_instances, &svc.service_id);
let source_image = service_instance
.and_then(|si| si.source.as_ref())
.and_then(|src| src.image.as_deref());
svc.is_database = is_database_service(source_image);
}
let http_futures: Vec<_> = services
.iter()
.enumerate()
.filter(|(_, svc)| !svc.is_database)
.map(|(i, svc)| {
let params = FetchHttpMetricsParams {
client: &client,
backboard: &backboard,
service_id: &svc.service_id,
environment_id: &environment_id,
start_date,
end_date: end,
step_seconds: Some(sample_rate),
method: method_filter.clone(),
path: path_filter.clone(),
include_time_series: false,
};
async move { (i, fetch_http_metrics(params).await) }
})
.collect();
let results = futures::future::join_all(http_futures).await;
for (i, result) in results {
if let Ok(http) = result {
services[i].http = http;
}
}
}
if let Some(sp) = spinner {
sp.finish_and_clear();
}
let project_name = project.name.clone();
if args.json {
print_project_json(
&project_name,
&environment_name,
start_date,
end_date,
§ions,
&services,
)?;
} else {
print_project_terminal(
&project_name,
&environment_name,
&window_label,
§ions,
&services,
);
}
return Ok(());
}
let services = project.services.edges.iter().collect::<Vec<_>>();
let (service_id, service_name) = match (args.service.as_deref(), linked_project.service) {
(Some(service_arg), _) => {
let s = services
.iter()
.find(|s| s.node.name == service_arg || s.node.id == service_arg)
.with_context(|| format!("Service '{service_arg}' not found"))?;
(s.node.id.clone(), s.node.name.clone())
}
(_, Some(linked_service)) => {
let name = services
.iter()
.find(|s| s.node.id == linked_service)
.map(|s| s.node.name.clone())
.unwrap_or_else(|| linked_service.clone());
(linked_service, name)
}
_ => bail!(
"No service could be found. Please either link one with `railway service` or specify one via the `--service` flag."
),
};
let sections = Sections::from_args(&args);
let service_instance = find_service_instance(&environment_instances, &service_id);
let source_image = service_instance
.and_then(|si| si.source.as_ref())
.and_then(|src| src.image.as_deref());
let is_db = is_database_service(source_image);
let db_type = detect_database_type(source_image);
let include_db_stats = should_include_db_stats(&args, §ions, is_db);
let db_stats_preflight_error = if db_type.is_some() && (args.watch || include_db_stats) {
db_stats::preflight_db_stats_ssh().err()
} else {
None
};
let service_instance_id = if db_type.is_some() && (args.watch || include_db_stats) {
service_instance.map(|service_instance| service_instance.id.clone())
} else {
None
};
if args.watch {
let volumes = if sections.volume {
get_volume_metrics(&environment_instances, &service_id)
} else {
vec![]
};
return crate::controllers::metrics_tui::run(
crate::controllers::metrics_tui::ServiceTuiParams {
client: client.clone(),
backboard: backboard.clone(),
service_id: service_id.clone(),
service_name: service_name.clone(),
environment_id: environment_id.clone(),
environment_name: environment_name.clone(),
since_label: watch_since_label.expect("watch time range was validated"),
sections,
is_db,
db_stats_supported: db_type.is_some(),
method: args.method.as_ref().map(|m| m.to_string()),
path: args.path.clone(),
volumes,
db_type: db_type.clone(),
service_instance_id: service_instance_id.clone(),
db_stats_preflight_error: db_stats_preflight_error.clone(),
},
)
.await;
}
let duration = end_date.unwrap_or_else(chrono::Utc::now) - start_date;
let sample_rate = compute_sample_rate(duration);
let window_label = format_window_label(&args.since, args.until.as_deref());
let spinner = create_spinner_if(show_spinner, "Fetching metrics...".into());
let db_stats_future = if include_db_stats && db_stats_preflight_error.is_none() {
if let (Some(dt), Some(instance_id)) = (db_type.as_ref(), service_instance_id.as_ref()) {
let dt = dt.clone();
let instance_id = instance_id.clone();
Some(tokio::spawn(async move {
db_stats::fetch_db_stats(&instance_id, &dt).await
}))
} else {
None
}
} else {
None
};
let resource_result = if sections.needs_resource_metrics() {
let measurements = sections.measurements(true);
Some(
fetch_resource_metrics(FetchResourceMetricsParams {
client: &client,
backboard: &backboard,
service_id: &service_id,
environment_id: &environment_id,
start_date,
end_date,
measurements,
sample_rate_seconds: Some(sample_rate),
include_raw: args.raw,
})
.await?,
)
} else {
None
};
let volume_metrics = if sections.volume {
get_volume_metrics(&environment_instances, &service_id)
} else {
vec![]
};
let needs_deployments = sections.http || !sections.has_explicit_filter;
let deployments_data = if needs_deployments {
fetch_deployments(
&client,
&backboard,
&linked_project.project,
&environment_id,
&service_id,
)
.await?
} else {
DeploymentsData { recent: vec![] }
};
let http_result = if sections.http && !is_db {
let end = end_date.unwrap_or_else(chrono::Utc::now);
fetch_http_metrics(FetchHttpMetricsParams {
client: &client,
backboard: &backboard,
service_id: &service_id,
environment_id: &environment_id,
start_date,
end_date: end,
step_seconds: Some(sample_rate),
method: args.method.as_ref().map(|m| m.to_string()),
path: args.path.clone(),
include_time_series: args.raw,
})
.await?
} else {
None
};
let db_stats_result = if let Some(handle) = db_stats_future {
match handle.await {
Ok(Ok(stats)) => Some(stats),
Ok(Err(e)) => {
if show_spinner {
let dt = db_type
.as_ref()
.expect("db_type present when fetch spawned");
let msg = db_stats::diagnose_db_stats_failure(&e, dt);
eprintln!("{} database stats unavailable:", "warning:".yellow().bold());
for line in msg.lines() {
eprintln!(" {line}");
}
}
None
}
Err(_) => None, }
} else if let Some(ref msg) = db_stats_preflight_error {
if show_spinner && include_db_stats {
eprintln!("{} database stats unavailable:", "warning:".yellow().bold());
for line in msg.lines() {
eprintln!(" {line}");
}
}
None
} else {
None
};
if let Some(sp) = spinner {
sp.finish_and_clear();
}
if args.raw {
if args.json {
print_raw_json(
&service_name,
&environment_name,
start_date,
end_date,
resource_result.as_ref(),
http_result.as_ref(),
)?;
} else {
print_raw_terminal(resource_result.as_ref(), http_result.as_ref());
}
} else if args.json {
print_json(
&service_name,
&environment_name,
start_date,
end_date,
§ions,
resource_result.as_ref(),
&volume_metrics,
http_result.as_ref(),
&deployments_data,
db_stats_result.as_ref(),
)?;
} else {
let has_http_filter = args.method.is_some() || args.path.is_some();
print_terminal(
&service_name,
&environment_name,
&window_label,
§ions,
resource_result.as_ref(),
&volume_metrics,
http_result.as_ref(),
&deployments_data,
is_db,
has_http_filter,
);
if let Some(ref db_stats) = db_stats_result {
print_db_stats_terminal(db_stats);
}
}
Ok(())
}
struct DeploymentInfo {
id: String,
created_at: chrono::DateTime<chrono::Utc>,
status: DeploymentStatus,
}
struct DeploymentsData {
recent: Vec<DeploymentInfo>,
}
async fn fetch_deployments(
client: &reqwest::Client,
backboard: &str,
project_id: &str,
environment_id: &str,
service_id: &str,
) -> Result<DeploymentsData> {
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::<queries::Deployments, _>(client, backboard, vars)
.await?
.deployments;
let mut all: Vec<_> = deployments.edges.into_iter().map(|d| d.node).collect();
all.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let recent = all
.iter()
.take(5)
.map(|d| DeploymentInfo {
id: d.id.clone(),
created_at: d.created_at,
status: d.status.clone(),
})
.collect();
Ok(DeploymentsData { recent })
}
fn is_relative_time_value(value: &str) -> bool {
let value_lower = value.trim().to_lowercase();
value_lower.ends_with('h')
|| value_lower.ends_with('m')
|| value_lower.ends_with('d')
|| value_lower.ends_with('w')
|| value_lower.ends_with('s')
}
fn format_time_bound_label(value: &str, suffix_for_relative: Option<&str>) -> String {
let value_lower = value.to_lowercase().trim().to_string();
if is_relative_time_value(value) {
match suffix_for_relative {
Some(suffix) => format!("{value_lower} {suffix}"),
None => value_lower,
}
} else {
value.to_string()
}
}
fn format_window_label(since: &str, until: Option<&str>) -> String {
match until {
Some(until) => format!(
"from {} until {}",
format_time_bound_label(since, Some("ago")),
format_time_bound_label(until, Some("ago"))
),
None if is_relative_time_value(since) => {
format!("last {}", format_time_bound_label(since, None))
}
None => format!("since {since}"),
}
}
fn pad_visible(visible: &str, display: &str, width: usize) -> String {
let pad = width.saturating_sub(visible.len());
format!("{}{}", display, " ".repeat(pad))
}
fn print_resource_line(
label: &str,
usage: &crate::controllers::metrics::MetricSummary,
limit: Option<&crate::controllers::metrics::MetricSummary>,
format_fn: fn(f64) -> String,
) {
let limit_val = limit.filter(|l| l.current > 0.0).map(|l| l.current);
let util_pct = utilization(usage.current, limit_val);
let current_plain = format_fn(usage.current);
let current_display = match util_pct {
Some(pct) => {
let plain = format!("{} ({:.0}%)", current_plain, pct);
let colored = format!("{} ({:.0}%)", color_by_health(¤t_plain, pct), pct);
pad_visible(&plain, &colored, 18)
}
None => format!("{:<18}", current_plain),
};
let limit_str = limit_val.map(format_fn).unwrap_or_else(|| "–".to_string());
println!(
" {:<9} {} {:<15} {:<15} {}",
label,
current_display,
format_fn(usage.average),
format_fn(usage.max),
limit_str,
);
}
#[allow(clippy::too_many_arguments)]
fn print_terminal(
service_name: &str,
environment_name: &str,
window_label: &str,
sections: &Sections,
resource: Option<&ResourceMetricsResult>,
volumes: &[VolumeMetrics],
http: Option<&HttpMetricsResult>,
deployments: &DeploymentsData,
is_db: bool,
has_http_filter: bool,
) {
println!(
"\nMetrics for {} in {} ({})\n",
service_name.bold(),
environment_name.blue().bold(),
window_label
);
let mut printed_any = false;
if let Some(res) = resource {
let cpu_usage = sections
.cpu
.then(|| find_metric(&res.metrics, "CPU_USAGE"))
.flatten();
let cpu_limit = sections
.cpu
.then(|| find_metric(&res.metrics, "CPU_LIMIT"))
.flatten();
let mem_usage = sections
.memory
.then(|| find_metric(&res.metrics, "MEMORY_USAGE_GB"))
.flatten();
let mem_limit = sections
.memory
.then(|| find_metric(&res.metrics, "MEMORY_LIMIT_GB"))
.flatten();
let has_cpu = cpu_usage.is_some();
let has_mem = mem_usage.is_some();
if has_cpu || has_mem {
println!(" {}", "Resource Usage".bold());
println!(" ─────────────────────────────────────────────────────────────────────");
println!(
" {}",
" current avg max limit".dimmed()
);
if let Some(cpu) = cpu_usage {
print_resource_line("CPU", cpu, cpu_limit, format_cpu);
}
if let Some(mem) = mem_usage {
print_resource_line("Memory", mem, mem_limit, format_gb);
}
println!();
printed_any = true;
}
if sections.network {
let tx = find_metric(&res.metrics, "NETWORK_TX_GB");
let rx = find_metric(&res.metrics, "NETWORK_RX_GB");
let has_network = tx.is_some() || rx.is_some();
if has_network {
println!(" {}", "Public Network Traffic".bold());
println!(" ──────────────────────────────────────────────");
if let Some(tx) = tx {
println!(
" {:<13}{:<24}avg: {:<17}max: {}",
"Egress",
format_gb(tx.current),
format_gb(tx.average),
format_gb(tx.max),
);
}
if let Some(rx) = rx {
println!(
" {:<13}{:<24}avg: {:<17}max: {}",
"Ingress",
format_gb(rx.current),
format_gb(rx.average),
format_gb(rx.max),
);
}
println!();
printed_any = true;
}
}
if sections.volume {
let disk = find_metric(&res.metrics, "DISK_USAGE_GB");
let use_service_disk_per_volume = disk.is_some() && volumes.len() <= 1;
for vol in volumes {
println!(" {}", format!("Volume: {}", vol.mount_path).bold());
println!(" ──────────────────────────────────────────────");
if use_service_disk_per_volume {
let disk = disk.expect("checked above");
let limit_val = if vol.limit_size_mb > 0.0 {
Some(vol.limit_size_mb / 1024.0)
} else {
None
};
let util_pct = utilization(disk.current, limit_val);
let current_str = format_gb(disk.current);
let current_display = match util_pct {
Some(p) => {
format!("{} ({:.0}%)", color_by_health(¤t_str, p), p)
}
None => current_str,
};
let limit_str = if vol.limit_size_mb > 0.0 {
format!(" (limit: {})", format_mb(vol.limit_size_mb))
} else {
String::new()
};
println!(
" {:<13}{:<24}avg: {:<17}max: {}{}",
"Disk",
current_display,
format_gb(disk.average),
format_gb(disk.max),
limit_str,
);
} else {
let util = utilization(
vol.current_size_mb,
Some(vol.limit_size_mb).filter(|&l| l > 0.0),
);
let pct_str = util.map(|p| format!(" ({:.0}%)", p)).unwrap_or_default();
println!(
" {:<13}{} / {}{}",
"Usage",
format_mb(vol.current_size_mb),
format_mb(vol.limit_size_mb),
pct_str,
);
}
println!();
printed_any = true;
}
if volumes.len() > 1 {
if let Some(disk) = disk {
println!(" {}", "Disk Usage (service total)".bold());
println!(" ──────────────────────────────────────────────");
println!(
" {:<13}{:<24}avg: {:<17}max: {}",
"Current",
format_gb(disk.current),
format_gb(disk.average),
format_gb(disk.max),
);
println!();
printed_any = true;
}
}
if volumes.is_empty() {
if let Some(disk) = disk {
println!(" {}", "Disk Usage".bold());
println!(" ──────────────────────────────────────────────");
println!(
" {:<13}{:<24}avg: {:<17}max: {}",
"Current",
format_gb(disk.current),
format_gb(disk.average),
format_gb(disk.max),
);
println!();
printed_any = true;
} else if sections.has_explicit_filter {
println!(" No volumes attached to this service.\n");
printed_any = true;
}
}
}
} else if sections.volume && !volumes.is_empty() {
for vol in volumes {
println!(" {}", format!("Volume: {}", vol.mount_path).bold());
println!(" ──────────────────────────────────────────────");
let util = utilization(
vol.current_size_mb,
Some(vol.limit_size_mb).filter(|&l| l > 0.0),
);
let pct_str = util.map(|p| format!(" ({:.0}%)", p)).unwrap_or_default();
println!(
" {:<13}{} / {}{}",
"Usage",
format_mb(vol.current_size_mb),
format_mb(vol.limit_size_mb),
pct_str,
);
println!();
printed_any = true;
}
} else if sections.volume && sections.has_explicit_filter {
println!(" No volumes attached to this service.\n");
printed_any = true;
}
if sections.http {
if is_db {
if sections.has_explicit_filter {
println!(" HTTP metrics are not available for database services.\n");
printed_any = true;
}
} else if let Some(http) = http {
let header = format!("HTTP Requests ({} total)", format_count(http.total));
println!(" {}", header.bold());
println!(" ──────────────────────────────────────────────");
println!(" {:<13}{}", "Total", format_count(http.total));
println!(
" {:<13}{} ({:.1}%)",
"2xx".green(),
format_count(http.status_counts[2]),
pct(http.status_counts[2], http.total),
);
println!(
" {:<13}{} ({:.1}%)",
"3xx".cyan(),
format_count(http.status_counts[3]),
pct(http.status_counts[3], http.total),
);
println!(
" {:<13}{} ({:.1}%)",
"4xx".yellow(),
format_count(http.status_counts[4]),
pct(http.status_counts[4], http.total),
);
println!(
" {:<13}{} ({:.1}%)",
"5xx".red(),
format_count(http.status_counts[5]),
pct(http.status_counts[5], http.total),
);
let error_rate_str = format!("{:.1}%", http.error_rate);
let error_rate_display = color_by_health(&error_rate_str, http.error_rate * 10.0);
println!(" {:<13}{}", "Error Rate", error_rate_display);
println!();
println!(
" {:<13}p50: {}ms p90: {}ms p95: {}ms p99: {}ms",
"Latency", http.p50_ms, http.p90_ms, http.p95_ms, http.p99_ms,
);
println!();
printed_any = true;
} else if sections.has_explicit_filter {
if has_http_filter {
println!(" No HTTP logs matched the filter.\n");
} else {
println!(" No HTTP logs found.\n");
}
printed_any = true;
}
}
let show_deploys = !sections.has_explicit_filter || http.is_some();
if show_deploys && !deployments.recent.is_empty() {
println!(" {}", "Recent Deployments".bold());
println!(" ──────────────────────────────────────────────");
for d in &deployments.recent {
let line = format!(
" {} {} {:?}",
d.created_at.format("%Y-%m-%d %H:%M UTC"),
&d.id[..8.min(d.id.len())],
d.status,
);
if matches!(d.status, DeploymentStatus::SUCCESS) {
println!("{}", line.green().bold());
} else if matches!(d.status, DeploymentStatus::REMOVED) {
println!("{}", line.dimmed());
} else {
println!("{}", line);
}
}
println!();
}
if !printed_any {
println!(
" No metrics data available yet. Metrics typically appear\n within a few minutes of deployment.\n"
);
}
}
fn color_by_health(s: &str, utilization_pct: f64) -> colored::ColoredString {
if utilization_pct >= 85.0 {
s.red()
} else if utilization_pct >= 60.0 {
s.yellow()
} else {
s.green()
}
}
#[allow(clippy::too_many_arguments)]
fn print_json(
service_name: &str,
environment_name: &str,
start_date: chrono::DateTime<chrono::Utc>,
end_date: Option<chrono::DateTime<chrono::Utc>>,
sections: &Sections,
resource: Option<&ResourceMetricsResult>,
volumes: &[VolumeMetrics],
http: Option<&HttpMetricsResult>,
deployments: &DeploymentsData,
db_stats: Option<&DatabaseStats>,
) -> Result<()> {
let mut json = serde_json::Map::new();
json.insert(
"service".to_string(),
serde_json::Value::String(service_name.to_string()),
);
json.insert(
"environment".to_string(),
serde_json::Value::String(environment_name.to_string()),
);
let mut window = serde_json::Map::new();
window.insert(
"since".to_string(),
serde_json::Value::String(start_date.to_rfc3339()),
);
window.insert(
"until".to_string(),
serde_json::Value::String(end_date.unwrap_or_else(chrono::Utc::now).to_rfc3339()),
);
json.insert("window".to_string(), serde_json::Value::Object(window));
if let Some(res) = resource {
if sections.cpu {
let cpu_usage = find_metric(&res.metrics, "CPU_USAGE");
let cpu_limit = find_metric(&res.metrics, "CPU_LIMIT");
if let Some(cpu) = cpu_usage {
let mut cpu_json = serde_json::Map::new();
cpu_json.insert("current".into(), serde_json::json!(cpu.current));
cpu_json.insert("average".into(), serde_json::json!(cpu.average));
cpu_json.insert("max".into(), serde_json::json!(cpu.max));
if let Some(limit) = cpu_limit.filter(|l| l.current > 0.0) {
cpu_json.insert("limit".into(), serde_json::json!(limit.current));
if let Some(pct) = utilization(cpu.current, Some(limit.current)) {
cpu_json.insert(
"utilization_pct".into(),
serde_json::json!((pct * 10.0).round() / 10.0),
);
}
}
cpu_json.insert("unit".into(), serde_json::json!("vCPU"));
json.insert("cpu".into(), serde_json::Value::Object(cpu_json));
}
}
if sections.memory {
let mem_usage = find_metric(&res.metrics, "MEMORY_USAGE_GB");
let mem_limit = find_metric(&res.metrics, "MEMORY_LIMIT_GB");
if let Some(mem) = mem_usage {
let mut mem_json = serde_json::Map::new();
mem_json.insert("current_mb".into(), serde_json::json!(mem.current * 1024.0));
mem_json.insert("average_mb".into(), serde_json::json!(mem.average * 1024.0));
mem_json.insert("max_mb".into(), serde_json::json!(mem.max * 1024.0));
if let Some(limit) = mem_limit.filter(|l| l.current > 0.0) {
mem_json.insert("limit_mb".into(), serde_json::json!(limit.current * 1024.0));
if let Some(pct) = utilization(mem.current, Some(limit.current)) {
mem_json.insert(
"utilization_pct".into(),
serde_json::json!((pct * 10.0).round() / 10.0),
);
}
}
json.insert("memory".into(), serde_json::Value::Object(mem_json));
}
}
if sections.network {
let tx = find_metric(&res.metrics, "NETWORK_TX_GB");
let rx = find_metric(&res.metrics, "NETWORK_RX_GB");
if tx.is_some() || rx.is_some() {
let mut net_json = serde_json::Map::new();
if let Some(tx) = tx {
let mut egress = serde_json::Map::new();
egress.insert("current_mb".into(), serde_json::json!(tx.current * 1024.0));
egress.insert("average_mb".into(), serde_json::json!(tx.average * 1024.0));
egress.insert("max_mb".into(), serde_json::json!(tx.max * 1024.0));
net_json.insert("egress".into(), serde_json::Value::Object(egress));
}
if let Some(rx) = rx {
let mut ingress = serde_json::Map::new();
ingress.insert("current_mb".into(), serde_json::json!(rx.current * 1024.0));
ingress.insert("average_mb".into(), serde_json::json!(rx.average * 1024.0));
ingress.insert("max_mb".into(), serde_json::json!(rx.max * 1024.0));
net_json.insert("ingress".into(), serde_json::Value::Object(ingress));
}
json.insert(
"public_network_traffic".into(),
serde_json::Value::Object(net_json),
);
}
}
if sections.volume {
let disk = find_metric(&res.metrics, "DISK_USAGE_GB");
let use_service_disk_per_volume = disk.is_some() && volumes.len() <= 1;
let vol_arr: Vec<serde_json::Value> = volumes
.iter()
.map(|v| {
let mut vol_json = serde_json::json!({
"name": v.volume_name,
"mount_path": v.mount_path,
"current_mb": v.current_size_mb,
"limit_mb": v.limit_size_mb,
});
if use_service_disk_per_volume {
let disk = disk.expect("checked above");
vol_json["current_mb"] = serde_json::json!(disk.current * 1024.0);
vol_json["average_mb"] = serde_json::json!(disk.average * 1024.0);
vol_json["max_mb"] = serde_json::json!(disk.max * 1024.0);
}
vol_json
})
.collect();
if !vol_arr.is_empty() {
json.insert("volumes".into(), serde_json::Value::Array(vol_arr));
}
if let Some(disk) = disk {
if volumes.is_empty() || volumes.len() > 1 {
let mut disk_json = serde_json::Map::new();
disk_json.insert(
"current_mb".into(),
serde_json::json!(disk.current * 1024.0),
);
disk_json.insert(
"average_mb".into(),
serde_json::json!(disk.average * 1024.0),
);
disk_json.insert("max_mb".into(), serde_json::json!(disk.max * 1024.0));
json.insert("disk".into(), serde_json::Value::Object(disk_json));
}
}
}
}
if let Some(http) = http {
let mut http_json = serde_json::Map::new();
http_json.insert("total".into(), serde_json::json!(http.total));
http_json.insert("2xx".into(), serde_json::json!(http.status_counts[2]));
http_json.insert("3xx".into(), serde_json::json!(http.status_counts[3]));
http_json.insert("4xx".into(), serde_json::json!(http.status_counts[4]));
http_json.insert("5xx".into(), serde_json::json!(http.status_counts[5]));
http_json.insert("error_rate".into(), serde_json::json!(http.error_rate));
http_json.insert("p50_ms".into(), serde_json::json!(http.p50_ms));
http_json.insert("p90_ms".into(), serde_json::json!(http.p90_ms));
http_json.insert("p95_ms".into(), serde_json::json!(http.p95_ms));
http_json.insert("p99_ms".into(), serde_json::json!(http.p99_ms));
json.insert("http".into(), serde_json::Value::Object(http_json));
}
if !deployments.recent.is_empty() {
let deploys: Vec<serde_json::Value> = deployments
.recent
.iter()
.map(|d| {
serde_json::json!({
"id": d.id,
"created_at": d.created_at.to_rfc3339(),
"status": format!("{:?}", d.status),
})
})
.collect();
json.insert("deployments".into(), serde_json::Value::Array(deploys));
}
if let Some(stats) = db_stats {
json.insert("db_stats".into(), serde_json::to_value(stats)?);
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(json))?
);
Ok(())
}
fn print_raw_json(
service_name: &str,
environment_name: &str,
start_date: chrono::DateTime<chrono::Utc>,
end_date: Option<chrono::DateTime<chrono::Utc>>,
resource: Option<&ResourceMetricsResult>,
http: Option<&HttpMetricsResult>,
) -> Result<()> {
fn points_to_json(
points: &[crate::controllers::metrics::MetricDataPoint],
) -> Vec<serde_json::Value> {
points
.iter()
.map(|p| {
let ts = chrono::DateTime::from_timestamp(p.ts, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| p.ts.to_string());
serde_json::json!({"ts": ts, "value": p.value})
})
.collect()
}
let mut json = serde_json::Map::new();
json.insert(
"service".into(),
serde_json::Value::String(service_name.to_string()),
);
json.insert(
"environment".into(),
serde_json::Value::String(environment_name.to_string()),
);
let mut window = serde_json::Map::new();
window.insert("since".into(), serde_json::json!(start_date.to_rfc3339()));
window.insert(
"until".into(),
serde_json::json!(end_date.unwrap_or_else(chrono::Utc::now).to_rfc3339()),
);
json.insert("window".into(), serde_json::Value::Object(window));
let mut measurements = serde_json::Map::new();
if let Some(res) = resource {
for metric in &res.metrics {
let points = points_to_json(&metric.raw_values);
measurements.insert(metric.measurement.clone(), serde_json::Value::Array(points));
}
}
json.insert(
"measurements".into(),
serde_json::Value::Object(measurements),
);
if let Some(http) = http.and_then(|result| result.time_series.as_ref()) {
let mut http_json = serde_json::Map::new();
let error_rate_pct: Vec<_> = http
.error_rate_ts
.iter()
.zip(http.request_rate_ts.iter())
.map(
|(errors, total)| crate::controllers::metrics::MetricDataPoint {
ts: errors.ts,
value: if total.value > 0.0 {
(errors.value / total.value) * 100.0
} else {
0.0
},
},
)
.collect();
http_json.insert(
"requests".into(),
serde_json::Value::Array(points_to_json(&http.request_rate_ts)),
);
http_json.insert(
"errors_5xx".into(),
serde_json::Value::Array(points_to_json(&http.error_rate_ts)),
);
http_json.insert(
"error_rate_pct".into(),
serde_json::Value::Array(points_to_json(&error_rate_pct)),
);
http_json.insert(
"status_2xx".into(),
serde_json::Value::Array(points_to_json(&http.status_2xx_ts)),
);
http_json.insert(
"status_3xx".into(),
serde_json::Value::Array(points_to_json(&http.status_3xx_ts)),
);
http_json.insert(
"status_4xx".into(),
serde_json::Value::Array(points_to_json(&http.status_4xx_ts)),
);
http_json.insert(
"status_5xx".into(),
serde_json::Value::Array(points_to_json(&http.status_5xx_ts)),
);
http_json.insert(
"p50_ms".into(),
serde_json::Value::Array(points_to_json(&http.p50_ts)),
);
http_json.insert(
"p90_ms".into(),
serde_json::Value::Array(points_to_json(&http.p90_ts)),
);
http_json.insert(
"p95_ms".into(),
serde_json::Value::Array(points_to_json(&http.p95_ts)),
);
http_json.insert(
"p99_ms".into(),
serde_json::Value::Array(points_to_json(&http.p99_ts)),
);
json.insert("http".into(), serde_json::Value::Object(http_json));
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(json))?
);
Ok(())
}
fn print_project_terminal(
project_name: &str,
environment_name: &str,
window_label: &str,
sections: &Sections,
services: &[ServiceMetricsSummary],
) {
println!(
"\nMetrics for {} in {} ({})\n",
project_name.bold(),
environment_name.blue().bold(),
window_label
);
if services.is_empty() {
println!(" No metrics data available.");
return;
}
let mut header = format!(" {:<25}", "Service");
if sections.cpu {
header.push_str(&format!("{:<28}", "CPU"));
}
if sections.memory {
header.push_str(&format!("{:<28}", "Memory"));
}
if sections.network {
header.push_str(&format!("{:<14}{:<14}", "Egress", "Ingress"));
}
if sections.volume {
header.push_str(&format!("{:<22}", "Volume"));
}
if sections.http {
header.push_str(&format!("{:<12}{:<12}", "Reqs", "Err Rate"));
}
println!("{}", header.bold());
println!(" {}", "─".repeat(header.len().saturating_sub(2)));
for svc in services {
let name = truncate(&svc.service_name, 23);
print!(" {:<25}", name);
if sections.cpu {
let (val, pct) = match (&svc.cpu, &svc.cpu_limit) {
(Some(cpu), Some(limit)) if limit.current > 0.0 => {
let p = (cpu.current / limit.current) * 100.0;
(
format!(
"{} / {}",
format_cpu(cpu.current),
format_cpu(limit.current)
),
Some(p),
)
}
(Some(cpu), _) => (format_cpu(cpu.current), None),
_ => ("—".to_string(), None),
};
let display = match pct {
Some(p) => format!("{}", color_by_health(&val, p)),
None => val.clone(),
};
let padding = 28usize.saturating_sub(val.len());
print!("{display}{:padding$}", "");
}
if sections.memory {
let (val, pct) = match (&svc.memory, &svc.memory_limit) {
(Some(mem), Some(limit)) if limit.current > 0.0 => {
let p = (mem.current / limit.current) * 100.0;
(
format!("{} / {}", format_gb(mem.current), format_gb(limit.current)),
Some(p),
)
}
(Some(mem), _) => (format_gb(mem.current), None),
_ => ("—".to_string(), None),
};
let display = match pct {
Some(p) => format!("{}", color_by_health(&val, p)),
None => val.clone(),
};
let padding = 28usize.saturating_sub(val.len());
print!("{display}{:padding$}", "");
}
if sections.network {
let tx_str = svc
.network_tx
.as_ref()
.filter(|t| t.current > 0.0001)
.map(|t| format_gb(t.current))
.unwrap_or_else(|| "—".to_string());
let rx_str = svc
.network_rx
.as_ref()
.filter(|r| r.current > 0.0001)
.map(|r| format_gb(r.current))
.unwrap_or_else(|| "—".to_string());
print!("{:<14}{:<14}", tx_str, rx_str);
}
if sections.volume {
if svc.volumes.is_empty() {
print!("{:<22}", "—");
} else {
let vol = &svc.volumes[0];
if vol.limit_size_mb > 0.0 {
let pct = (vol.current_size_mb / vol.limit_size_mb) * 100.0;
let val = format!(
"{} / {}",
format_mb(vol.current_size_mb),
format_mb(vol.limit_size_mb)
);
let display = format!("{}", color_by_health(&val, pct));
let padding = 22usize.saturating_sub(val.len());
print!("{display}{:padding$}", "");
} else {
print!("{:<22}", format_mb(vol.current_size_mb));
}
}
}
if sections.http {
if svc.is_database {
print!("{:<12}{:<12}", "—", "—");
} else if let Some(ref http) = svc.http {
let reqs = format_count(http.total);
let err_val = format!("{:.1}%", http.error_rate);
let err_display = format!("{}", color_by_health(&err_val, http.error_rate * 10.0));
let err_padding = 12usize.saturating_sub(err_val.len());
print!("{:<12}{err_display}{:err_padding$}", reqs, "");
} else {
print!("{:<12}{:<12}", "—", "—");
}
}
println!();
}
println!();
}
fn print_project_json(
project_name: &str,
environment_name: &str,
start_date: chrono::DateTime<chrono::Utc>,
end_date: Option<chrono::DateTime<chrono::Utc>>,
sections: &Sections,
services: &[ServiceMetricsSummary],
) -> Result<()> {
let svc_arr: Vec<serde_json::Value> = services
.iter()
.map(|svc| {
let mut obj = serde_json::json!({
"name": svc.service_name,
"id": svc.service_id,
});
if let Some(cpu) = sections.cpu.then_some(svc.cpu.as_ref()).flatten() {
let mut cpu_json = serde_json::json!({
"current": cpu.current,
"unit": "vCPU",
});
if let Some(ref limit) = svc.cpu_limit {
if limit.current > 0.0 {
cpu_json["limit"] = serde_json::json!(limit.current);
if let Some(pct) = utilization(cpu.current, Some(limit.current)) {
cpu_json["utilization_pct"] =
serde_json::json!((pct * 10.0).round() / 10.0);
}
}
}
obj["cpu"] = cpu_json;
}
if let Some(mem) = sections.memory.then_some(svc.memory.as_ref()).flatten() {
let mut mem_json = serde_json::json!({
"current_mb": mem.current * 1024.0,
});
if let Some(ref limit) = svc.memory_limit {
if limit.current > 0.0 {
mem_json["limit_mb"] = serde_json::json!(limit.current * 1024.0);
if let Some(pct) = utilization(mem.current, Some(limit.current)) {
mem_json["utilization_pct"] =
serde_json::json!((pct * 10.0).round() / 10.0);
}
}
}
obj["memory"] = mem_json;
}
if sections.network && (svc.network_tx.is_some() || svc.network_rx.is_some()) {
let mut net = serde_json::Map::new();
if let Some(ref tx) = svc.network_tx {
net.insert("egress_mb".into(), serde_json::json!(tx.current * 1024.0));
}
if let Some(ref rx) = svc.network_rx {
net.insert("ingress_mb".into(), serde_json::json!(rx.current * 1024.0));
}
obj["network"] = serde_json::Value::Object(net);
}
if sections.volume && !svc.volumes.is_empty() {
let vol_arr: Vec<serde_json::Value> = svc
.volumes
.iter()
.map(|v| {
serde_json::json!({
"name": v.volume_name,
"mount_path": v.mount_path,
"current_mb": v.current_size_mb,
"limit_mb": v.limit_size_mb,
})
})
.collect();
obj["volumes"] = serde_json::Value::Array(vol_arr);
}
if let Some(http) = sections.http.then_some(svc.http.as_ref()).flatten() {
obj["http"] = serde_json::json!({
"total": http.total,
"2xx": http.status_counts[2],
"3xx": http.status_counts[3],
"4xx": http.status_counts[4],
"5xx": http.status_counts[5],
"error_rate": http.error_rate,
"p50_ms": http.p50_ms,
"p90_ms": http.p90_ms,
"p95_ms": http.p95_ms,
"p99_ms": http.p99_ms,
});
}
obj
})
.collect();
let json = serde_json::json!({
"project": project_name,
"environment": environment_name,
"window": {
"since": start_date.to_rfc3339(),
"until": end_date.unwrap_or_else(chrono::Utc::now).to_rfc3339(),
},
"services": svc_arr,
});
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
fn print_raw_terminal(resource: Option<&ResourceMetricsResult>, http: Option<&HttpMetricsResult>) {
fn print_raw_points(
name: &str,
points: &[crate::controllers::metrics::MetricDataPoint],
printed: &mut bool,
) {
for point in points {
let ts = chrono::DateTime::from_timestamp(point.ts, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| point.ts.to_string());
println!("{} {:<20} {:.6}", ts, name, point.value);
*printed = true;
}
}
let mut printed = false;
if let Some(res) = resource {
for metric in &res.metrics {
print_raw_points(&metric.measurement, &metric.raw_values, &mut printed);
}
}
if let Some(http) = http.and_then(|result| result.time_series.as_ref()) {
print_raw_points("HTTP_REQUESTS", &http.request_rate_ts, &mut printed);
print_raw_points("HTTP_5XX", &http.error_rate_ts, &mut printed);
print_raw_points("HTTP_2XX", &http.status_2xx_ts, &mut printed);
print_raw_points("HTTP_3XX", &http.status_3xx_ts, &mut printed);
print_raw_points("HTTP_4XX", &http.status_4xx_ts, &mut printed);
print_raw_points("HTTP_5XX_BUCKET", &http.status_5xx_ts, &mut printed);
print_raw_points("HTTP_P50_MS", &http.p50_ts, &mut printed);
print_raw_points("HTTP_P90_MS", &http.p90_ts, &mut printed);
print_raw_points("HTTP_P95_MS", &http.p95_ts, &mut printed);
print_raw_points("HTTP_P99_MS", &http.p99_ts, &mut printed);
for (errors, total) in http.error_rate_ts.iter().zip(http.request_rate_ts.iter()) {
let pct = if total.value > 0.0 {
(errors.value / total.value) * 100.0
} else {
0.0
};
print_raw_points(
"HTTP_ERROR_RATE_PCT",
&[crate::controllers::metrics::MetricDataPoint {
ts: errors.ts,
value: pct,
}],
&mut printed,
);
}
}
if !printed {
println!("No data points available.");
}
}
fn print_db_stats_terminal(stats: &DatabaseStats) {
use crate::controllers::db_stats::types::*;
fn health_color(value: f64, warn: f64, crit: f64, inverted: bool) -> colored::ColoredString {
let s = format!("{:.1}%", value);
if inverted {
if value < crit {
s.red()
} else if value < warn {
s.yellow()
} else {
s.green()
}
} else {
if value > crit {
s.red()
} else if value > warn {
s.yellow()
} else {
s.green()
}
}
}
fn fmt_bytes(bytes: i64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.2} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
println!();
println!(" {}", "Database Stats".bold());
println!(" ─────────────────────────────────────────────────────────────────────");
match stats {
DatabaseStats::PostgreSQL(pg) => {
let util = if pg.connections.max_connections > 0 {
pg.connections.total as f64 / pg.connections.max_connections as f64 * 100.0
} else {
0.0
};
println!(
" {} Active: {} Idle: {} Idle(txn): {} Total: {} / {} ({})",
"Connections".bold(),
format!("{}", pg.connections.active).cyan(),
pg.connections.idle,
pg.connections.idle_in_transaction,
pg.connections.total,
pg.connections.max_connections,
health_color(util, 60.0, 80.0, false),
);
println!(
" {} Hit Ratio: {}",
"Cache ".bold(),
health_color(pg.cache.hit_ratio * 100.0, 95.0, 90.0, true),
);
println!(
" {} Total: {} Tables: {} Indexes: {}",
"Storage ".bold(),
fmt_bytes(pg.database_size.total_bytes).cyan(),
fmt_bytes(pg.database_size.tables_bytes),
fmt_bytes(pg.database_size.indexes_bytes),
);
if !pg.table_stats.is_empty() {
println!();
println!(
" {}",
" Table Size Seq Scan Idx Scan Dead Rows"
.dimmed()
);
for t in &pg.table_stats {
let dead_pct = if t.live_tuples + t.dead_tuples > 0 {
t.dead_tuples as f64 / (t.live_tuples + t.dead_tuples) as f64 * 100.0
} else {
0.0
};
let dead_str = if dead_pct > 10.0 {
format!("{:.1}%", dead_pct).red().to_string()
} else if dead_pct > 5.0 {
format!("{:.1}%", dead_pct).yellow().to_string()
} else {
format!("{:.1}%", dead_pct)
};
println!(
" {:24} {:12} {:11} {:11} {}",
truncate(&t.table_name, 24),
fmt_bytes(t.size_bytes),
format_count(t.seq_scan as usize),
format_count(t.idx_scan as usize),
dead_str,
);
}
}
if let Some(ref queries) = pg.query_stats {
if !queries.is_empty() {
println!();
println!(
" {} {}",
"Top Queries".bold(),
"(pg_stat_statements)".dimmed()
);
println!(" {}", " Calls Total Mean Query".dimmed());
for q in queries {
println!(
" {:9} {:11} {:9} {}",
format_count(q.calls as usize),
format_duration_ms(q.total_time_ms),
format_duration_ms(q.mean_time_ms),
truncate(&q.query, 60),
);
}
}
}
if !pg.index_health.unused_indexes.is_empty() {
println!();
println!(
" {} {} unused out of {}",
"Indexes ".bold(),
format!("{}", pg.index_health.unused_indexes.len()).yellow(),
pg.index_health.total_index_count,
);
}
}
DatabaseStats::Redis(r) => {
println!(
" {} Version: {} Uptime: {} Clients: {} Blocked: {}",
"Server ".bold(),
r.server.version.cyan(),
format_uptime(r.server.uptime_seconds),
r.server.connected_clients,
r.server.blocked_clients,
);
println!(
" {} Used: {} RSS: {} Peak: {} Frag: {:.2}x Policy: {}",
"Memory ".bold(),
fmt_bytes(r.memory.used_bytes).cyan(),
fmt_bytes(r.memory.rss_bytes),
fmt_bytes(r.memory.peak_bytes),
r.memory.fragmentation_ratio,
r.memory.eviction_policy,
);
println!(
" {} Ops/sec: {} Total Cmds: {} Total Conns: {}",
"Throughput ".bold(),
format!("{:.0}", r.throughput.ops_per_sec).cyan(),
format_count(r.throughput.total_commands as usize),
format_count(r.throughput.total_connections as usize),
);
println!(
" {} Hit Rate: {} Hits: {} Misses: {} Expired: {} Evicted: {}",
"Cache ".bold(),
health_color(r.cache.hit_rate * 100.0, 95.0, 90.0, true),
format_count(r.cache.hits as usize),
format_count(r.cache.misses as usize),
format_count(r.cache.expired_keys as usize),
format_count(r.cache.evicted_keys as usize),
);
println!(
" {} RDB: {} AOF: {}",
"Persistence".bold(),
r.persistence.rdb_last_save_status,
if r.persistence.aof_enabled {
"enabled"
} else {
"disabled"
},
);
if !r.keyspace.is_empty() {
println!();
println!(" {}", " DB Keys Expires Avg TTL".dimmed());
for db in &r.keyspace {
println!(
" db{:<4} {:10} {:10} {}",
db.db_index,
format_count(db.keys as usize),
format_count(db.expires as usize),
if db.avg_ttl > 0 {
format_duration_ms(db.avg_ttl as f64)
} else {
"-".to_string()
},
);
}
}
}
DatabaseStats::MySQL(my) => {
let util = if my.connections.max_connections > 0 {
my.connections.threads_connected as f64 / my.connections.max_connections as f64
* 100.0
} else {
0.0
};
println!(
" {} Connected: {} Running: {} Max Used: {} Max: {} ({})",
"Connections".bold(),
format!("{}", my.connections.threads_connected).cyan(),
my.connections.threads_running,
my.connections.max_used_connections,
my.connections.max_connections,
health_color(util, 60.0, 80.0, false),
);
println!(
" {} Hit Ratio: {} Usage: {:.1}% Size: {}",
"Buffer Pool".bold(),
health_color(my.buffer_pool.hit_ratio * 100.0, 95.0, 90.0, true),
my.buffer_pool.usage_pct,
fmt_bytes(my.buffer_pool.total_bytes),
);
println!(
" {} SELECT: {} INSERT: {} UPDATE: {} DELETE: {} Slow: {}",
"Queries ".bold(),
format_count(my.queries.selects as usize),
format_count(my.queries.inserts as usize),
format_count(my.queries.updates as usize),
format_count(my.queries.deletes as usize),
if my.queries.slow_queries > 0 {
format!("{}", my.queries.slow_queries).yellow().to_string()
} else {
"0".to_string()
},
);
println!(
" {} Reads: {} Inserts: {} Updates: {} Deletes: {}",
"InnoDB Rows".bold(),
format_count(my.innodb.row_reads as usize),
format_count(my.innodb.row_inserts as usize),
format_count(my.innodb.row_updates as usize),
format_count(my.innodb.row_deletes as usize),
);
if !my.table_sizes.is_empty() {
println!();
println!(
" {}",
" Table Data Index Total".dimmed()
);
for t in &my.table_sizes {
println!(
" {:24} {:12} {:12} {}",
truncate(&t.table_name, 24),
fmt_bytes(t.data_bytes),
fmt_bytes(t.index_bytes),
fmt_bytes(t.total_bytes),
);
}
}
}
DatabaseStats::MongoDB(m) => {
println!(
" {} Current: {} Available: {} Total Created: {}",
"Connections".bold(),
format!("{}", m.connections.current).cyan(),
m.connections.available,
format_count(m.connections.total_created as usize),
);
println!(
" {} Insert: {} Query: {} Update: {} Delete: {} Command: {}",
"Operations ".bold(),
format_count(m.operations.insert as usize),
format_count(m.operations.query as usize),
format_count(m.operations.update as usize),
format_count(m.operations.delete as usize),
format_count(m.operations.command as usize),
);
println!(
" {} Resident: {} MB Virtual: {} MB",
"Memory ".bold(),
format!("{}", m.memory.resident_mb).cyan(),
m.memory.virtual_mb,
);
if m.wired_tiger.cache_max_bytes > 0 {
let util = m.wired_tiger.cache_used_bytes as f64
/ m.wired_tiger.cache_max_bytes as f64
* 100.0;
println!(
" {} Used: {} / {} ({})",
"WT Cache ".bold(),
fmt_bytes(m.wired_tiger.cache_used_bytes),
fmt_bytes(m.wired_tiger.cache_max_bytes),
health_color(util, 80.0, 95.0, false),
);
}
if !m.collection_stats.is_empty() {
println!();
println!(
" {}",
" Collection Size Count Indexes".dimmed()
);
for c in &m.collection_stats {
println!(
" {:24} {:12} {:10} {}",
truncate(&c.name, 24),
fmt_bytes(c.size_bytes),
format_count(c.count as usize),
c.index_count,
);
}
}
}
}
println!();
}
fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else if max_len <= 3 {
".".repeat(max_len)
} else {
format!(
"{}...",
s.chars()
.take(max_len.saturating_sub(3))
.collect::<String>()
)
}
}
fn format_duration_ms(ms: f64) -> String {
if ms >= 1000.0 {
format!("{:.1}s", ms / 1000.0)
} else if ms >= 1.0 {
format!("{:.1}ms", ms)
} else {
format!("{:.0}us", ms * 1000.0)
}
}
fn format_uptime(seconds: i64) -> String {
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let mins = (seconds % 3600) / 60;
if days > 0 {
format!("{}d {}h", days, hours)
} else if hours > 0 {
format!("{}h {}m", hours, mins)
} else {
format!("{}m", mins)
}
}
fn detect_database_type(source_image: Option<&str>) -> Option<DatabaseType> {
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;
#[test]
fn window_label_includes_until_when_present() {
assert_eq!(
format_window_label("1h", Some("30m")),
"from 1h ago until 30m ago"
);
}
#[test]
fn window_label_preserves_unbounded_relative_and_absolute_since() {
assert_eq!(format_window_label("6h", None), "last 6h");
assert_eq!(
format_window_label("2024-01-15T10:00:00Z", None),
"since 2024-01-15T10:00:00Z"
);
}
}