use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
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, config_path};
use crate::style;
pub fn init() -> Result<()> {
println!("{}", style::header("StackPatrol setup"));
println!();
println!(
" {} Open @StackPatrolBot on Telegram and run /start",
style::dim("1.")
);
println!(
" {} Tap \"Add server\" and give it a name",
style::dim("2.")
);
println!(" {} Paste the token it gives you below", style::dim("3."));
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 {}",
style::ok_mark(),
style::cyan(&path.display().to_string())
);
let daemon_running = try_autostart_systemd();
println!();
println!("{}", style::bold("Next steps:"));
println!(
" {} {} {}",
style::dim("1."),
style::cyan("stackpatrol alert-test"),
style::dim("# verify token + Telegram delivery")
);
println!(
" {} {} {}",
style::dim("2."),
style::cyan("stackpatrol probe add …"),
style::dim("# tell it what to watch (try `stackpatrol probe --help`)")
);
if !daemon_running {
println!(
" {} {} {}",
style::dim("3."),
style::cyan("stackpatrol daemon"),
style::dim("# or install the systemd unit (see packaging/)")
);
}
Ok(())
}
fn try_autostart_systemd() -> bool {
if std::env::var("STACKPATROL_NO_AUTOSTART").is_ok() {
return false;
}
let unit = std::path::Path::new("/etc/systemd/system/stackpatrol.service");
if !unit.exists() {
return false;
}
println!();
println!(
"{} systemd unit detected — starting daemon...",
style::dim("→"),
);
let enable = std::process::Command::new("systemctl")
.args(["enable", "stackpatrol.service"])
.output();
let restart = std::process::Command::new("systemctl")
.args(["restart", "stackpatrol.service"])
.output();
match (enable, restart) {
(Ok(e), Ok(r)) if e.status.success() && r.status.success() => {
println!(
" {} {} the dashboard should mark this server online within ~30s",
style::ok_mark(),
style::dim("daemon running —"),
);
println!(
" {} tail logs: {}",
style::dim("→"),
style::cyan("journalctl -u stackpatrol -f"),
);
true
}
(e, r) => {
println!(
" {} could not start daemon automatically (likely needs sudo)",
style::warn_mark(),
);
for stream in [e, r].iter().filter_map(|x| x.as_ref().ok()) {
let msg = String::from_utf8_lossy(&stream.stderr);
let trimmed = msg.trim();
if !trimmed.is_empty() {
eprintln!(" {}", style::dim(trimmed));
}
}
println!(
" {} start it manually: {}",
style::dim("→"),
style::cyan("sudo systemctl enable --now stackpatrol"),
);
false
}
}
}
pub async fn status() -> Result<()> {
let cfg = Config::load().context("could not load config — run `stackpatrol init` first")?;
println!(
"{} — server: {}, endpoint: {}",
style::header("StackPatrol"),
style::bold(&cfg.server_name),
style::dim(&cfg.api_endpoint),
);
println!();
let mut sys = System::new();
sys.refresh_memory();
let disks = Disks::new_with_refreshed_list();
let load = System::load_average();
println!("{}", style::header("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
};
let mem_pct_str = format!("{mem_pct}%");
let mem_pct_styled = colorize_percent(&mem_pct_str, mem_pct, cfg.resources.memory_high_percent);
println!(
" memory {} / {} ({})",
format_bytes(mem_used),
format_bytes(mem_total),
mem_pct_styled,
);
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;
let pct_str = format!("{pct:>3}%");
let pct_styled = colorize_percent(&pct_str, pct, cfg.resources.disk_high_percent);
println!(
" {:<30} {} ({} / {})",
disk.mount_point().display(),
pct_styled,
format_bytes(used),
format_bytes(total)
);
}
println!();
println!("{}", style::header("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()
};
let load_warn = if cfg.resources.load_1m_warning > 0.0 {
format!("{:.2}", cfg.resources.load_1m_warning)
} else {
"auto".to_string()
};
let disk_warn = effective_warning_percent(
cfg.resources.disk_warning_percent,
cfg.resources.disk_high_percent,
);
let mem_warn = effective_warning_percent(
cfg.resources.memory_warning_percent,
cfg.resources.memory_high_percent,
);
println!(
" resources disk warn≥{}% / crit≥{}% mem warn≥{}% / crit≥{}% load1 warn≥{} / crit≥{}",
disk_warn,
cfg.resources.disk_high_percent,
mem_warn,
cfg.resources.memory_high_percent,
load_warn,
load_threshold,
);
println!();
println!("{}", style::header("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()
);
println!();
println!("{}", style::header("API"));
let url = events_url(&cfg);
let client = build_client()?;
let started = Instant::now();
match client.get(&url).send().await {
Ok(resp) => {
let code = resp.status();
let elapsed = started.elapsed().as_millis();
let mark = if code.is_success()
|| code.is_redirection()
|| matches!(code.as_u16(), 401 | 403 | 405)
{
style::ok_mark()
} else {
style::warn_mark()
};
println!(" reachable {mark} HTTP {code} ({elapsed}ms)");
}
Err(e) => println!(" reachable {} {e}", style::err_mark()),
}
println!(" endpoint {}", style::dim(&url));
if cfg.docker.projects.is_empty() && cfg.systemd.units.is_empty() && cfg.ports.tcp.is_empty() {
println!();
println!(
"{} no probes configured. Add some with: {}",
style::warn_mark(),
style::cyan("stackpatrol probe add --help"),
);
}
Ok(())
}
fn colorize_percent(label: &str, pct: u32, critical: u8) -> String {
let crit = critical as u32;
if pct >= crit {
style::red(label)
} else if pct + 10 >= crit {
style::yellow(label)
} else {
style::green(label)
}
}
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.");
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(())
}
const COMPOSE_FILES: &[&str] = &[
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
pub fn probe_ls() -> Result<()> {
let cfg = Config::load().context("could not load config — run `stackpatrol init` first")?;
let path = config_path()?;
println!(
"{} — server: {}",
style::header("Configured probes"),
style::bold(&cfg.server_name)
);
println!();
let docker: Vec<String> = cfg
.docker
.projects
.iter()
.map(|p| p.display().to_string())
.collect();
print_probe_section(
"Docker projects",
&docker,
"stackpatrol probe add docker <path>",
);
print_probe_section(
"Systemd units",
&cfg.systemd.units,
"stackpatrol probe add systemd <unit>",
);
print_probe_section(
"TCP ports",
&cfg.ports.tcp,
"stackpatrol probe add port <host:port>",
);
let r = &cfg.resources;
let load_crit = if r.load_1m_high > 0.0 {
format!("{:.2}", r.load_1m_high)
} else {
"auto".into()
};
let load_warn = if r.load_1m_warning > 0.0 {
format!("{:.2}", r.load_1m_warning)
} else {
"auto".into()
};
let disk_warn = effective_warning_percent(r.disk_warning_percent, r.disk_high_percent);
let mem_warn = effective_warning_percent(r.memory_warning_percent, r.memory_high_percent);
println!("{}", style::bold("Resources (always on)"));
println!(
" disk warn ≥{}% / crit ≥{}%",
disk_warn, r.disk_high_percent
);
println!(
" memory warn ≥{}% / crit ≥{}%",
mem_warn, r.memory_high_percent
);
println!(" load1m warn {} / crit {}", load_warn, load_crit);
println!(
" {} thresholds are edited by hand for now: {}",
style::dim("→"),
style::dim(&path.display().to_string()),
);
println!();
println!("config: {}", style::dim(&path.display().to_string()));
Ok(())
}
fn print_probe_section(title: &str, items: &[String], add_hint: &str) {
if items.is_empty() {
println!("{} — {}", style::bold(title), style::dim("off"));
println!(" add one with: {}", style::cyan(add_hint));
} else {
println!(
"{} ({})",
style::bold(title),
style::dim(&format!("{} entries", items.len())),
);
for item in items {
println!(" {} {}", style::ok_mark(), item);
}
}
println!();
}
pub fn probe_add_docker(path: PathBuf) -> Result<()> {
let canonical = path
.canonicalize()
.with_context(|| format!("path does not exist: {}", path.display()))?;
if !canonical.is_dir() {
bail!("not a directory: {}", canonical.display());
}
if !COMPOSE_FILES.iter().any(|f| canonical.join(f).is_file()) {
bail!(
"no compose file found in {} (looked for: {})",
canonical.display(),
COMPOSE_FILES.join(", ")
);
}
let mut cfg = load_config_for_edit()?;
if cfg.docker.projects.iter().any(|p| same_path(p, &canonical)) {
println!(
"{} already watching {}",
style::warn_mark(),
style::cyan(&canonical.display().to_string())
);
return Ok(());
}
cfg.docker.projects.push(canonical.clone());
let path = cfg.save()?;
print_added(
&format!("docker project {}", canonical.display()),
&path.display().to_string(),
);
Ok(())
}
pub fn probe_add_systemd(unit: String) -> Result<()> {
let unit = normalize_systemd_unit(&unit)?;
let mut cfg = load_config_for_edit()?;
if cfg.systemd.units.iter().any(|u| u == &unit) {
println!(
"{} already watching {}",
style::warn_mark(),
style::cyan(&unit)
);
return Ok(());
}
cfg.systemd.units.push(unit.clone());
let path = cfg.save()?;
print_added(&format!("systemd unit {unit}"), &path.display().to_string());
Ok(())
}
pub fn probe_add_port(target: String) -> Result<()> {
let target = validate_port_target(&target)?;
let mut cfg = load_config_for_edit()?;
if cfg.ports.tcp.iter().any(|t| t == &target) {
println!(
"{} already watching {}",
style::warn_mark(),
style::cyan(&target)
);
return Ok(());
}
cfg.ports.tcp.push(target.clone());
let path = cfg.save()?;
print_added(&format!("TCP target {target}"), &path.display().to_string());
Ok(())
}
pub fn probe_rm_docker(path: PathBuf) -> Result<()> {
let canonical = path.canonicalize().ok();
let mut cfg = load_config_for_edit()?;
let before = cfg.docker.projects.len();
cfg.docker.projects.retain(|p| {
if let Some(c) = &canonical {
!same_path(p, c)
} else {
p != &path
}
});
if cfg.docker.projects.len() == before {
bail!("not currently watching {}", path.display());
}
let saved = cfg.save()?;
print_removed(
&format!("docker project {}", path.display()),
&saved.display().to_string(),
);
Ok(())
}
pub fn probe_rm_systemd(unit: String) -> Result<()> {
let unit = normalize_systemd_unit(&unit)?;
let mut cfg = load_config_for_edit()?;
let before = cfg.systemd.units.len();
cfg.systemd.units.retain(|u| u != &unit);
if cfg.systemd.units.len() == before {
bail!("not currently watching {unit}");
}
let saved = cfg.save()?;
print_removed(
&format!("systemd unit {unit}"),
&saved.display().to_string(),
);
Ok(())
}
pub fn probe_rm_port(target: String) -> Result<()> {
let mut cfg = load_config_for_edit()?;
let before = cfg.ports.tcp.len();
cfg.ports.tcp.retain(|t| t != &target);
if cfg.ports.tcp.len() == before {
bail!("not currently watching {target}");
}
let saved = cfg.save()?;
print_removed(
&format!("TCP target {target}"),
&saved.display().to_string(),
);
Ok(())
}
fn load_config_for_edit() -> Result<Config> {
Config::load().context("could not load config — run `stackpatrol init` first")
}
fn print_added(what: &str, config_path: &str) {
println!("{} now watching {}", style::ok_mark(), style::cyan(what));
println!(" config: {}", style::dim(config_path));
println!(
" {} {}",
style::dim("→"),
style::dim(&daemon_restart_hint()),
);
}
fn print_removed(what: &str, config_path: &str) {
println!(
"{} no longer watching {}",
style::ok_mark(),
style::cyan(what)
);
println!(" config: {}", style::dim(config_path));
println!(
" {} {}",
style::dim("→"),
style::dim(&daemon_restart_hint()),
);
}
fn daemon_restart_hint() -> String {
let unit_installed = std::process::Command::new("systemctl")
.args(["cat", "stackpatrol.service"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if unit_installed {
"restart the daemon to pick up the change: sudo systemctl restart stackpatrol".to_string()
} else {
"restart the running daemon to pick up the change (or start one with `stackpatrol daemon` if it isn't running yet)".to_string()
}
}
fn normalize_systemd_unit(raw: &str) -> Result<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("unit name cannot be empty");
}
if trimmed.contains(char::is_whitespace) || trimmed.contains('/') {
bail!("unit name contains illegal characters: {trimmed}");
}
if trimmed.contains('.') {
Ok(trimmed.to_string())
} else {
Ok(format!("{trimmed}.service"))
}
}
fn validate_port_target(raw: &str) -> Result<String> {
let trimmed = raw.trim();
let (host, port) = trimmed
.rsplit_once(':')
.with_context(|| format!("expected `host:port`, got `{trimmed}`"))?;
if host.is_empty() {
bail!("host part is empty in `{trimmed}`");
}
port.parse::<u16>()
.with_context(|| format!("port part `{port}` is not a valid u16"))?;
Ok(trimmed.to_string())
}
fn same_path(a: &Path, b: &Path) -> bool {
match (a.canonicalize().ok(), b.canonicalize().ok()) {
(Some(a), Some(b)) => a == b,
_ => a == b,
}
}
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 effective_warning_percent(configured: u8, critical: u8) -> u8 {
if configured > 0 && configured < critical {
configured
} else {
critical.saturating_sub(10).max(50)
}
}
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");
}
#[test]
fn systemd_unit_appends_service_suffix() {
assert_eq!(normalize_systemd_unit("nginx").unwrap(), "nginx.service");
assert_eq!(
normalize_systemd_unit("nginx.service").unwrap(),
"nginx.service"
);
assert_eq!(
normalize_systemd_unit("my.timer").unwrap(),
"my.timer",
"respects existing suffix",
);
assert_eq!(
normalize_systemd_unit(" redis ").unwrap(),
"redis.service",
"trims whitespace",
);
}
#[test]
fn systemd_unit_rejects_garbage() {
assert!(normalize_systemd_unit("").is_err());
assert!(normalize_systemd_unit(" ").is_err());
assert!(normalize_systemd_unit("bad name").is_err());
assert!(normalize_systemd_unit("path/in/it").is_err());
}
#[test]
fn port_target_accepts_host_port() {
assert_eq!(
validate_port_target("localhost:5432").unwrap(),
"localhost:5432"
);
assert_eq!(validate_port_target("1.1.1.1:443").unwrap(), "1.1.1.1:443");
assert_eq!(
validate_port_target(" example.com:80 ").unwrap(),
"example.com:80",
"trims whitespace",
);
}
#[test]
fn port_target_rejects_garbage() {
assert!(validate_port_target("nope").is_err(), "missing colon");
assert!(validate_port_target(":80").is_err(), "empty host");
assert!(
validate_port_target("host:abc").is_err(),
"non-numeric port"
);
assert!(validate_port_target("host:99999").is_err(), "port > u16");
}
}