use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use clap::Parser;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use muxtop_core::collector::Collector;
use muxtop_core::container_engine::ContainerEngine;
use muxtop_core::docker_engine::maybe_connect_default_engine;
use muxtop_core::process::SortField;
use muxtop_core::system::SystemSnapshot;
use muxtop_proto::{RemoteCollector, parse_remote_target};
use muxtop_tui::{CliConfig, ConnectionMode};
#[derive(Clone)]
struct Token(String);
impl Token {
fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Debug for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Token(\"[REDACTED]\")")
}
}
fn read_token_file(path: &Path) -> Result<Token> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read --token-file {}", path.display()))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!(
"--token-file {} is empty after trimming whitespace",
path.display()
);
}
if trimmed.len() < 16 {
bail!(
"--token-file {} contains a {}-char token; minimum is 16 characters",
path.display(),
trimmed.len()
);
}
Ok(Token(trimmed.to_string()))
}
#[derive(Parser, Debug)]
#[command(
name = "muxtop",
about = "A modern, multiplexed system monitor for the terminal",
version,
author
)]
struct Cli {
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u64).range(1..=3600))]
refresh: u64,
#[arg(long)]
filter: Option<String>,
#[arg(long, default_value = "cpu", value_parser = clap::value_parser!(SortField))]
sort: SortField,
#[arg(long)]
tree: bool,
#[arg(long)]
about: bool,
#[arg(long)]
remote: Option<String>,
#[arg(long, env = "MUXTOP_TOKEN", conflicts_with = "token_file")]
token: Option<String>,
#[arg(long, value_name = "PATH")]
token_file: Option<PathBuf>,
#[arg(long)]
tls_ca: Option<PathBuf>,
#[arg(long, conflicts_with = "tls_ca")]
tls_skip_verify: bool,
#[arg(long, value_name = "PATH")]
docker_socket: Option<PathBuf>,
#[arg(long)]
no_containers: bool,
#[arg(long, hide = true, value_name = "SECS")]
bench_run: Option<u64>,
}
fn init_tracing() -> Result<()> {
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
let data_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("muxtop");
std::fs::create_dir_all(&data_dir).context("failed to create muxtop data directory")?;
let log_path = data_dir.join("muxtop.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.context("failed to open log file")?;
let env_filter =
EnvFilter::try_from_env("MUXTOP_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(log_file)
.with_ansi(false)
.with_target(true)
.with_thread_ids(true),
)
.with(env_filter)
.init();
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.about {
print_about();
return Ok(());
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to build tokio runtime")?;
runtime.block_on(run_app(cli))
}
async fn run_app(cli: Cli) -> Result<()> {
init_tracing()?;
tracing::info!("muxtop starting");
if cli.tls_skip_verify {
eprintln!(
"============================================================\n\
WARNING: --tls-skip-verify is set.\n\
Server certificates will NOT be validated. Anyone on the\n\
network path can impersonate the muxtop-server. Use only\n\
on a trusted local-loopback or development network.\n\
============================================================"
);
}
if cli.remote.is_some() && cli.refresh != 1 {
eprintln!("warning: --refresh is ignored in remote mode (server dictates timing)");
}
let (tx, rx) = mpsc::channel::<SystemSnapshot>(4);
let cancel = CancellationToken::new();
let container_engine: Option<Arc<dyn ContainerEngine + Send + Sync>> =
if cli.remote.is_none() && !cli.no_containers {
maybe_connect_default_engine(cli.docker_socket.as_deref()).await
} else {
None
};
let parsed_remote = if let Some(ref addr_str) = cli.remote {
let (addr, sni) = parse_remote_target(addr_str)
.with_context(|| format!("invalid --remote target: {addr_str}"))?;
Some((addr_str.clone(), addr, sni))
} else {
None
};
let collector_handle = if let Some((_, addr, ref server_name)) = parsed_remote {
let token: Token = if let Some(path) = cli.token_file.as_deref() {
read_token_file(path)?
} else {
match cli.token.clone() {
Some(t) if t.len() >= 16 => Token(t),
Some(t) if !t.is_empty() => bail!(
"Authentication token is too short ({} chars). \
Use at least 16 characters for security.",
t.len()
),
_ => bail!(
"Authentication token is required for remote connections. \
Set --token <secret>, --token-file <path>, or the MUXTOP_TOKEN env var \
(minimum 16 characters)."
),
}
};
let tls_connector = if let Some(ref ca_path) = cli.tls_ca {
muxtop_proto::tls::connector_from_ca(ca_path)
.context("failed to load TLS CA certificate")?
} else if cli.tls_skip_verify {
muxtop_proto::tls::connector_insecure()
} else {
bail!(
"TLS CA certificate is required. Use --tls-ca <path> to specify the server's \
certificate, or --tls-skip-verify for development (INSECURE)."
);
};
let remote = RemoteCollector::new(
addr,
Some(token.into_inner()),
tls_connector,
server_name.clone(),
);
remote.spawn(tx, None, cancel.clone())
} else {
let collector = Collector::with_container_engine(
Duration::from_secs(cli.refresh),
container_engine.clone(),
);
collector.spawn(tx, cancel.clone())
};
let connection_mode = if let Some((raw, addr, _)) = parsed_remote {
ConnectionMode::Remote {
hostname: raw,
addr,
}
} else {
ConnectionMode::Local
};
let config = CliConfig {
filter: cli.filter,
sort_field: cli.sort,
tree_mode: cli.tree,
connection_mode,
};
if let Some(secs) = cli.bench_run {
bench_run_loop(rx, config, Duration::from_secs(secs)).await;
cancel.cancel();
if let Err(e) = collector_handle.await {
tracing::error!("collector task panicked: {e:?}");
}
tracing::info!("muxtop bench-run shutting down");
return Ok(());
}
let tui_engine = container_engine.clone();
let tui_result = tokio::task::spawn_blocking(move || muxtop_tui::run(rx, config, tui_engine))
.await
.context("TUI thread panicked")?;
cancel.cancel();
if let Err(e) = collector_handle.await {
tracing::error!("collector task panicked: {e:?}");
}
tracing::info!("muxtop shutting down");
tui_result.context("TUI error")?;
Ok(())
}
async fn bench_run_loop(
mut rx: mpsc::Receiver<SystemSnapshot>,
config: CliConfig,
duration: Duration,
) {
let mut app =
muxtop_tui::AppState::with_config(config, muxtop_tui::terminal::TermCaps::default());
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => break,
maybe_snap = rx.recv() => match maybe_snap {
Some(snap) => {
app.apply_snapshot(snap);
app.recompute_visible();
}
None => break,
}
}
}
}
fn print_about() {
let version = env!("CARGO_PKG_VERSION");
println!("muxtop v{version}");
println!("A modern, multiplexed system monitor for the terminal");
println!();
println!("License: MIT OR Apache-2.0");
println!("Repository: https://github.com/lanexadev/muxtop");
println!("Authors: Lucas Schimmel");
println!();
println!("Privacy: muxtop collects NO telemetry, NO analytics,");
println!(" and phones home to NOBODY. Ever.");
}