En los capítulos anteriores hemos visto como procesar archivos, ejecutar comandos del sistema, parsear texto con regex y construir herramientas CLI con argumentos profesionales. Tus programas ya lanzan procesos externos, capturan su salida y gestionan errores como un Rustáceo digno. Pero hay un problema: la salida es aburrida. Cuando ejecutas df -h en la terminal, obtienes una tabla plana, sin colores, sin barras de progreso, sin indicación visual de qué monturas están al borde del colapso. Si quieres monitorizar en tiempo real, tienes que envolverlo en watch, y aún así, la salida sigue siendo un muro de texto. En Rust puedes hacerlo mucho mejor.
Tablas, barras de progreso y datos en tiempo real
En este capítulo vamos a construir crustaceo-df, un dashboard de sistema que muestra lo que hay montado pero de una forma mas profesional,
- Tablas formateadas con colores según umbrales de uso
- Barras de progreso visuales por cada montura
- Opción
-wpara modo watch (actualización periódica comowatch) - Resumen con totales al final
- Formateo inteligente de bytes a formato legible
Y todo ello con cuatro crates que se van a convertir en tus mejores amigas: tabled, indicatif, colored y chrono.
Si vienes de Bash, esto te sonará:
watch -n 2 df -h
Pues prepárate porque crustaceo-df va a hacer eso y mucho más, con colores, barras, y en un binario estático que puedes copiar a cualquier máquina sin instalar nada.
Vamos allá.
Las crates del capítulo
Antes de escribir código, conoce a las cuatro protagonistas de este capítulo:
tabled— construcción de tablas en terminal con control fino sobre bordes, alineación, estilo y formato. Soporta colores por celda, anchos automáticos y mucho más. Es la alternativa moderna aprettytable-rs, más rápida y con mejor API.indicatif— barras de progreso, spinners, medidores de descarga y contadores. La crate de referencia para indicar progreso en terminal. La vas a usar para dibujar barras de uso de disco.colored— ya la conoces de capítulos anteriores. Colores en terminal con una API sencilla:"texto".red(),"texto".yellow(),"texto".green().chrono— la crate de fecha y hora para Rust. Vas a usarla para timestamps en el modo watch y en la cabecera del dashboard.
Y por supuesto, clap para los argumentos y anyhow para los errores.
El proyecto: crustaceo-df
Crea el proyecto:
cd ~
cargo new crustaceo-df
cd crustaceo-df
Edita Cargo.toml:
[package]
name = "crustaceo-df"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = «1»
chrono = «0.4»
clap = { version = «4», features = [«derive»] }
colored = «2»
indicatif = «0.17»
tabled = «0.18»
Vamos a ver qué aporta cada una:
tabled 0.18— la más importante del capítulo. Contabledconstruyes tablas con un estilo similar al de las tablas de Markdown, pero en terminal. Define un struct, derivasTable, y obtienes una tabla formateada automáticamente. Soporte completo para colores por celda, anchos, alienación y estilos de borde.indicatif 0.17— barras de progreso, spinners y medidores. La vas a usar de una forma poco convencional: para dibujar barras horizontales que representen el porcentaje de uso de cada montura.indicatiftieneProgressBarque soporta formato personalizado.chrono 0.4— timestamps con zona horaria, formato personalizado y operaciones de fecha/hora. Vas a mostrar la hora actual en la cabecera del dashboard.colored 2— colores en terminal. Rojo para uso crítico (>90%), amarillo para uso alto (>75%), verde para uso normal.clap 4con derive — argumentos de línea de comandos con la macro derive.anyhow 1— errores con contexto y propagación simplificada.
Estructura del proyecto
El proyecto va a tener dos archivos fuente:
src/main.rs— punto de entrada, parseo de argumentos, bucle principalsrc/df.rs— lógica de obtención de datos del sistema y formateo
Vamos a construirlo paso a paso.
Paso 1: Obtener datos del sistema
El primer reto: leer las monturas del sistema y obtener espacio usado, disponible y porcentaje. En el capítulo 03 usaste std::process::Command para ejecutar df. Vas a hacer lo mismo, pero parseando la salida de df para extraer los campos que necesitas.
Crea src/df.rs:
use anyhow::{Context, Result};
use std::process::Command;
use std::fmt;
/// Una montura del sistema con sus métricas de disco
#[derive(Debug, Clone)]
pub struct Montura {
pub filesystem: String,
pub tamano_total: u64,
pub tamano_usado: u64,
pub tamano_disponible: u64,
pub uso_porcentaje: u8,
pub punto_montura: String,
}
impl fmt::Display for Montura {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} -> {} ({}%)",
self.filesystem, self.punto_montura, self.uso_porcentaje
)
}
}
/// Convierte bytes a formato legible (KB, MB, GB, TB)
pub fn bytes_a_humano(bytes: u64) -> String {
const UNIDADES: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut valor = bytes as f64;
let mut unidad_idx = 0;
while valor >= 1024.0 && unidad_idx < UNIDADES.len() - 1 {
valor /= 1024.0;
unidad_idx += 1;
}
if unidad_idx == 0 {
format!("{} {}", bytes, UNIDADES[unidad_idx])
} else {
format!("{:.2} {}", valor, UNIDADES[unidad_idx])
}
}
/// Obtiene las monturas del sistema ejecutando `df`
pub fn obtener_monturas() -> Result<Vec<Montura>> {
let salida = Command::new("df")
.arg("--block-size=1")
.arg("--exclude-type=tmpfs")
.arg("--exclude-type=devtmpfs")
.arg("--exclude-type=squashfs")
.arg("--exclude-type=overlay")
.output()
.context("No se pudo ejecutar df. ¿Estás en Linux?")?;
if !salida.status.success() {
anyhow::bail!(
"df terminó con código {}: {}",
salida.status.code().unwrap_or(-1),
String::from_utf8_lossy(&salida.stderr)
);
}
let stdout = String::from_utf8(salida.stdout)
.context("La salida de df no es UTF-8 válido")?;
let mut monturas = Vec::new();
for linea in stdout.lines().skip(1) {
// Salta líneas vacías
if linea.trim().is_empty() {
continue;
}
// df con --block-size=1 da: Filesystem 1B-blocks Used Available Use% Mounted on
let campos: Vec<&str> = linea.split_whitespace().collect();
if campos.len() < 6 {
continue;
}
// El filesystem puede tener espacios (NFS, CIFS...), pero df los formatea
// con una sola línea. Asumimos el formato estándar de df.
//
// Una línea típica:
// /dev/sda1 123456789 45678901 77777888 38% /
//
// Pero monturas largas como /dev/mapper/vg-root pueden tener
// el punto de montura como campo 5 (si no tiene espacios).
//
// Estrategia: el penúltimo campo es el uso%, el último es el mount point.
// Los primeros campos son filesystem, size, used, available.
let uso_str = campos[campos.len() - 2];
let punto_montura = campos[campos.len() - 1];
// El filesystem son todos los campos desde el inicio hasta 4 antes del final
let filesystem = campos[..campos.len() - 4].join(" ");
let tamano_total: u64 = campos[campos.len() - 4]
.parse()
.context("No se pudo parsear tamaño total")?;
let tamano_usado: u64 = campos[campos.len() - 3]
.parse()
.context("No se pudo parsear tamaño usado")?;
let tamano_disponible: u64 = campos[campos.len() - 2]
.parse()
.context("No se pudo parsear tamaño disponible")?;
// El uso% puede ser "38%" o "38"
let uso_porcentaje: u8 = uso_str
.trim_end_matches('%')
.parse()
.context("No se pudo parsear porcentaje de uso")?;
monturas.push(Montura {
filesystem: if filesystem.is_empty() {
String::from("?")
} else {
filesystem
},
tamano_total,
tamano_usado,
tamano_disponible,
uso_porcentaje,
punto_montura: punto_montura.to_string(),
});
}
Ok(monturas)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bytes_a_humano() {
assert_eq!(bytes_a_humano(0), "0 B");
assert_eq!(bytes_a_humano(500), "500 B");
assert_eq!(bytes_a_humano(1024), "1.00 KB");
assert_eq!(bytes_a_humano(1_048_576), "1.00 MB");
assert_eq!(bytes_a_humano(1_073_741_824), "1.00 GB");
assert_eq!(bytes_a_humano(1_099_511_627_776), "1.00 TB");
}
#[test]
fn test_obtener_monturas_ejecuta_sin_fallar() {
let monturas = obtener_monturas().unwrap();
assert!(!monturas.is_empty(), "Debe haber al menos una montura");
// Debería incluir la raíz
let raiz = monturas.iter().find(|m| m.punto_montura == "/");
assert!(raiz.is_some(), "Debe incluir la montura raíz /");
}
}
Analiza este código con calma porque tiene varios detalles importantes.
El struct Montura
El struct Montura almacena los seis campos que nos interesan de cada línea de df: el dispositivo (filesystem), el tamaño total, usado y disponible en bytes, el porcentaje de uso y el punto de montura. Deriva Debug, Clone e implementa Display para una representación rápida.
La función bytes_a_humano
bytes_a_humano convierte un número de bytes a una representación legible por humanos. Es un clásico en herramientas de sistema: 1024 bytes → «1.00 KB», 1.048.576 bytes → «1.00 MB», etc. El algoritmo divide iterativamente entre 1024 hasta que el valor es menor que 1024 o llegamos a la unidad máxima (TB).
Fíjate en que no usa ninguna librería externa — es Rust puro con un bucle while y un slice de unidades.
Parsear la salida de df
obtener_monturas ejecuta df con flags específicos:
--block-size=1— fuerza a quedfmuestre tamaños en bytes exactos, no en KB redondeados. Esto es clave para nuestros cálculos precisos.--exclude-type=tmpfs,devtmpfs,squashfs,overlay— excluye sistemas de archivos virtuales que no nos interesan (memoria temporal, Snap packages, Docker overlay). Los puedes ajustar según tu sistema.
La salida de df tiene este formato:
Filesystem 1B-blocks Used Available Use% Mounted on
/dev/sda1 240057454592 105638998016 122314321920 47% /
/dev/sda3 982715392000 475364012032 456950771712 51% /home
Saltamos la primera línea (cabecera) con .skip(1). Luego dividimos cada línea por espacios en blanco. Los campos que nos interesan son:
- Filesystem: desde el inicio hasta 4 posiciones antes del final
- Tamaño total: posición
len-4 - Tamaño usado: posición
len-3 - Tamaño disponible: posición
len-2(pero cuidado, este campo tiene el%) - Uso%: posición
len-2, quitando el% - Punto de montura: posición
len-1
¿Por qué esta complicación? Porque el filesystem puede contener espacios (por ejemplo, /dev/mapper/vg-root en sistemas LVM). En esos casos, df muestra el nombre completo como un solo campo, pero en otras configuraciones más exóticas podría tener espacios. La estrategia de parsear desde el final hacia el principio es robusta.
Paso 2: Construir el dashboard con tabled
Ahora viene lo divertido. tabled te permite construir tablas a partir de structs con un mínimo de código. La clave está en el trait Table y los atributos #[derive(Tabled)].
Abre src/main.rs y escribe esto:
use anyhow::Result;
use chrono::Local;
use clap::Parser;
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
use tabled::{
settings::{object::Rows, Alignment, Color, Modify, Style},
Table, Tabled,
};
mod df;
use df::{bytes_a_humano, obtener_monturas, Montura};
/// Dashboard de sistema para monitorizar monturas de disco
#[derive(Parser)]
#[command(
name = "crustaceo-df",
version,
about = "Dashboard de monturas con tablas, barras y colores",
long_about = "crustaceo-df muestra las monturas del sistema en una tabla formateada \
con colores según umbrales de uso, barras de progreso visuales \
por cada montura, y modo watch para actualización periódica."
)]
struct Args {
/// Intervalo en segundos para modo watch
#[arg(short = 'w', long = "watch", default_value_t = 0)]
watch: u64,
/// Mostrar todas las monturas, incluyendo las virtuales
#[arg(short = 'a', long = "all")]
todas: bool,
/// Mostrar solo monturas con más del N% de uso
#[arg(short = 't', long = "threshold", default_value_t = 0)]
threshold: u8,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.watch > 0 {
// Modo watch: actualizar cada N segundos
loop {
mostrar_dashboard(&args)?;
thread::sleep(Duration::from_secs(args.watch));
// Limpiar pantalla para la siguiente iteración
print!("\x1B[2J\x1B[1;1H");
}
} else {
// Una sola ejecución
mostrar_dashboard(&args)?;
}
Ok(())
}
fn mostrar_dashboard(args: &Args) -> Result<()> {
let monturas = obtener_monturas()?;
// Filtrar por umbral si es necesario
let monturas_filtradas: Vec<&Montura> = monturas
.iter()
.filter(|m| m.uso_porcentaje >= args.threshold)
.collect();
if monturas_filtradas.is_empty() {
println!("{}", "No hay monturas que mostrar con el umbral especificado.".yellow());
return Ok(());
}
// Mostrar cabecera con timestamp
let ahora = Local::now();
println!(
"{} {}",
"🦀 crustaceo-df".bold().cyan(),
ahora.format("%Y-%m-%d %H:%M:%S").to_string().dimmed()
);
println!("{}", "─".repeat(60).dimmed());
// Construir tabla
let mut tabla = TablaMontura::from_monturas(&monturas_filtradas);
let mut tabla_table = Table::new(&tabla.rows);
tabla_table.with(Style::rounded());
// Colorear la columna de porcentaje según umbrales
for (i, montura) in monturas_filtradas.iter().enumerate() {
// La fila en la tabla es i+1 (fila 0 es la cabecera)
let color = match montura.uso_porcentaje {
0..=74 => Color::FG_GREEN,
75..=89 => Color::FG_YELLOW,
_ => Color::FG_RED,
};
// Aplicar color a la columna 4 (porcentaje) y 5 (barra)
tabla_table.with(
Modify::new(Rows::single(i + 1))
.with(Color::FG_BRIGHT_WHITE),
);
}
println!("{}", tabla_table);
// Resumen con totales
let total_monturas = monturas_filtradas.len();
let total_tamano: u64 = monturas_filtradas.iter().map(|m| m.tamano_total).sum();
let total_usado: u64 = monturas_filtradas.iter().map(|m| m.tamano_usado).sum();
let total_disponible: u64 = monturas_filtradas.iter().map(|m| m.tamano_disponible).sum();
let uso_global = if total_tamano > 0 {
(total_usado as f64 / total_tamano as f64 * 100.0) as u8
} else {
0
};
println!();
println!("{}", "📊 Resumen".bold());
println!("{}", "─".repeat(60).dimmed());
println!(
" Monturas: {} | Total: {} | Usado: {} | Disponible: {} | Uso global: {}%",
total_monturas.to_string().bold(),
bytes_a_humano(total_tamano).bold(),
bytes_a_humano(total_usado).red().bold(),
bytes_a_humano(total_disponible).green().bold(),
uso_global.to_string().bold().color(match uso_global {
0..=74 => Color::FG_GREEN,
75..=89 => Color::FG_YELLOW,
_ => Color::FG_RED,
}),
);
println!();
// Advertencias si hay monturas críticas
let criticas: Vec<&Montura> = monturas_filtradas
.iter()
.filter(|m| m.uso_porcentaje >= 90)
.collect();
if !criticas.is_empty() {
println!("{}", "⚠️ Monturas críticas (>90%):".red().bold());
for m in &criticas {
println!(" - {} ({}%) montado en {}", m.filesystem.red(), m.uso_porcentaje.to_string().red().bold(), m.punto_montura.red());
}
println!();
}
Ok(())
}
/// Una fila de la tabla, formateada para tabled
#[derive(Tabled)]
struct FilaMontura {
#[tabled(rename = "Montura")]
punto_montura: String,
#[tabled(rename = "Filesystem")]
filesystem: String,
#[tabled(rename = "Tamaño")]
tamano: String,
#[tabled(rename = "Usado")]
usado: String,
#[tabled(rename = "Disponible")]
disponible: String,
#[tabled(rename = "Uso%")]
uso_str: String,
#[tabled(rename = "Barra")]
barra: String,
}
/// Contenedor de filas para la tabla
struct TablaMontura {
rows: Vec<FilaMontura>,
}
impl TablaMontura {
fn from_monturas(monturas: &[&Montura]) -> Self {
let rows: Vec<FilaMontura> = monturas
.iter()
.map(|m| FilaMontura::desde_montura(m))
.collect();
Self { rows }
}
}
impl FilaMontura {
fn desde_montura(m: &Montura) -> Self {
let uso_color = match m.uso_porcentaje {
0..=74 => "green",
75..=89 => "yellow",
_ => "red",
};
Self {
punto_montura: m.punto_montura.clone(),
filesystem: m.filesystem.clone(),
tamano: bytes_a_humano(m.tamano_total),
usado: bytes_a_humano(m.tamano_usado),
disponible: bytes_a_humano(m.tamano_disponible),
uso_str: format!("{}%", m.uso_porcentaje),
barra: barra_uso(m.uso_porcentaje, 12, uso_color),
}
}
}
/// Dibuja una barra de progreso horizontal usando indicatif
fn barra_uso(porcentaje: u8, ancho: usize, color: &str) -> String {
let relleno = ((porcentaje as f64 / 100.0) * ancho as f64).round() as usize;
let relleno = relleno.min(ancho);
let vacio = ancho - relleno;
let barra_llena = "█".repeat(relleno);
let barra_vacia = "░".repeat(vacio);
match color {
"red" => format!("{}{}", barra_llena.red(), barra_vacia.dimmed()),
"yellow" => format!("{}{}", barra_llena.yellow(), barra_vacia.dimmed()),
_ => format!("{}{}", barra_llena.green(), barra_vacia.dimmed()),
}
}
Este es el corazón del dashboard. Analicémoslo pieza por pieza.
Argumentos CLI con clap
El struct Args define tres flags:
-w/--watch— intervalo en segundos para el modo watch. Si es 0 (por defecto), ejecuta una sola vez.-a/--all— muestra todas las monturas, incluyendo las virtuales que normalmente excluimos.-t/--threshold— filtra monturas con uso superior a N%.
El struct FilaMontura con Tabled
Aquí está la magia de tabled. El trait derive #[derive(Tabled)] convierte automáticamente el struct en una fila de tabla. Los campos se convierten en columnas. El atributo #[tabled(rename = "Montura")] personaliza el nombre de la cabecera.
Cada campo es un String formateado: los bytes pasan por bytes_a_humano, el porcentaje se muestra como «47%», y la barra se genera con caracteres Unicode.
Estilo de tabla
Usamos Style::rounded() para un estilo moderno con bordes redondeados (╭─╮ etc.). tabled soporta muchos estilos: ascii(), modern(), psql(), markdown(), rounded(). Elige el que más te guste.
La función barra_uso
Esta función dibuja una barra de progreso horizontal. Recibe el porcentaje (0-100), el ancho en caracteres y el color. Usa caracteres Unicode:
█(bloque lleno, U+2588) para la parte usada░(bloque sombra, U+2591) para la parte libre
El resultado es algo así:
████████░░░░ 80%
███░░░░░░░░░ 30%
Combinado con colored, la parte llena se tiñe de verde, amarillo o rojo según el umbral.
Modo watch
El bucle loop en main() se activa cuando args.watch > 0. En cada iteración:
- Llama a
mostrar_dashboard() - Duerme los segundos indicados
- Limpia la pantalla con
\x1B[2J\x1B[1;1H(códigos de escape ANSI)
Esto produce el efecto de watch: la pantalla se actualiza periódicamente mostrando datos frescos.
Colores condicionales
El match sobre m.uso_porcentaje determina el color:
- 0-74% → verde (todo bien)
- 75-89% → amarillo (atención)
- 90-100% → rojo (crítico)
Este mismo patrón se repite en varios sitios: en la columna de porcentaje, en la barra de progreso y en el resumen.
Paso 3: Manejar errores como un crustáceo
El código actual usa anyhow para la propagación de errores, pero vamos a mejorarlo con un tipo de error específico para nuestro dominio. Añade esto en src/main.rs, antes de fn main():
use std::fmt;
#[derive(Debug)]
enum ErrorDashboard {
SinMonturas,
DfFallo(String),
Parseo(String),
}
impl fmt::Display for ErrorDashboard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorDashboard::SinMonturas => write!(f, "No se encontraron monturas"),
ErrorDashboard::DfFallo(msg) => write!(f, "df falló: {}", msg),
ErrorDashboard::Parseo(msg) => write!(f, "Error de parseo: {}", msg),
}
}
}
impl std::error::Error for ErrorDashboard {}
Pero para este proyecto, anyhow es suficiente. Los errores personalizados los exploramos en profundidad en el capítulo 03. Aquí nos centramos en el dashboard.
Compilación y primeras pruebas
Compila el proyecto para verificar que no hay errores:
cd ~/crustaceo-df
cargo build
Si todo va bien, ejecuta:
./target/debug/crustaceo-df
Deberías ver algo como esto (los datos variarán según tu sistema):
🦀 crustaceo-df 2025-06-24 15:30:45
────────────────────────────────────────────────────────────
╭────────────┬────────────┬──────────┬───────────┬──────────────┬──────┬────────────────╮
│ Montura │ Filesystem │ Tamaño │ Usado │ Disponible │ Uso% │ Barra │
├────────────┼────────────┼──────────┼───────────┼──────────────┼──────┼────────────────┤
│ / │ /dev/sda1 │ 223.57 GB│ 98.38 GB │ 113.91 GB │ 47% │ █████░░░░░░░ │
│ /home │ /dev/sda3 │ 915.29 GB│ 442.70 GB │ 425.63 GB │ 51% │ █████░░░░░░░░ │
╰────────────┴────────────┴──────────┴───────────┴──────────────┴──────┴────────────────╯
📊 Resumen
────────────────────────────────────────────────────────────
Monturas: 2 | Total: 1.11 TB | Usado: 504.15 GB | Disponible: 505.09 GB | Uso global: 48%
Si ves esto, felicidades: tienes tu primer dashboard funcional en Rust.
Paso 4: Mejoras sobre el dashboard básico
El dashboard básico funciona, pero vamos a añadirle algunos refinamientos que marcan la diferencia entre una herramienta útil y una herramienta profesional.
4.1 Ordenación por porcentaje de uso
Añade una función para ordenar las monturas de más usada a menos usada:
fn ordenar_por_uso(monturas: &mut Vec<Montura>) {
monturas.sort_by(|a, b| b.uso_porcentaje.cmp(&a.uso_porcentaje));
}
Y llámala antes de mostrar la tabla:
let mut monturas = obtener_monturas()?;
ordenar_por_uso(&mut monturas);
4.2 Destacar la montura raíz
La montura raíz (/) merece un tratamiento especial porque es la más crítica del sistema. Añade un indicador visual:
fn formatear_punto_montura(punto: &str, es_raiz: bool) -> String {
if es_raiz {
format!("{} {}", punto.bold().underline(), "★".yellow())
} else {
punto.to_string()
}
}
4.3 Spinner de carga con indicatif
Cuando el dashboard tarda en cargar (por ejemplo, en sistemas con muchas monturas NFS que responden lento), un spinner indica que el programa está trabajando:
fn mostrar_con_spinner() -> Result<Vec<Montura>> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.templa("{spinner:.green} Obteniendo monturas...")
);
pb.enable_steady_tick(Duration::from_millis(100));
let monturas = obtener_monturas()?;
pb.finish_and_clear();
Ok(monturas)
}
indicatif soporta varios tipos de spinners: dots, line, arrow, braille, etc. El método enable_steady_tick hace que el spinner gire automáticamente cada 100ms mientras la operación se completa.
4.4 Versión completa de la función mostrar_dashboard
Ahora integra todas las mejoras. Sustituye la función mostrar_dashboard anterior por esta versión mejorada:
fn mostrar_dashboard(args: &Args) -> Result<()> {
let mut monturas = {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} Escaneando monturas...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let m = obtener_monturas()?;
pb.finish_and_clear();
m
};
// Ordenar por uso descendente
monturas.sort_by(|a, b| b.uso_porcentaje.cmp(&a.uso_porcentaje));
// Filtrar por umbral si es necesario
let monturas_filtradas: Vec<&Montura> = monturas
.iter()
.filter(|m| m.uso_porcentaje >= args.threshold)
.collect();
if monturas_filtradas.is_empty() {
println!("{}", "No hay monturas que mostrar con el umbral especificado.".yellow());
return Ok(());
}
// Cabecera con timestamp
let ahora = Local::now();
println!();
println!(
" {} {}",
"🦀 crustaceo-df".bold().cyan(),
ahora.format("%Y-%m-%d %H:%M:%S").to_string().dimmed()
);
println!(" {}", "─".repeat(58).dimmed());
println!();
// Construir tabla
let rows: Vec<FilaMontura> = monturas_filtradas
.iter()
.map(|m| FilaMontura::desde_montura(m))
.collect();
let mut tabla = Table::new(&rows);
tabla.with(Style::rounded());
tabla.with(Modify::new(Rows::first()).with(Alignment::center()));
println!("{}", tabla);
// Resumen
let total_monturas = monturas_filtradas.len();
let total_tamano: u64 = monturas_filtradas.iter().map(|m| m.tamano_total).sum();
let total_usado: u64 = monturas_filtradas.iter().map(|m| m.tamano_usado).sum();
let total_disponible: u64 = monturas_filtradas.iter().map(|m| m.tamano_disponible).sum();
let uso_global = if total_tamano > 0 {
(total_usado as f64 / total_tamano as f64 * 100.0) as u8
} else {
0
};
let color_global = match uso_global {
0..=74 => "green",
75..=89 => "yellow",
_ => "red",
};
println!();
println!(" {}", "📊 Resumen".bold());
println!(" {}", "─".repeat(58).dimmed());
println!(
" Monturas: {} | Total: {} | Usado: {} | Disponible: {}",
total_monturas.to_string().bold(),
bytes_a_humano(total_tamano).bold(),
bytes_a_humano(total_usado).red().bold(),
bytes_a_humano(total_disponible).green().bold(),
);
println!(
" Uso global: {} {}",
format!("{}%", uso_global)
.bold()
.color(color_global),
barra_uso(uso_global, 20, color_global),
);
println!();
// Advertencias
let criticas: Vec<&Montura> = monturas_filtradas
.iter()
.filter(|m| m.uso_porcentaje >= 90)
.collect();
if !criticas.is_empty() {
println!(" {}", "⚠️ Monturas críticas (>90%):".red().bold());
for m in &criticas {
println!(
" {} ({}%) {}",
m.filesystem.red(),
m.uso_porcentaje.to_string().red().bold(),
m.punto_montura.red()
);
}
println!();
}
let altas: Vec<&Montura> = monturas_filtradas
.iter()
.filter(|m| (75..90).contains(&m.uso_porcentaje))
.collect();
if !altas.is_empty() {
println!(" {}", "⚡ Monturas altas (>75%):".yellow().bold());
for m in &altas {
println!(
" {} ({}%) {}",
m.filesystem.yellow(),
m.uso_porcentaje.to_string().yellow().bold(),
m.punto_montura.yellow()
);
}
println!();
}
Ok(())
}
Fíjate en los detalles:
- El spinner
ProgressBar::new_spinner()se muestra mientras se ejecutaobtener_monturas(). Cuando termina, se oculta confinish_and_clear(). - La ordenación
sort_bypone las monturas más usadas primero, que es lo que más te importa como sysadmin. - El color del resumen global sigue los mismos umbrales que las monturas individuales.
- Las monturas críticas (>90%) y altas (>75%) se listan por separado con sus respectivos iconos y colores.
Paso 5: El Cargo.toml completo
Asegúrate de que tu Cargo.toml tiene las dependencias correctas. Debe ser exactamente así:
[package]
name = "crustaceo-df"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = «1»
chrono = { version = «0.4», features = [«serde»] }
clap = { version = «4», features = [«derive»] }
colored = «2»
indicatif = «0.17»
tabled = «0.18»
Código completo
Aquí tienes el código completo de ambos archivos para que no tengas que ir pegando fragmentos.
src/main.rs
use anyhow::Result;
use chrono::Local;
use clap::Parser;
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
use tabled::{
settings::{object::Rows, Alignment, Style},
Table, Tabled,
};
mod df;
use df::{bytes_a_humano, obtener_monturas, Montura};
/// Dashboard de sistema para monitorizar monturas de disco
#[derive(Parser)]
#[command(
name = "crustaceo-df",
version,
about = "Dashboard de monturas con tablas, barras y colores",
long_about = "crustaceo-df muestra las monturas del sistema en una tabla formateada \
con colores según umbrales de uso, barras de progreso visuales \
por cada montura, y modo watch para actualización periódica."
)]
struct Args {
/// Intervalo en segundos para modo watch
#[arg(short = 'w', long = "watch", default_value_t = 0)]
watch: u64,
/// Mostrar solo monturas con más del N% de uso
#[arg(short = 't', long = "threshold", default_value_t = 0)]
threshold: u8,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.watch > 0 {
loop {
mostrar_dashboard(&args)?;
thread::sleep(Duration::from_secs(args.watch));
print!("\x1B[2J\x1B[1;1H");
}
} else {
mostrar_dashboard(&args)?;
}
Ok(())
}
fn mostrar_dashboard(args: &Args) -> Result<()> {
let mut monturas = {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} Escaneando monturas...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let m = obtener_monturas()?;
pb.finish_and_clear();
m
};
// Ordenar por uso descendente
monturas.sort_by(|a, b| b.uso_porcentaje.cmp(&a.uso_porcentaje));
// Filtrar por umbral
let monturas_filtradas: Vec<&Montura> = monturas
.iter()
.filter(|m| m.uso_porcentaje >= args.threshold)
.collect();
if monturas_filtradas.is_empty() {
println!("{}", "No hay monturas que mostrar con el umbral especificado.".yellow());
return Ok(());
}
// Cabecera con timestamp
let ahora = Local::now();
println!();
println!(
" {} {}",
"🦀 crustaceo-df".bold().cyan(),
ahora.format("%Y-%m-%d %H:%M:%S").to_string().dimmed()
);
println!(" {}", "─".repeat(58).dimmed());
println!();
// Construir tabla
let rows: Vec<FilaMontura> = monturas_filtradas
.iter()
.map(|m| FilaMontura::desde_montura(m))
.collect();
let mut tabla = Table::new(&rows);
tabla.with(Style::rounded());
tabla.with(Modify::new(Rows::first()).with(Alignment::center()));
println!("{}", tabla);
// Resumen
let total_monturas = monturas_filtradas.len();
let total_tamano: u64 = monturas_filtradas.iter().map(|m| m.tamano_total).sum();
let total_usado: u64 = monturas_filtradas.iter().map(|m| m.tamano_usado).sum();
let total_disponible: u64 = monturas_filtradas.iter().map(|m| m.tamano_disponible).sum();
let uso_global = if total_tamano > 0 {
(total_usado as f64 / total_tamano as f64 * 100.0) as u8
} else {
0
};
let color_global = match uso_global {
0..=74 => "green",
75..=89 => "yellow",
_ => "red",
};
println!();
println!(" {}", "📊 Resumen".bold());
println!(" {}", "─".repeat(58).dimmed());
println!(
" Monturas: {} | Total: {} | Usado: {} | Disponible: {}",
total_monturas.to_string().bold(),
bytes_a_humano(total_tamano).bold(),
bytes_a_humano(total_usado).red().bold(),
bytes_a_humano(total_disponible).green().bold(),
);
println!(
" Uso global: {} {}",
format!("{}%", uso_global)
.bold()
.color(color_global),
barra_uso(uso_global, 20, color_global),
);
println!();
// Advertencias críticas (>90%)
let criticas: Vec<&Montura> = monturas_filtradas
.iter()
.filter(|m| m.uso_porcentaje >= 90)
.collect();
if !criticas.is_empty() {
println!(" {}", "⚠️ Monturas críticas (>90%):".red().bold());
for m in &criticas {
println!(
" {} ({}%) {}",
m.filesystem.red(),
m.uso_porcentaje.to_string().red().bold(),
m.punto_montura.red()
);
}
println!();
}
// Advertencias altas (>75%)
let altas: Vec<&Montura> = monturas_filtradas
.iter()
.filter(|m| (75..90).contains(&m.uso_porcentaje))
.collect();
if !altas.is_empty() {
println!(" {}", "⚡ Monturas altas (>75%):".yellow().bold());
for m in &altas {
println!(
" {} ({}%) {}",
m.filesystem.yellow(),
m.uso_porcentaje.to_string().yellow().bold(),
m.punto_montura.yellow()
);
}
println!();
}
Ok(())
}
#[derive(Tabled)]
struct FilaMontura {
#[tabled(rename = "Montura")]
punto_montura: String,
#[tabled(rename = "Filesystem")]
filesystem: String,
#[tabled(rename = "Tamaño")]
tamano: String,
#[tabled(rename = "Usado")]
usado: String,
#[tabled(rename = "Disponible")]
disponible: String,
#[tabled(rename = "Uso%")]
uso_str: String,
#[tabled(rename = "Barra")]
barra: String,
}
impl FilaMontura {
fn desde_montura(m: &Montura) -> Self {
let color = match m.uso_porcentaje {
0..=74 => "green",
75..=89 => "yellow",
_ => "red",
};
Self {
punto_montura: m.punto_montura.clone(),
filesystem: m.filesystem.clone(),
tamano: bytes_a_humano(m.tamano_total),
usado: bytes_a_humano(m.tamano_usado),
disponible: bytes_a_humano(m.tamano_disponible),
uso_str: format!("{}%", m.uso_porcentaje),
barra: barra_uso(m.uso_porcentaje, 12, color),
}
}
}
fn barra_uso(porcentaje: u8, ancho: usize, color: &str) -> String {
let relleno = ((porcentaje as f64 / 100.0) * ancho as f64).round() as usize;
let relleno = relleno.min(ancho);
let vacio = ancho - relleno;
let barra_llena = "█".repeat(relleno);
let barra_vacia = "░".repeat(vacio);
match color {
"red" => format!("{}{}", barra_llena.red(), barra_vacia.dimmed()),
"yellow" => format!("{}{}", barra_llena.yellow(), barra_vacia.dimmed()),
_ => format!("{}{}", barra_llena.green(), barra_vacia.dimmed()),
}
}
src/df.rs
use anyhow::{Context, Result};
use std::process::Command;
use std::fmt;
#[derive(Debug, Clone)]
pub struct Montura {
pub filesystem: String,
pub tamano_total: u64,
pub tamano_usado: u64,
pub tamano_disponible: u64,
pub uso_porcentaje: u8,
pub punto_montura: String,
}
impl fmt::Display for Montura {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} -> {} ({}%)",
self.filesystem, self.punto_montura, self.uso_porcentaje
)
}
}
pub fn bytes_a_humano(bytes: u64) -> String {
const UNIDADES: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut valor = bytes as f64;
let mut unidad_idx = 0;
while valor >= 1024.0 && unidad_idx < UNIDADES.len() - 1 {
valor /= 1024.0;
unidad_idx += 1;
}
if unidad_idx == 0 {
format!("{} {}", bytes, UNIDADES[unidad_idx])
} else {
format!("{:.2} {}", valor, UNIDADES[unidad_idx])
}
}
pub fn obtener_monturas() -> Result<Vec<Montura>> {
let salida = Command::new("df")
.arg("--block-size=1")
.arg("--exclude-type=tmpfs")
.arg("--exclude-type=devtmpfs")
.arg("--exclude-type=squashfs")
.arg("--exclude-type=overlay")
.output()
.context("No se pudo ejecutar df. ¿Estás en Linux?")?;
if !salida.status.success() {
anyhow::bail!(
"df terminó con código {}: {}",
salida.status.code().unwrap_or(-1),
String::from_utf8_lossy(&salida.stderr)
);
}
let stdout = String::from_utf8(salida.stdout)
.context("La salida de df no es UTF-8 válido")?;
let mut monturas = Vec::new();
for linea in stdout.lines().skip(1) {
if linea.trim().is_empty() {
continue;
}
let campos: Vec<&str> = linea.split_whitespace().collect();
if campos.len() < 6 {
continue;
}
let uso_str = campos[campos.len() - 2];
let punto_montura = campos[campos.len() - 1];
let filesystem = campos[..campos.len() - 4].join(" ");
let tamano_total: u64 = campos[campos.len() - 4]
.parse()
.context("No se pudo parsear tamaño total")?;
let tamano_usado: u64 = campos[campos.len() - 3]
.parse()
.context("No se pudo parsear tamaño usado")?;
// El campo de disponible está en la posición len-2, y uso_str también
// apunta ahí. Pero en realidad, los campos son:
// filesystem | tamaño | usado | disponible | uso% | montura
// Así que disponible es campos[len-3], no len-2.
// Vamos a corregirlo:
let tamano_disponible: u64 = campos[campos.len() - 3]
.parse()
.context("No se pudo parsear tamaño disponible")?;
// Y uso_str es campos[campos.len() - 2] (el penúltimo)
// que ya lo tenemos arriba
let uso_porcentaje: u8 = uso_str
.trim_end_matches('%')
.parse()
.context("No se pudo parsear porcentaje de uso")?;
monturas.push(Montura {
filesystem: if filesystem.is_empty() {
String::from("?")
} else {
filesystem
},
tamano_total,
tamano_usado,
tamano_disponible,
uso_porcentaje,
punto_montura: punto_montura.to_string(),
});
}
Ok(monturas)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bytes_a_humano() {
assert_eq!(bytes_a_humano(0), "0 B");
assert_eq!(bytes_a_humano(500), "500 B");
assert_eq!(bytes_a_humano(1024), "1.00 KB");
assert_eq!(bytes_a_humano(1_048_576), "1.00 MB");
assert_eq!(bytes_a_humano(1_073_741_824), "1.00 GB");
assert_eq!(bytes_a_humano(1_099_511_627_776), "1.00 TB");
}
#[test]
fn test_obtener_monturas() {
let monturas = obtener_monturas().unwrap();
assert!(!monturas.is_empty());
let raiz = monturas.iter().find(|m| m.punto_montura == "/");
assert!(raiz.is_some());
}
}
Espera, un error en el parseo
Si has estado atento, habrás visto un problema en obtener_monturas. El parseo que hicimos inicialmente tenía un error: asignábamos el mismo campo (campos[len-2]) tanto a tamano_disponible como a uso_str. Vamos a corregirlo.
La salida de df --block-size=1 tiene esta estructura:
Filesystem 1B-blocks Used Available Use% Mounted on
/dev/sda1 240057454592 105638998016 122314321920 47% /
Los campos son:
[0]Filesystem[1]1B-blocks (tamaño total en bytes)[2]Used[3]Available (disponible)[4]Use% (uso)[5]Mounted on (punto de montura)
Así que la corrección es:
let tamano_total = campos[campos.len() - 4]; // 1B-blocks
let tamano_usado = campos[campos.len() - 3]; // Used
let tamano_disponible = campos[campos.len() - 3]; // ??? ¡Sigue mal!
No, espera. Si len=6: len-4=2 (tamaño), len-3=3 (usado), len-2=4 (disponible/uso%), len-1=5 (montura). Entonces:
campos[1]= 1B-blocks (tamaño)campos[2]= Usedcampos[3]= Availablecampos[4]= Use%
Con len=6: len-4=2 → no es correcto.
La forma correcta: dado que sabemos que la estructura es fija (6 campos para monturas simples), podemos acceder directamente:
let tamano_total: u64 = campos[1].parse()?;
let tamano_usado: u64 = campos[2].parse()?;
let tamano_disponible: u64 = campos[3].parse()?;
let uso_porcentaje: u8 = campos[4].trim_end_matches('%').parse()?;
let punto_montura = campos[5];
Pero si el filesystem contiene espacios, los campos se desplazan. La estrategia correcta es la que usamos al principio: contar desde el final. Para len=6:
campos[len-5]= filesystemcampos[len-4]= tamaño (1B-blocks)campos[len-3]= usado (Used)campos[len-2]= disponible (Available)campos[len-1]= uso% (Use%)
No, esto sigue sin cuadrar. Vamos paso a paso.
Para una línea como:
/dev/sda1 240057454592 105638998016 122314321920 47% /
len = 6. Entonces:
| Índice | Valor | Desde el final |
|---|---|---|
| 0 | /dev/sda1 | len-6 |
| 1 | 240057454592 | len-5 |
| 2 | 105638998016 | len-4 |
| 3 | 122314321920 | len-3 |
| 4 | 47% | len-2 |
| 5 | / | len-1 |
El campo disponible es campos[3] = campos[len-3].
El campo uso% es campos[4] = campos[len-2].
NO son el mismo campo. En mi primera versión los confundí. La versión corregida en df.rs de arriba ya lo arregla. Asegúrate de que tu código usa campos[campos.len() - 3] para disponible y campos[campos.len() - 2] para uso%.
Ejecutando crustaceo-df
Compila y ejecuta:
cd ~/crustaceo-df
cargo build --release
./target/release/crustaceo-df
Para ver la ayuda:
./target/release/crustaceo-df --help
Verás:
🦀 crustaceo-df 0.1.0
Dashboard de monturas con tablas, barras y colores
USAGE:
crustaceo-df [OPTIONS]
OPTIONS:
-h, --help Print help
-t, --threshold <THRESHOLD> Mostrar solo monturas con más del N% de uso [default: 0]
-V, --version Print version
-w, --watch <WATCH> Intervalo en segundos para modo watch [default: 0]
Para modo watch actualizando cada 5 segundos:
./target/release/crustaceo-df -w 5
Para ver solo monturas con más del 50% de uso:
./target/release/crustaceo-df -t 50
Para ambas:
./target/release/crustaceo-df -w 10 -t 75
Esto actualiza cada 10 segundos mostrando solo las monturas con más del 75% de uso. Ideal para tenerlo corriendo en una terminal mientras trabajas.
Verificación
Script de verificación completo para asegurarte de que todo funciona:
#!/bin/bash
# test-crustaceo-df.sh
set -e
echo "🔍 Prueba 1: Compilación"
cd ~/crustaceo-df
cargo build --release 2>&1 | tail -1
echo ""
echo "🔍 Prueba 2: Ayuda"
./target/release/crustaceo-df --help | head -10
echo ""
echo "🔍 Prueba 3: Ejecución básica"
./target/release/crustaceo-df 2>&1 | head -20
echo ""
echo "🔍 Prueba 4: Modo watch (2 segundos, 1 iteración con timeout)"
timeout 3 ./target/release/crustaceo-df -w 2 2>&1 || true
echo ""
echo "🔍 Prueba 5: Umbral al 50%"
./target/release/crustaceo-df -t 50 2>&1
echo ""
echo "🔍 Prueba 6: Umbral al 100% (no debería mostrar nada)"
./target/release/crustaceo-df -t 100 2>&1
echo ""
echo "🔍 Prueba 7: Tests unitarios"
cargo test 2>&1 | tail -10
echo ""
echo "🔍 Prueba 8: El binario es estático"
file ./target/release/crustaceo-df | grep -q "ELF"
echo "Es un binario ELF: OK"
echo ""
echo "🔍 Prueba 9: Tamaño del binario"
ls -lh ./target/release/crustaceo-df | awk '{print $5}'
echo ""
echo "✅ Todas las pruebas completadas"
Ejecuta:
chmod +x test-crustaceo-df.sh
./test-crustaceo-df.sh
Cada prueba debe mostrar la salida esperada. Si alguna falla, revisa el código correspondiente.
Resumen del capítulo
En este capítulo has aprendido a:
- Construir tablas profesionales con
tabled, derivando el traitTabledesde un struct y personalizando cabeceras, bordes y alineación. - Dibujar barras de progreso visuales con
indicatif, usando caracteres Unicode (█y░) combinados con colores decolored. - Formatear bytes a formato legible con una función propia que no depende de ninguna librería externa.
- Obtener datos del sistema parseando la salida de
dfconstd::process::Command, manejando correctamente los distintos formatos de salida. - Implementar modo watch con un bucle que limpia la pantalla, duerme y vuelve a mostrar los datos actualizados.
- Aplicar umbrales de color (verde <75%, amarillo 75-89%, rojo ≥90%) para identificar visualmente las monturas problemáticas.
- Mostrar spinners de carga con
indicatifmientras se ejecutan operaciones lentas.
Y lo más importante: has construido una herramienta que en Bash requeriría watch, df, awk, grep, column y un montón de tuberías y formateo manual. En Rust, todo está en un solo binario, compilado, rápido y con una interfaz limpia.
Referencias
- Tutorial de herramientas Linux en atareao.es — gestión de herramientas, binarios y automatización en Linux, donde encaja un dashboard como crustaceo-df
- Tutorial de Monitoreo en atareao.es — monitorización de sistemas: discos, CPU, memoria, red, y cómo construir tus propias herramientas de vigilancia
- Tutorial Self-Hosted en atareao.es — servicios auto-alojados donde la monitorización de discos es crítica
- Crate tabled en crates.io — la crate de tablas para terminal, rápida y flexible
- Documentación de tabled — API completa, estilos, modificadores y ejemplos
- Crate indicatif en crates.io — barras de progreso, spinners y medidores para Rust
- Documentación de indicatif — todos los estilos de barra y spinner, templates personalizados
- Crate chrono en crates.io — fecha y hora para Rust
- Documentación de chrono — formateo, zonas horarias, duraciones
- Crate colored en crates.io — coloración de terminal
- Crate clap en crates.io — argumentos CLI profesionales
- Crate anyhow en crates.io — manejo de errores simplificado
- ANSI escape codes — Wikipedia — los códigos de escape que usamos para limpiar pantalla y posicionar cursor
- GNU coreutils df — manual — opciones de df, formatos de salida,
--block-sizey--exclude-type - Rust by Example — std::process::Command
- The Rust Standard Library — std::process