use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::api::models::{Backup, CanvasNode, Domain, Volume};
use crate::output::{self, Format};
use crate::render::diff;
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 {
sleep_phrase(detail.environment.sleep_after_minutes)
} else {
"siempre despierto".to_owned()
},
);
if let Some(capabilities) = &detail.capabilities {
let mut parts: Vec<String> = Vec::new();
if let Some(framework) = &capabilities.framework {
parts.push(match &capabilities.framework_version {
Some(version) => format!("{framework} {version}"),
None => framework.clone(),
});
}
if let Some(port) = capabilities.declared_port {
parts.push(format!(":{port}"));
}
for endpoint in capabilities.endpoints.iter().take(3) {
parts.push(endpoint.path.clone());
}
if !parts.is_empty() {
println!(" {} {}", "ofrece".style(theme::muted()), parts.join(" · "));
}
}
println!(
" {} {}{}",
"salud".style(theme::muted()),
match &detail.environment.health_path {
Some(path) => path.clone(),
None => match detail.environment.health_probe_resolved.as_deref() {
Some("tcp") => "el puerto acepta conexiones".to_owned(),
Some("none") => "solo que siga en pie".to_owned(),
_ => "/".to_owned(),
},
},
match (
detail.environment.health_path.as_deref(),
detail.environment.health_path_origin.as_deref(),
) {
(Some(_), Some("detected")) => " (detectada)".style(theme::muted()).to_string(),
_ => String::new(),
},
);
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
.live_deployment
.as_ref()
.or(detail.active_deployment.as_ref())
{
println!(
" {} #{} {}",
"desplegado".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 commit(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
sha: Option<String>,
file: Option<String>,
stat: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let sha = match sha {
Some(given) => given,
None => {
let deployments = client.deployments(&found.id).await?;
let Some(shipped) = deployments.iter().find_map(|one| one.commit_sha.clone()) else {
println!("`{}` no ha desplegado nunca un commit.", found.name);
return Ok(());
};
shipped
}
};
let answer = client.commit_change(&found.id, &sha).await?;
if format.is_json() {
output::emit(&answer);
return Ok(());
}
if let Some(unavailable) = &answer.unavailable {
println!("{}", unavailable.message);
return Ok(());
}
let Some(change) = &answer.commit else {
println!("GitHub no ha dicho nada de ese commit.");
return Ok(());
};
print_commit_heading(change);
if let Some(path) = &file {
let Some(only) = change.files.iter().find(|one| &one.path == path) else {
println!("`{path}` no está entre los ficheros de este commit.");
return Ok(());
};
diff::print_file(only);
return Ok(());
}
let mut table = Table::new(&["estado", "", "+", "−", "fichero"]);
for one in &change.files {
table.push(vec![
Cell::plain(diff::status_word(&one.status).to_owned()),
Cell::plain(diff::proportion(one.additions, one.deletions)),
Cell::styled(format!("{}", one.additions), theme::success()),
Cell::styled(format!("{}", one.deletions), theme::danger()),
Cell::plain(truncate(&one.path, 64)),
]);
}
table.print();
if change.truncated {
println!(
"{}",
"GitHub solo lista los 300 primeros ficheros de un commit; hay más."
.style(theme::warning())
);
}
if !stat {
for one in &change.files {
diff::print_file(one);
}
}
Ok(())
}
fn print_commit_heading(change: &crate::api::models::CommitChange) {
let author = change
.author
.as_ref()
.map(|account| account.login.clone())
.or_else(|| change.author_name.clone())
.unwrap_or_else(|| "—".to_owned());
println!(
"{} {}",
change
.sha
.chars()
.take(7)
.collect::<String>()
.style(theme::accent()),
change.subject.clone().style(theme::strong())
);
println!(
"{}",
format!(
"{author} · {} · {} ficheros · +{} −{}",
change.repo_full_name,
change.files.len(),
change.additions,
change.deletions
)
.style(theme::muted())
);
if let Some(url) = &change.html_url {
println!("{}", url.clone().style(theme::muted()));
}
println!();
}
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(())
}
pub fn format_money(micros: u64) -> String {
if micros == 0 {
return "$0".to_string();
}
let dollars = micros as f64 / 1e6;
if dollars < 0.01 {
format!("${dollars:.4}")
} else {
format!("${dollars:.2}")
}
}
pub fn format_tokens(value: u64) -> String {
if value >= 1_000_000 {
format!("{:.1} M", value as f64 / 1e6)
} else if value >= 1000 {
format!("{} k", value / 1000)
} else {
value.to_string()
}
}
pub async fn consumption(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
range: &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.consumption(&found.id, range).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
if !page.available {
println!("`{}` no lleva cuenta de lo que gasta.", found.name);
return Ok(());
}
println!();
println!(
" {} {}",
format!("gasto ({range})").style(theme::muted()),
format_money(page.totals.cost_micros).style(theme::strong()),
);
println!(
" {} {}",
"gasto total ".style(theme::muted()),
format_money(page.lifetime.cost_micros).style(theme::strong()),
);
println!(
" {} {} ({} de media)",
"peticiones ".style(theme::muted()),
page.totals.prompts.style(theme::strong()),
format_money(page.totals.average_cost_micros),
);
println!(
" {} {}",
"tokens ".style(theme::muted()),
format_tokens(page.totals.tokens.total()).style(theme::strong()),
);
println!();
let mut table = Table::new(&["cuándo", "modelo", "tokens", "tardó", "coste"]);
for prompt in &page.recent {
table.push(vec![
Cell::plain(time::relative_time(&prompt.at).unwrap_or_else(|| "—".to_string())),
Cell::styled(truncate(&prompt.model, 24), theme::muted()),
Cell::plain(format_tokens(prompt.tokens.total())),
Cell::styled(format_millis(prompt.duration_ms as i64), theme::muted()),
Cell::styled(format_money(prompt.cost_micros), theme::strong()),
]);
}
if table.is_empty() {
println!(
" {}",
format!("`{}` todavía no ha contestado nada.", found.name).style(theme::muted())
);
} else {
table.print();
}
println!();
Ok(())
}
pub async fn agent_activity(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
range: &str,
outcome: Option<&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.agent_activity(&found.id, range, outcome).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
if !page.available {
println!("`{}` no lleva cuenta de lo que le piden.", found.name);
return Ok(());
}
println!();
println!(
" {} {} ({} contestadas, {} fallidas, {} rechazadas)",
format!("peticiones ({range})").style(theme::muted()),
page.requests.style(theme::strong()),
page.counts.answered,
page.counts.failed,
page.counts.rejected,
);
println!(
" {} {}",
"contestadas ".style(theme::muted()),
format!("{}%", page.answered_percent).style(theme::strong()),
);
match &page.live {
Some(live) => {
println!(
" {} {} en curso, {} en cola (de {} a la vez, {} en espera)",
"ahora mismo ".style(theme::muted()),
live.running.style(theme::strong()),
live.queued,
live.max_concurrent,
live.max_queued,
);
for request in &live.requests {
let state = if request.started_at.is_some() {
"en curso"
} else {
"en cola"
};
println!(
" {} {}",
format!("· {state}").style(theme::muted()),
truncate(&first_line(&request.prompt_excerpt), 64),
);
}
}
None => println!(
" {} {}",
"ahora mismo ".style(theme::muted()),
"el contenedor no está respondiendo".style(theme::muted()),
),
}
println!();
let mut table = Table::new(&["cuándo", "resultado", "vía", "prompt", "tardó", "coste"]);
for request in &page.entries {
table.push(vec![
Cell::plain(time::relative_time(&request.at).unwrap_or_else(|| "—".to_string())),
Cell::styled(outcome_label(request), outcome_style(&request.outcome)),
Cell::styled(truncate(&request.route, 8), theme::muted()),
Cell::plain(truncate(&first_line(&request.prompt_excerpt), 48)),
Cell::styled(format_millis(request.duration_ms as i64), theme::muted()),
Cell::styled(format_money(request.cost_micros), theme::strong()),
]);
}
if table.is_empty() {
println!(
" {}",
format!("A `{}` no le han pedido nada todavía.", found.name).style(theme::muted())
);
} else {
table.print();
}
println!();
Ok(())
}
fn outcome_label(request: &crate::api::models::AgentRequest) -> String {
let word = match request.outcome.as_str() {
"answered" => "contestada",
"failed" => "falló",
"rejected" => "rechazada",
other => other,
};
match &request.detail {
Some(detail) if !detail.is_empty() => format!("{word} · {detail}"),
_ => word.to_string(),
}
}
fn outcome_style(outcome: &str) -> owo_colors::Style {
match outcome {
"failed" => theme::danger(),
"rejected" => theme::warning(),
_ => theme::muted(),
}
}
fn first_line(excerpt: &str) -> String {
match excerpt.lines().next() {
Some(line) if !line.trim().is_empty() => line.trim().to_string(),
_ => "—".to_string(),
}
}
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();
for domain in &domains {
if domain.enabled && domain.state != "active" {
print_domain_progress(domain);
}
}
if domains.iter().all(|domain| !domain.enabled) {
println!();
println!(
" {}",
"El servicio es privado. Sus dominios vuelven con `fundaia expose`."
.style(theme::muted()),
);
}
Ok(())
}
pub async fn verify_domain(client: &ApiClient, domain_id: &str, format: Format) -> Result<()> {
let domain = client.verify_domain(domain_id).await?;
if format.is_json() {
output::emit(&domain);
return Ok(());
}
if domain.steps.is_none() {
println!(
"{} no es un dominio personalizado: no hay nada que comprobar.",
domain.host.style(theme::strong()),
);
return Ok(());
}
print_domain_progress(&domain);
if domain.state == "active" {
println!();
println!(
" {} {}",
"✓".style(theme::success()),
domain.url.clone().style(theme::accent())
);
}
Ok(())
}
fn print_domain_progress(domain: &Domain) {
let Some(steps) = domain.steps.as_ref() else {
return;
};
println!();
println!(" {}", domain.host.style(theme::strong()));
for step in steps {
let (mark, style) = match step.state.as_str() {
"done" => ("✓", theme::success()),
"working" => ("·", theme::accent()),
"blocked" => ("✗", theme::danger()),
_ => ("·", theme::muted()),
};
println!(
" {} {}",
mark.style(style),
step.detail.clone().style(if step.state == "done" {
theme::muted()
} else {
theme::strong()
}),
);
}
let waiting_on_dns = steps.first().is_some_and(|step| step.state != "done");
if let (true, Some(target)) = (waiting_on_dns, domain.cname_target.as_ref()) {
println!();
println!(
" {} CNAME {} {}",
"→".style(theme::muted()),
domain.dns_label.clone().unwrap_or_else(|| "@".to_string()),
target.clone().style(theme::accent()),
);
}
}
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", "ocupación", "montado", "id"]);
for volume in &volumes {
table.push(vec![
Cell::styled(volume.mount_path.clone(), theme::strong()),
occupancy_cell(volume),
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(())
}
fn occupancy_cell(volume: &Volume) -> Cell {
let Some(percent) = volume.occupancy_percent else {
return Cell::styled("sin medir".to_owned(), theme::muted());
};
let style = match percent {
0..=79 => theme::muted(),
80..=99 => theme::warning(),
_ => theme::danger(),
};
Cell::styled(
format!("{percent} % de {}", format_megabytes(volume.size_limit_mb)),
style,
)
}
fn format_megabytes(megabytes: u64) -> String {
if megabytes >= 1024 && megabytes % 1024 == 0 {
format!("{} GB", megabytes / 1024)
} else {
format!("{megabytes} MB")
}
}
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!(
" {} {}",
"remoto".style(theme::muted()),
match &page.remote {
Some(remote) => remote.clone(),
None => "ninguno — las copias están solo en esta máquina".to_owned(),
}
.style(theme::strong()),
);
println!();
let headers: Vec<&str> = if page.remote.is_some() {
vec!["id", "origen", "tamaño", "estado", "remoto", "hecha"]
} else {
vec!["id", "origen", "tamaño", "estado", "hecha"]
};
let mut table = Table::new(&headers);
for backup in &page.backups {
let mut row = 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(),
},
),
];
if page.remote.is_some() {
row.push(replication_cell(backup));
}
row.push(Cell::styled(backup.created_at.clone(), theme::muted()));
table.push(row);
}
if table.is_empty() {
println!("Todavía no hay ninguna copia de `{}`.", found.name);
return Ok(());
}
table.print();
Ok(())
}
fn replication_cell(backup: &Backup) -> Cell {
match (&backup.replicated_at, &backup.replication_error) {
(Some(_), _) => Cell::styled("sí", theme::success()),
(None, Some(_)) => Cell::styled("falló", theme::danger()),
(None, None) => Cell::styled("no", theme::muted()),
}
}
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(())
}
pub async fn service_config(
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 page = client.service_config(&found.id).await?;
if format.is_json() && !plain {
output::emit(&page);
return Ok(());
}
if plain {
print!("{}", page.config.content);
return Ok(());
}
println!();
println!(
" {} {}",
page.config.container_path.style(theme::strong()),
match page.config.source.as_str() {
"edited" => "editado a mano — el formulario ya no lo sobrescribe".to_owned(),
_ => "generado desde el formulario".to_owned(),
}
.style(theme::muted()),
);
if !page.stale_targets.is_empty() {
println!(
" {} {} apuntan a un servicio que ya no existe",
"!".style(theme::warning()),
page.stale_targets.len(),
);
}
for warning in &page.config.warnings {
println!(" {} {}", "!".style(theme::warning()), warning);
}
println!();
for line in page.config.content.lines() {
println!(" {line}");
}
println!();
Ok(())
}
pub async fn capabilities(
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?;
let Some(capabilities) = detail.capabilities else {
if format.is_json() {
output::emit(&serde_json::Value::Null);
return Ok(());
}
println!("\n Todavía no se ha leído este repositorio.\n");
return Ok(());
};
if format.is_json() {
output::emit(&capabilities);
return Ok(());
}
println!();
if let Some(framework) = &capabilities.framework {
println!(
" {} {}{}",
"framework".style(theme::muted()),
framework,
match &capabilities.framework_version {
Some(version) => format!(" {version}"),
None => String::new(),
},
);
}
if let Some(port) = capabilities.declared_port {
println!(" {} {port}", "puerto".style(theme::muted()));
}
for endpoint in &capabilities.endpoints {
println!(
" {} {} {}",
endpoint.path.style(theme::accent()),
endpoint.kind.style(theme::muted()),
endpoint.evidence.style(theme::muted()),
);
}
if capabilities.shallow {
println!(
"\n {}",
"leído sin descargar el repositorio; el próximo despliegue mirará dentro del código"
.style(theme::muted())
);
}
println!();
Ok(())
}
pub async fn sign_ins(client: &ApiClient, limit: usize, format: Format) -> Result<()> {
let entries = client.sign_ins(limit).await?;
if format.is_json() {
output::emit(&entries);
return Ok(());
}
if entries.is_empty() {
println!("Todavía no hay ningún acceso registrado.");
return Ok(());
}
println!();
let mut table = Table::new(&["cuándo", "cómo", "resultado", "desde", "dispositivo"]);
for entry in &entries {
table.push(vec![
Cell::styled(
time::relative_time(&entry.at).unwrap_or_else(|| "—".to_string()),
theme::muted(),
),
Cell::plain(sign_in_method(&entry.method)),
Cell::styled(
sign_in_outcome(&entry.outcome),
match entry.outcome.as_str() {
"succeeded" => theme::success(),
"pending" => theme::muted(),
"refused" => theme::warning(),
_ => theme::danger(),
},
),
Cell::plain(entry.client_address.clone()),
Cell::styled(
entry.device.clone().unwrap_or_else(|| "—".to_string()),
theme::muted(),
),
]);
}
table.print();
println!();
Ok(())
}
pub async fn auth_sessions(client: &ApiClient, format: Format) -> Result<()> {
let sessions = client.auth_sessions().await?;
if format.is_json() {
output::emit(&sessions);
return Ok(());
}
if sessions.is_empty() {
println!("No hay ninguna sesión abierta.");
return Ok(());
}
println!();
let mut table = Table::new(&["id", "dispositivo", "desde", "activa", ""]);
for session in &sessions {
table.push(vec![
Cell::styled(
session.id.chars().take(12).collect::<String>(),
theme::muted(),
),
Cell::plain(session.device.clone().unwrap_or_else(|| "—".to_string())),
Cell::plain(
session
.client_address
.clone()
.unwrap_or_else(|| "—".to_string()),
),
Cell::styled(
time::relative_time(&session.last_seen_at).unwrap_or_else(|| "—".to_string()),
theme::muted(),
),
Cell::styled(
if session.current {
"esta".to_string()
} else if session.pending {
"sin verificar".to_string()
} else {
String::new()
},
theme::accent(),
),
]);
}
table.print();
println!();
Ok(())
}
pub fn sign_in_method(method: &str) -> String {
match method {
"github" => "GitHub",
"passkey" => "passkey",
"totp" => "código",
"recovery-code" => "recuperación",
"api-token" => "token",
"cli-approval" => "terminal",
other => other,
}
.to_string()
}
pub fn sign_in_outcome(outcome: &str) -> String {
match outcome {
"succeeded" => "entró",
"pending" => "a medias",
"refused" => "rechazado",
"failed" => "falló",
other => other,
}
.to_string()
}
pub async fn repository_access(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let page = client.repository_access(&service.id).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
let Some(request) = page.request else {
println!(
" {}",
"el repositorio se lee sin problema".style(theme::muted())
);
return Ok(());
};
let owner = match &request.owner_name {
Some(name) => format!("{name} (@{})", request.owner_login),
None => format!("@{}", request.owner_login),
};
println!(" {}", request.repo_full_name.style(theme::strong()));
println!(" propietario {owner}");
println!(
" estado {}",
request.state.style(match request.state.as_str() {
"pending" => theme::warning(),
"sent" => theme::accent(),
"granted" => theme::success(),
_ => theme::muted(),
})
);
match (&request.email, request.state.as_str()) {
(Some(email), "sent") => {
println!(" enlace enviado a {email}");
println!(
" {}",
match request.opened_at {
Some(_) => "ya ha abierto el enlace; falta que lo autorice en GitHub",
None => "todavía no lo ha abierto",
}
.style(theme::muted())
);
}
_ => println!(
" {}",
format!(
"GitHub no publica su correo: fundaia access request {project_reference} {service_reference} --email <correo>"
)
.style(theme::muted())
),
}
println!();
println!(
" {}",
"en cuanto lo conceda, el servicio se despliega solo".style(theme::muted())
);
Ok(())
}
pub async fn approvals(
client: &ApiClient,
project_reference: &str,
service_reference: Option<&str>,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let approvals = match service_reference {
None => client.project_approvals(&project.id).await?,
Some(reference) => {
let found = client.find_service(&project.id, reference).await?;
client.service_approvals(&found.id).await?
}
};
if format.is_json() {
output::emit(&approvals);
return Ok(());
}
if approvals.is_empty() {
println!(" {}", "nada esperando aprobación".style(theme::muted()));
return Ok(());
}
let mut table = Table::new(&["commit", "autor", "por qué", "estado", "cuándo", "id"]);
for approval in &approvals {
table.push(vec![
Cell::plain(short_sha(&approval.commit_sha)),
Cell::plain(
approval
.author_login
.clone()
.map(|login| format!("@{login}"))
.unwrap_or_else(|| "—".to_string()),
),
Cell::plain(reason_label(&approval.reason).to_string()),
Cell::styled(
approval.state.clone(),
match approval.state.as_str() {
"pending" => theme::warning(),
"approved" => theme::success(),
"rejected" => theme::danger(),
_ => theme::muted(),
},
),
Cell::plain(format_millis(
epoch_millis(&approval.requested_at).unwrap_or(-1),
)),
Cell::styled(approval.id.clone(), theme::muted()),
]);
}
table.print();
println!();
println!(
" {}",
"fundaia approve <id> despliega · fundaia reject <id> lo descarta para siempre"
.style(theme::muted())
);
Ok(())
}
fn short_sha(sha: &str) -> String {
sha.chars().take(7).collect()
}
fn reason_label(reason: &str) -> &str {
match reason {
"outside-workspace" => "no está en el espacio",
"unlinked-commit" => "sin cuenta de GitHub",
"unverifiable" => "no se pudo comprobar",
other => other,
}
}
pub async fn incidents(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
decided: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
let incidents = client.incidents(&found.id, decided).await?;
if format.is_json() {
output::emit(&incidents);
return Ok(());
}
let mut table = Table::new(&["veces", "nivel", "qué falla", "última vez", "estado", "id"]);
for incident in &incidents {
let level = match incident.level.as_str() {
"error" => theme::danger(),
_ => theme::warning(),
};
table.push(vec![
Cell::plain(incident.occurrences.to_string()),
Cell::styled(incident.level.clone(), level),
Cell::plain(truncate(&incident.shape, 60)),
Cell::plain(format_millis(
epoch_millis(&incident.last_seen_at).unwrap_or(-1),
)),
Cell::styled(
incident.status.clone(),
match incident.status.as_str() {
"open" => theme::danger(),
"investigating" | "fixProposed" => theme::accent(),
_ => theme::muted(),
},
),
Cell::styled(incident.id.clone(), theme::muted()),
]);
}
table.print();
Ok(())
}
pub async fn autofix(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
changes: serde_json::Value,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let found = client.find_service(&project.id, service_reference).await?;
if let Some(object) = changes.as_object() {
if !object.is_empty() {
let settings = client.set_autofix(&found.id, &changes).await?;
if format.is_json() {
output::emit(&settings);
return Ok(());
}
println!(
" {} ajustes guardados",
theme::CHECK.style(theme::success())
);
}
}
let runs = client.autofix_runs(&found.id).await?;
if format.is_json() {
output::emit(&runs);
return Ok(());
}
let mut table = Table::new(&["estado", "paso", "coste", "pull request", "cuándo"]);
for run in &runs {
table.push(vec![
Cell::styled(
run.status.clone(),
match run.status.as_str() {
"succeeded" => theme::success(),
"running" => theme::accent(),
"notReproducible" => theme::warning(),
_ => theme::danger(),
},
),
Cell::plain(run.step.clone()),
Cell::plain(format!("{:.2} €", run.cost_micros as f64 / 1_000_000.0)),
Cell::plain(
run.pull_request_url
.clone()
.unwrap_or_else(|| "—".to_owned()),
),
Cell::plain(format_millis(epoch_millis(&run.started_at).unwrap_or(-1))),
]);
}
table.print();
Ok(())
}
pub async fn canvas(
client: &ApiClient,
project_reference: &str,
map: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let view = client.canvas(&project.id).await?;
if format.is_json() {
output::emit(&view);
return Ok(());
}
if view.nodes.is_empty() {
println!("`{}` todavía no tiene servicios.", project.name);
return Ok(());
}
if map {
for line in sketch(&view.nodes) {
println!("{}", line.style(theme::muted()));
}
println!();
}
let mut table = Table::new(&["servicio", "x", "y", "depende de"]);
for node in &view.nodes {
let dependencies: Vec<String> = view
.edges
.iter()
.filter(|arrow| arrow.from_service_id == node.service_id)
.map(|arrow| arrow.to.clone())
.collect();
table.push(vec![
Cell::styled(node.name.clone(), theme::strong()),
Cell::plain(node.x.to_string()),
Cell::plain(node.y.to_string()),
Cell::styled(
if dependencies.is_empty() {
"—".to_owned()
} else {
dependencies.join(", ")
},
theme::muted(),
),
]);
}
table.print();
Ok(())
}
const SKETCH_WIDTH: usize = 72;
const SKETCH_HEIGHT: usize = 14;
fn sketch(nodes: &[CanvasNode]) -> Vec<String> {
let left = nodes.iter().map(|node| node.x).min().unwrap_or(0);
let right = nodes.iter().map(|node| node.x).max().unwrap_or(0);
let top = nodes.iter().map(|node| node.y).min().unwrap_or(0);
let bottom = nodes.iter().map(|node| node.y).max().unwrap_or(0);
let label_width = 12;
let mut grid = vec![vec![' '; SKETCH_WIDTH]; SKETCH_HEIGHT];
for node in nodes {
let label = truncate(&node.slug, label_width);
let column = scaled(
node.x - left,
right - left,
SKETCH_WIDTH - label.chars().count(),
);
let row = scaled(node.y - top, bottom - top, SKETCH_HEIGHT - 1);
place(&mut grid, row, column, &label);
}
grid.into_iter()
.map(|row| row.into_iter().collect::<String>().trim_end().to_owned())
.filter(|row| !row.is_empty())
.collect()
}
fn scaled(offset: i64, span: i64, cells: usize) -> usize {
if span <= 0 || cells == 0 {
return 0;
}
let ratio = offset as f64 / span as f64;
(ratio * cells as f64).round() as usize
}
fn place(grid: &mut [Vec<char>], row: usize, column: usize, label: &str) {
let width = label.chars().count();
for line in grid.iter_mut().skip(row) {
for candidate in column..=line.len().saturating_sub(width) {
let free = line[candidate..candidate + width]
.iter()
.all(|cell| *cell == ' ');
let margin_free = candidate == 0 || line[candidate - 1] == ' ';
if free && margin_free {
for (offset, letter) in label.chars().enumerate() {
line[candidate + offset] = letter;
}
return;
}
}
}
}
fn sleep_phrase(sleep_after_minutes: u32) -> String {
match sleep_after_minutes {
0 => "duerme sin tráfico".to_owned(),
minutes if minutes % 60 == 0 => format!("duerme tras {} h sin tráfico", minutes / 60),
minutes => format!("duerme tras {minutes} min sin tráfico"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_say_the_idle_window_in_hours_when_it_is_whole_hours() {
assert_eq!(sleep_phrase(480), "duerme tras 8 h sin tráfico");
}
#[test]
fn it_should_say_the_idle_window_in_minutes_otherwise() {
assert_eq!(sleep_phrase(90), "duerme tras 90 min sin tráfico");
}
#[test]
fn it_should_not_invent_a_window_a_server_did_not_send() {
assert_eq!(sleep_phrase(0), "duerme sin tráfico");
}
fn node(slug: &str, x: i64, y: i64) -> CanvasNode {
CanvasNode {
service_id: format!("svc_{slug}"),
service_environment_id: format!("sve_{slug}"),
name: slug.to_owned(),
slug: slug.to_owned(),
kind: "application".to_owned(),
state: "active".to_owned(),
x,
y,
}
}
#[test]
fn it_should_draw_the_leftmost_service_against_the_left_margin() {
let drawn = sketch(&[node("db", 80, 80), node("api", 380, 80)]);
assert!(drawn[0].starts_with("db"));
}
#[test]
fn it_should_draw_a_service_further_right_further_right() {
let drawn = sketch(&[node("db", 80, 80), node("api", 380, 80)]);
assert!(drawn[0].find("api") > drawn[0].find("db"));
}
#[test]
fn it_should_draw_a_service_further_down_on_a_later_line() {
let drawn = sketch(&[node("db", 80, 80), node("api", 80, 500)]);
assert!(drawn.len() > 1);
}
#[test]
fn it_should_draw_both_of_two_services_at_the_same_point() {
let drawn = sketch(&[node("db", 80, 80), node("api", 80, 80)]);
assert!(drawn.concat().contains("db") && drawn.concat().contains("api"));
}
#[test]
fn it_should_put_a_lone_service_in_the_first_cell() {
assert_eq!(scaled(0, 0, 40), 0);
}
#[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)));
}
#[test]
fn it_should_show_four_decimals_under_a_cent() {
assert_eq!(format_money(3_400), "$0.0034");
}
#[test]
fn it_should_show_two_decimals_above_a_cent() {
assert_eq!(format_money(1_250_000), "$1.25");
}
#[test]
fn it_should_print_nothing_spent_without_decimals() {
assert_eq!(format_money(0), "$0");
}
#[test]
fn it_should_abbreviate_thousands_of_tokens() {
assert_eq!(format_tokens(23_578), "23 k");
}
#[test]
fn it_should_abbreviate_millions_of_tokens() {
assert_eq!(format_tokens(2_400_000), "2.4 M");
}
#[test]
fn it_should_print_a_small_count_whole() {
assert_eq!(format_tokens(511), "511");
}
#[test]
fn it_should_count_cached_tokens_towards_the_total() {
let counts = crate::api::models::TokenCounts {
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 4,
cache_creation_tokens: 8,
};
assert_eq!(counts.total(), 15);
}
}