stackpatrol 0.1.0

Single-binary Rust CLI that monitors a server and reports to the StackPatrol control plane.
use std::io::{self, BufRead, Write};
use std::time::Instant;

use anyhow::{Context, Result, bail};
use stackpatrol_core::event::Event;
use stackpatrol_core::token;
use sysinfo::{Disks, System};

use crate::buffer::{self, Buffer};
use crate::client::{build_client, events_url, send_event};
use crate::config::Config;

pub fn init() -> Result<()> {
    println!("StackPatrol setup");
    println!();
    println!("1. Open @StackPatrolBot on Telegram and run /start");
    println!("2. Tap \"Add server\" and give it a name");
    println!("3. Paste the token it gives you below");
    println!();

    let server_name = prompt("Server name (e.g. web-01)")?;
    if server_name.is_empty() {
        bail!("server name cannot be empty");
    }

    let raw_token = prompt("Server token (sp_live_…)")?;
    token::validate(&raw_token).context("invalid token format")?;

    let api_endpoint = prompt_default(
        "API endpoint",
        "https://api.stackpatrol.dev",
    )?;

    let cfg = Config {
        server_name,
        token: raw_token,
        api_endpoint,
        daemon: Default::default(),
        docker: Default::default(),
        systemd: Default::default(),
        resources: Default::default(),
        ports: Default::default(),
    };
    let path = cfg.save()?;

    println!();
    println!("✔ wrote {}", path.display());
    println!("  next: stackpatrol alert-test");
    Ok(())
}

pub async fn status() -> Result<()> {
    let cfg = Config::load().context("could not load config — run `stackpatrol init` first")?;

    println!(
        "StackPatrol — server: {}, endpoint: {}",
        cfg.server_name, cfg.api_endpoint
    );
    println!();

    // System
    let mut sys = System::new();
    sys.refresh_memory();
    let disks = Disks::new_with_refreshed_list();
    let load = System::load_average();

    println!("System");
    println!("  uptime           {}", format_duration(System::uptime()));
    println!(
        "  load (1m/5m/15m) {:.2} / {:.2} / {:.2}",
        load.one, load.five, load.fifteen
    );

    let mem_total = sys.total_memory();
    let mem_used = sys.used_memory();
    let mem_pct = if mem_total > 0 {
        (mem_used as f64 / mem_total as f64 * 100.0).round() as u32
    } else {
        0
    };
    println!(
        "  memory           {} / {} ({}%)",
        format_bytes(mem_used),
        format_bytes(mem_total),
        mem_pct
    );

    println!("  disks");
    for disk in &disks {
        let total = disk.total_space();
        if total == 0 {
            continue;
        }
        let used = total.saturating_sub(disk.available_space());
        let pct = (used as f64 / total as f64 * 100.0).round() as u32;
        println!(
            "    {:<30} {:>3}% ({} / {})",
            disk.mount_point().display(),
            pct,
            format_bytes(used),
            format_bytes(total)
        );
    }

    // Probes
    println!();
    println!("Probes");
    println!(
        "  docker     {}",
        if cfg.docker.projects.is_empty() {
            "off".to_string()
        } else {
            format!("{} project(s)", cfg.docker.projects.len())
        }
    );
    println!(
        "  systemd    {}",
        if cfg.systemd.units.is_empty() {
            "off".to_string()
        } else {
            format!("{} unit(s)", cfg.systemd.units.len())
        }
    );
    println!(
        "  ports      {}",
        if cfg.ports.tcp.is_empty() {
            "off".to_string()
        } else {
            format!("{} TCP target(s)", cfg.ports.tcp.len())
        }
    );
    let load_threshold = if cfg.resources.load_1m_high > 0.0 {
        format!("{:.2}", cfg.resources.load_1m_high)
    } else {
        "auto".to_string()
    };
    println!(
        "  resources  disk≥{}% mem≥{}% load1≥{}",
        cfg.resources.disk_high_percent, cfg.resources.memory_high_percent, load_threshold
    );

    // Buffer
    println!();
    println!("Buffer");
    let buffer = Buffer::new(buffer::default_path()?);
    let queued = buffer.read_all().await.unwrap_or_default();
    println!(
        "  queue      {} event(s) at {}",
        queued.len(),
        buffer.path().display()
    );

    // API reachability — any HTTP response means the endpoint resolves and connects.
    // We don't validate status code: a 401/404 still proves the API is up.
    println!();
    println!("API");
    let url = events_url(&cfg);
    let client = build_client()?;
    let started = Instant::now();
    match client.get(&cfg.api_endpoint).send().await {
        Ok(resp) => println!(
            "  reachable  ✓ HTTP {} ({}ms)",
            resp.status(),
            started.elapsed().as_millis()
        ),
        Err(e) => println!("  reachable  ✗ {e}"),
    }
    println!("  endpoint   {url}");

    Ok(())
}

pub async fn alert_test() -> Result<()> {
    let cfg = Config::load().context("could not load config — run `stackpatrol init` first")?;

    let url = events_url(&cfg);
    let client = build_client()?;

    println!("→ sending service_down(\"alert-test\") to {url}");
    send_event(&client, &url, &cfg, Event::ServiceDown { name: "alert-test".into() }).await?;
    println!("  ✔ delivered. Check Telegram — you should see a 🔴 alert in a few seconds.");

    // Auto-recover so the demo doesn't leave a fake outage on the dashboard.
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;

    println!("→ sending service_up(\"alert-test\") to clear the demo alert");
    send_event(&client, &url, &cfg, Event::ServiceUp { name: "alert-test".into() }).await?;
    println!("  ✔ delivered.");

    Ok(())
}

fn prompt(label: &str) -> Result<String> {
    print!("{label}: ");
    io::stdout().flush().ok();
    let mut line = String::new();
    io::stdin().lock().read_line(&mut line)?;
    Ok(line.trim().to_string())
}

fn prompt_default(label: &str, default: &str) -> Result<String> {
    print!("{label} [{default}]: ");
    io::stdout().flush().ok();
    let mut line = String::new();
    io::stdin().lock().read_line(&mut line)?;
    let trimmed = line.trim();
    if trimmed.is_empty() {
        Ok(default.to_string())
    } else {
        Ok(trimmed.to_string())
    }
}

fn format_bytes(n: u64) -> String {
    const KIB: u64 = 1024;
    const MIB: u64 = KIB * 1024;
    const GIB: u64 = MIB * 1024;
    const TIB: u64 = GIB * 1024;
    if n >= TIB {
        format!("{:.1} TiB", n as f64 / TIB as f64)
    } else if n >= GIB {
        format!("{:.1} GiB", n as f64 / GIB as f64)
    } else if n >= MIB {
        format!("{:.1} MiB", n as f64 / MIB as f64)
    } else if n >= KIB {
        format!("{:.1} KiB", n as f64 / KIB as f64)
    } else {
        format!("{n} B")
    }
}

fn format_duration(secs: u64) -> String {
    let days = secs / 86_400;
    let hours = (secs % 86_400) / 3_600;
    let minutes = (secs % 3_600) / 60;
    let seconds = secs % 60;
    if days > 0 {
        format!("{days}d {hours}h {minutes}m")
    } else if hours > 0 {
        format!("{hours}h {minutes}m")
    } else if minutes > 0 {
        format!("{minutes}m {seconds}s")
    } else {
        format!("{seconds}s")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn format_bytes_picks_unit() {
        assert_eq!(format_bytes(0), "0 B");
        assert_eq!(format_bytes(512), "512 B");
        assert_eq!(format_bytes(2048), "2.0 KiB");
        assert_eq!(format_bytes(2 * 1024 * 1024), "2.0 MiB");
        assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
    }

    #[test]
    fn format_duration_compacts() {
        assert_eq!(format_duration(0), "0s");
        assert_eq!(format_duration(45), "45s");
        assert_eq!(format_duration(125), "2m 5s");
        assert_eq!(format_duration(3661), "1h 1m");
        assert_eq!(format_duration(90061), "1d 1h 1m");
    }
}