use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::output::{self, Format};
use crate::render::table::{truncate, Cell, Table};
use crate::render::theme;
use crate::render::time;
use crate::render::time::{epoch_millis, format_millis};
pub async fn status(client: &ApiClient, format: Format) -> Result<()> {
let (projects, version) = tokio::join!(client.projects(), client.version());
let projects = projects?;
let version = version.ok();
if format.is_json() {
output::emit(&serde_json::json!({
"url": client.base(),
"cli": env!("CARGO_PKG_VERSION"),
"server": version,
"projects": projects,
}));
return Ok(());
}
let services: u32 = projects.iter().map(|project| project.service_count).sum();
let online: u32 = projects.iter().map(|project| project.online_count).sum();
println!();
println!(" {}", client.base().style(theme::strong()));
if let Some(version) = version {
let mine = env!("CARGO_PKG_VERSION");
let note = if version.platform == mine {
String::new()
} else {
format!(" · este binario es {mine}")
};
println!(
" {}{}",
format!("servidor {}", version.platform).style(theme::muted()),
note.style(theme::muted()),
);
}
println!(
" {} proyectos · {} servicios · {} en marcha",
projects.len().style(theme::strong()),
services.style(theme::strong()),
if online == services && services > 0 {
format!("{}", online.style(theme::success()))
} else if online == 0 && services > 0 {
format!("{}", online.style(theme::danger()))
} else {
format!("{}", online.style(theme::warning()))
},
);
println!();
let mut table = Table::new(&["proyecto", "entorno", "servicios", "estado"]);
for project in &projects {
let dots: String = project
.services
.iter()
.map(|service| theme::state_dot(&service.state))
.collect::<Vec<_>>()
.join(" ");
table.push(vec![
Cell::styled(project.name.clone(), theme::strong()),
Cell::styled(project.environment_name.clone(), theme::muted()),
Cell::plain(format!(
"{}/{}",
project.online_count, project.service_count
)),
Cell::plain(dots),
]);
}
if table.is_empty() {
println!(
" {}",
"Todavía no hay ningún proyecto.".style(theme::muted())
);
} else {
table.print();
}
println!();
Ok(())
}
pub async fn projects(client: &ApiClient, format: Format) -> Result<()> {
let projects = client.projects().await?;
if format.is_json() {
output::emit(&projects);
return Ok(());
}
let mut table = Table::new(&["id", "nombre", "slug", "servicios"]);
for project in &projects {
table.push(vec![
Cell::styled(project.id.clone(), theme::muted()),
Cell::styled(project.name.clone(), theme::strong()),
Cell::plain(project.slug.clone()),
Cell::plain(format!(
"{}/{}",
project.online_count, project.service_count
)),
]);
}
if table.is_empty() {
println!("Todavía no hay ningún proyecto.");
return Ok(());
}
table.print();
Ok(())
}
pub async fn services(client: &ApiClient, project_reference: &str, format: Format) -> Result<()> {
let project = client.find_project(project_reference).await?;
let page = client.services(&project.id).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
let mut table = Table::new(&["servicio", "estado", "origen", "host", "depende de"]);
for service in &page.services {
let dependencies: Vec<String> = page
.edges
.iter()
.filter(|edge| edge.from == service.id)
.filter_map(|edge| page.services.iter().find(|target| target.id == edge.to))
.map(|target| target.name.clone())
.collect();
table.push(vec![
Cell::styled(service.name.clone(), theme::strong()),
Cell::styled(
format!(
"{} {}",
theme::state_dot(&service.state),
theme::state_word(&service.state)
),
theme::state_style(&service.state),
),
Cell::styled(
service
.repo_full_name
.clone()
.or_else(|| service.brand.clone())
.unwrap_or_else(|| "—".to_owned()),
theme::muted(),
),
Cell::styled(
service.host.clone().unwrap_or_else(|| "—".to_owned()),
theme::muted(),
),
Cell::plain(if dependencies.is_empty() {
"—".to_owned()
} else {
dependencies.join(", ")
}),
]);
}
if table.is_empty() {
println!("`{}` todavía no tiene servicios.", project.name);
return Ok(());
}
table.print();
Ok(())
}
pub async fn service(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let detail = client.service_detail(&found.id).await?;
if format.is_json() {
output::emit(&detail);
return Ok(());
}
println!();
println!(
" {} {} {}",
theme::state_dot(&detail.environment.state),
detail.service.name.style(theme::strong()),
theme::state_word(&detail.environment.state)
.style(theme::state_style(&detail.environment.state)),
);
println!(
" {} {}",
"id".style(theme::muted()),
detail.environment.id.style(theme::muted())
);
println!(
" {} {} MB · {} CPU · {}",
"recursos".style(theme::muted()),
detail.environment.memory_mb,
detail.environment.cpus,
if detail.environment.sleep_enabled {
"duerme sin tráfico"
} else {
"siempre despierto"
},
);
for domain in detail.domains.iter().filter(|domain| domain.enabled) {
println!(
" {} {}",
"host".style(theme::muted()),
domain.url.style(theme::accent())
);
}
if let Some(deployment) = &detail.active_deployment {
println!(
" {} #{} {}",
"activo".style(theme::muted()),
deployment.number,
deployment
.commit_subject
.clone()
.unwrap_or_else(|| deployment.trigger.clone())
.style(theme::muted()),
);
}
println!();
Ok(())
}
pub async fn history(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
limit: usize,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let deployments = client.deployments(&found.id).await?;
if format.is_json() {
let limited: Vec<_> = deployments.iter().take(limit).collect();
output::emit(&limited);
return Ok(());
}
let mut table = Table::new(&["#", "estado", "motivo", "commit", "duración", "asunto"]);
for deployment in deployments.iter().take(limit) {
let duration = match (
&deployment.finished_at,
epoch_millis(&deployment.created_at),
) {
(Some(finished), Some(created)) => epoch_millis(finished)
.map(|end| format_millis(end - created))
.unwrap_or_else(|| "—".to_owned()),
_ => "—".to_owned(),
};
table.push(vec![
Cell::plain(format!("{}", deployment.number)),
Cell::styled(
deployment.status.clone(),
theme::deployment_status_style(&deployment.status),
),
Cell::styled(deployment.trigger.clone(), theme::muted()),
Cell::styled(
deployment
.commit_sha
.as_ref()
.map(|sha| sha.chars().take(7).collect::<String>())
.unwrap_or_else(|| "—".to_owned()),
theme::muted(),
),
Cell::plain(duration),
Cell::plain(truncate(
deployment
.failure_message
.as_ref()
.or(deployment.commit_subject.as_ref())
.map(String::as_str)
.unwrap_or("—"),
52,
)),
]);
}
if table.is_empty() {
println!("`{}` no se ha desplegado nunca.", found.name);
return Ok(());
}
table.print();
Ok(())
}
pub async fn metrics(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
minutes: u32,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let samples = client.usage(&found.id, minutes).await?;
if format.is_json() {
output::emit(&samples);
return Ok(());
}
let Some(latest) = samples.last() else {
println!("Todavía no hay medidas de `{}`.", found.name);
return Ok(());
};
let share = (latest.memory_bytes * 100)
.checked_div(latest.memory_limit_bytes)
.unwrap_or(0) as u32;
println!();
println!(
" {} {} mCPU",
"cpu ".style(theme::muted()),
latest.cpu_millicores.style(theme::strong())
);
println!(
" {} {} MB de {} MB",
"memoria".style(theme::muted()),
(latest.memory_bytes / 1_000_000).style(theme::strong()),
latest.memory_limit_bytes / 1_000_000,
);
println!(" {} {}", "límite ".style(theme::muted()), bar(share));
println!();
let cpu: Vec<u64> = samples
.iter()
.map(|sample| u64::from(sample.cpu_millicores))
.collect();
let memory: Vec<u64> = samples
.iter()
.map(|sample| sample.memory_bytes / 1_000_000)
.collect();
println!(" {} {}", "cpu ".style(theme::muted()), sparkline(&cpu));
println!(
" {} {}",
"memoria".style(theme::muted()),
sparkline(&memory)
);
println!(
" {}",
format!("{} medidas de los últimos {minutes} minutos", samples.len()).style(theme::muted()),
);
println!();
Ok(())
}
fn bar(percent: u32) -> String {
let filled = (percent.min(100) as usize) / 5;
let style = if percent > 90 {
theme::danger()
} else if percent > 70 {
theme::warning()
} else {
theme::success()
};
format!(
"{}{} {percent}%",
"█".repeat(filled).style(style),
"░".repeat(20 - filled).style(theme::muted()),
)
}
pub fn sparkline(values: &[u64]) -> String {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let Some(highest) = values.iter().copied().max() else {
return String::new();
};
if highest == 0 {
return BLOCKS[0].to_string().repeat(values.len());
}
values
.iter()
.map(|value| {
let index = ((value * 7) / highest) as usize;
BLOCKS[index.min(7)]
})
.collect()
}
pub async fn variables(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
reveal: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let variables = client.variables(&found.id).await?;
if format.is_json() {
let mut entries = Vec::with_capacity(variables.len());
for variable in &variables {
let mut entry = serde_json::to_value(variable)?;
if reveal && variable.kind == "secret" && !variable.write_only && !variable.is_sealed()
{
if let Ok(value) = client.reveal_variable(&variable.id).await {
entry["value"] = value.into();
}
}
entries.push(entry);
}
output::emit(&entries);
return Ok(());
}
let mut table = Table::new(&["clave", "tipo", "valor"]);
for variable in &variables {
let value =
if reveal && variable.kind == "secret" && !variable.write_only && !variable.is_sealed()
{
client
.reveal_variable(&variable.id)
.await
.unwrap_or_else(|_| "—".to_owned())
} else if variable.is_sealed() {
"sellada · solo se puede borrar".to_owned()
} else {
variable
.preview
.clone()
.unwrap_or_else(|| "••••••••".to_owned())
};
let kind = if variable.is_sealed() {
"sellada".to_owned()
} else {
variable.kind.clone()
};
table.push(vec![
Cell::styled(variable.key.clone(), theme::strong()),
Cell::styled(
kind,
match (variable.is_sealed(), variable.kind.as_str()) {
(true, _) => theme::warning(),
(false, "reference") => theme::accent(),
(false, "secret") => theme::warning(),
_ => theme::muted(),
},
),
Cell::plain(truncate(&value, 60)),
]);
}
if table.is_empty() {
println!("`{}` no define ninguna variable.", found.name);
return Ok(());
}
table.print();
Ok(())
}
pub async fn domains(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let domains = client.domains(&found.id).await?;
if format.is_json() {
output::emit(&domains);
return Ok(());
}
let mut table = Table::new(&["host", "tipo", "estado", "id"]);
for domain in &domains {
let (state, style) = if domain.enabled {
(
domain.state.clone(),
match domain.state.as_str() {
"active" => theme::success(),
"failed" => theme::danger(),
_ => theme::warning(),
},
)
} else {
("desactivado".to_string(), theme::muted())
};
table.push(vec![
Cell::styled(
domain.host.clone(),
if domain.enabled {
theme::strong()
} else {
theme::muted()
},
),
Cell::styled(domain.kind.clone(), theme::muted()),
Cell::styled(state, style),
Cell::styled(domain.id.clone(), theme::muted()),
]);
}
if table.is_empty() {
println!("`{}` no responde en ningún host.", found.name);
return Ok(());
}
table.print();
if domains.iter().all(|domain| !domain.enabled) {
println!();
println!(
" {}",
"El servicio es privado. Sus dominios vuelven con `fundaia expose`."
.style(theme::muted()),
);
}
for domain in domains
.iter()
.filter(|domain| domain.enabled && domain.dns_instructions.is_some())
{
println!();
println!(
" {} {}",
"!".style(theme::warning()),
domain.dns_instructions.clone().unwrap_or_default(),
);
}
Ok(())
}
pub async fn volumes(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let volumes = client.volumes(&found.id).await?;
if format.is_json() {
output::emit(&volumes);
return Ok(());
}
let mut table = Table::new(&["ruta", "nombre", "montado", "id"]);
for volume in &volumes {
table.push(vec![
Cell::styled(volume.mount_path.clone(), theme::strong()),
Cell::plain(volume.name.clone()),
Cell::styled(
if volume.mounted {
"sí"
} else {
"en el próximo despliegue"
}
.to_owned(),
if volume.mounted {
theme::success()
} else {
theme::warning()
},
),
Cell::styled(volume.id.clone(), theme::muted()),
]);
}
if table.is_empty() {
println!("`{}` no tiene ningún volumen.", found.name);
return Ok(());
}
table.print();
Ok(())
}
pub async fn recipe(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
plain: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let recipe = client.recipe(&found.id).await?;
if format.is_json() && !plain {
output::emit(&recipe);
return Ok(());
}
if plain {
print!("{}", recipe.containerfile);
return Ok(());
}
println!();
println!(
" {} {}",
recipe.builder.style(theme::strong()),
match recipe.source.as_str() {
"edited" => "editado a mano — no se vuelve a generar".to_owned(),
_ => "generado".to_owned(),
}
.style(theme::muted()),
);
if let Some(port) = recipe.exposed_port {
println!(" {}", format!("puerto {port}").style(theme::muted()));
}
for warning in &recipe.warnings {
println!(" {} {}", "!".style(theme::warning()), warning);
}
println!();
for line in recipe.containerfile.lines() {
println!(" {line}");
}
println!();
for note in &recipe.notes {
println!(" {}", note.style(theme::muted()));
}
Ok(())
}
pub async fn backups(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let page = client.backups(&found.id).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
if !page.supported {
println!(
"`{}` no tiene volumen, así que no hay nada que copiar.",
found.name
);
return Ok(());
}
println!();
println!(
" {} {}",
"programación".style(theme::muted()),
if page.schedules.is_empty() {
"ninguna — este servicio no se copia solo".to_owned()
} else {
page.schedules.join(", ")
}
.style(theme::strong()),
);
if let Some(next) = &page.next_at {
println!(
" {} {}",
"siguiente".style(theme::muted()),
next.style(theme::muted())
);
}
println!();
let mut table = Table::new(&["id", "origen", "tamaño", "estado", "hecha"]);
for backup in &page.backups {
table.push(vec![
Cell::styled(backup.id.clone(), theme::muted()),
Cell::plain(match &backup.schedule {
Some(schedule) => schedule.clone(),
None => "a mano".to_owned(),
}),
Cell::plain(human_size(backup.size_bytes)),
Cell::styled(
if backup.locked {
format!("{} · guardada", backup.status)
} else {
backup.status.clone()
},
match backup.status.as_str() {
"ready" => theme::success(),
"failed" => theme::danger(),
_ => theme::warning(),
},
),
Cell::styled(backup.created_at.clone(), theme::muted()),
]);
}
if table.is_empty() {
println!("Todavía no hay ninguna copia de `{}`.", found.name);
return Ok(());
}
table.print();
Ok(())
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{size:.1} {}", UNITS[unit])
}
}
pub async fn activity(
client: &ApiClient,
project_reference: &str,
limit: usize,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let entries = client.activity(&project.id, limit).await?;
if format.is_json() {
output::emit(&entries);
return Ok(());
}
if entries.is_empty() {
println!("Todavía no se ha hecho nada en `{}`.", project.name);
return Ok(());
}
println!();
let mut table = Table::new(&["cuándo", "quién", "qué"]);
for entry in &entries {
table.push(vec![
Cell::styled(
time::relative_time(&entry.at).unwrap_or_else(|| "—".to_string()),
theme::muted(),
),
Cell::styled(
entry
.actor
.display_name
.clone()
.unwrap_or_else(|| entry.actor.id.clone()),
theme::strong(),
),
Cell::plain(entry.summary.clone()),
]);
}
table.print();
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_draw_one_block_per_reading() {
assert_eq!(sparkline(&[1, 2, 3]).chars().count(), 3);
}
#[test]
fn it_should_draw_the_largest_reading_at_full_height() {
assert!(sparkline(&[1, 10]).ends_with('█'));
}
#[test]
fn it_should_draw_a_flat_series_of_zeroes_at_the_floor() {
assert_eq!(sparkline(&[0, 0, 0]), "▁▁▁");
}
#[test]
fn it_should_draw_nothing_for_no_readings() {
assert_eq!(sparkline(&[]), "");
}
#[test]
fn it_should_fill_a_full_meter_completely() {
assert!(bar(100).contains(&"█".repeat(20)));
}
#[test]
fn it_should_leave_an_empty_meter_unfilled() {
assert!(bar(0).contains(&"░".repeat(20)));
}
}