Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ or export `WEBMUX_URL` + `WEBMUX_TOKEN` (flags `--url/--token` override both).
### Commands

```
webmux machines [--json] # list machines (online/offline)
webmux machines [--all] [--json] # list machines (default: online; --all includes offline)
webmux machines rm <id|name> [--yes] # forget a registered machine
webmux ls [--machine <id>] [--json] # list terminals: id, title, group, cwd, size, reachable
webmux open <machine> --cwd <dir> [--cmd <shell command>] [--group <name>] [--json]
webmux read <term> [--lines N] [--json] # capture the current screen as text
Expand Down
18 changes: 18 additions & 0 deletions crates/cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ impl HubClient {
self.get("/machines").await
}

pub async fn machines_including_offline(&self) -> Result<Vec<MachineInfo>, CliError> {
self.get("/machines?include_offline=true").await
}

pub async fn terminals(&self) -> Result<Vec<TerminalInfo>, CliError> {
self.get("/terminals").await
}
Expand Down Expand Up @@ -160,6 +164,20 @@ impl HubClient {
parse_json(response).await
}

pub async fn delete_machine(&self, machine_id: &str) -> Result<(), CliError> {
let response = self
.http
.delete(self.url(&format!("/machines/{machine_id}")))
.send()
.await
.map_err(network_error)?;
if response.status().is_success() {
Ok(())
} else {
Err(status_error(response.status(), response).await)
}
}

pub async fn delete_terminal(
&self,
machine_id: &str,
Expand Down
164 changes: 156 additions & 8 deletions crates/cli/src/commands/machines.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::{IsTerminal, Write};

use tc_protocol::MachineInfo;

use crate::client::HubClient;
use crate::resolve::short_id;
use crate::resolve::{resolve_prefix, short_id};
use crate::CliError;

/// List online machines. The hub only returns online machines, so every
/// listed machine is reachable; terminal counts come from /api/terminals.
pub async fn run(client: &HubClient, json: bool) -> Result<(), CliError> {
let machines = client.machines().await?;
/// List machines. Default is online-only (the hub's /api/machines).
/// `--all` includes offline registered hosts.
pub async fn run(client: &HubClient, json: bool, all: bool) -> Result<(), CliError> {
let machines = if all {
client.machines_including_offline().await?
} else {
client.machines().await?
};
if json {
super::out_line(&super::json_pretty(&machines)?);
return Ok(());
}

let online_ids: HashSet<String> = if all {
client
.machines()
.await?
.into_iter()
.map(|machine| machine.id)
.collect()
} else {
machines.iter().map(|machine| machine.id.clone()).collect()
};

let terminals = client.terminals().await?;
let mut counts: HashMap<&str, usize> = HashMap::new();
for terminal in &terminals {
*counts.entry(terminal.machine_id.as_str()).or_default() += 1;
}

if machines.is_empty() {
super::out_line("(no machines online)");
super::out_line(if all {
"(no machines registered)"
} else {
"(no machines online)"
});
return Ok(());
}
super::out_line(&format!(
Expand All @@ -29,13 +51,139 @@ pub async fn run(client: &HubClient, json: bool) -> Result<(), CliError> {
));
for machine in &machines {
let count = counts.get(machine.id.as_str()).copied().unwrap_or(0);
let status = if online_ids.contains(&machine.id) {
"online"
} else {
"offline"
};
super::out_line(&format!(
"{:<10} {:<24} {:>5} {:<8}",
short_id(&machine.id),
machine.name,
count,
"online"
status
));
}
Ok(())
}

/// Forget a machine. Resolves id, unique id prefix, or unique name.
pub async fn rm(client: &HubClient, query: &str, yes: bool) -> Result<(), CliError> {
let machines = client.machines_including_offline().await?;
let machine = resolve_machine(query, &machines)?;

if !yes && std::io::stdin().is_terminal() && !confirm(machine)? {
super::out_line("aborted");
return Ok(());
}

client.delete_machine(&machine.id).await?;
super::out_line(&format!("removed {}", machine.id));
Ok(())
}

fn resolve_machine<'a>(
query: &str,
machines: &'a [MachineInfo],
) -> Result<&'a MachineInfo, CliError> {
match resolve_prefix(query, machines, |machine| machine.id.as_str()) {
Ok(machine) => return Ok(machine),
Err(CliError::Usage(message)) if message.contains("ambiguous") => {
return Err(CliError::Usage(message));
}
Err(_) => {}
}

let matches: Vec<&MachineInfo> = machines
.iter()
.filter(|machine| machine.name.eq_ignore_ascii_case(query))
.collect();
match matches.len() {
1 => Ok(matches[0]),
0 => Err(CliError::Usage(format!("no machine matching '{query}'"))),
_ => {
let candidates = matches
.iter()
.map(|machine| format!(" {} {}", machine.id, machine.name))
.collect::<Vec<_>>()
.join("\n");
Err(CliError::Usage(format!(
"'{query}' is ambiguous — candidates:\n{candidates}"
)))
}
}
}

fn confirm(machine: &MachineInfo) -> Result<bool, CliError> {
eprint!(
"Remove machine {} ({})? [y/N] ",
short_id(&machine.id),
machine.name
);
std::io::stderr()
.flush()
.map_err(|error| CliError::Usage(format!("failed to prompt: {error}")))?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|error| CliError::Usage(format!("failed to read confirmation: {error}")))?;
let answer = answer.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}

#[cfg(test)]
mod tests {
use super::resolve_machine;
use tc_protocol::MachineInfo;

fn machine(id: &str, name: &str) -> MachineInfo {
MachineInfo {
id: id.to_string(),
name: name.to_string(),
os: "linux".to_string(),
home_dir: "/tmp".to_string(),
production: false,
}
}

#[test]
fn unique_name_resolves_when_id_does_not_match() {
let machines = vec![
machine("aaaa1111-rest", "nas"),
machine("bbbb2222-rest", "localhost.localdomain"),
];
assert_eq!(
resolve_machine("nas", &machines).unwrap().id,
"aaaa1111-rest"
);
assert_eq!(
resolve_machine("LOCALHOST.LOCALDOMAIN", &machines)
.unwrap()
.id,
"bbbb2222-rest"
);
}

#[test]
fn duplicate_names_require_an_id() {
let machines = vec![
machine("aaaa1111-rest", "localhost.localdomain"),
machine("bbbb2222-rest", "localhost.localdomain"),
];
let error = resolve_machine("localhost.localdomain", &machines).unwrap_err();
let message = error.to_string();
assert!(message.contains("ambiguous"), "{message}");
assert!(message.contains("aaaa1111-rest"), "{message}");
assert_eq!(
resolve_machine("aaaa1111", &machines).unwrap().id,
"aaaa1111-rest"
);
}

#[test]
fn missing_query_is_an_error() {
let machines = vec![machine("aaaa1111-rest", "nas")];
let error = resolve_machine("nope", &machines).unwrap_err();
assert!(error.to_string().contains("no machine matching 'nope'"));
}
}
26 changes: 24 additions & 2 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ struct Cli {

#[derive(Subcommand)]
enum Commands {
/// List online machines
/// List machines, or forget one
Machines {
#[command(subcommand)]
action: Option<MachinesAction>,
/// Include offline registered hosts
#[arg(long)]
all: bool,
/// Machine-readable JSON on stdout
#[arg(long)]
json: bool,
Expand Down Expand Up @@ -170,6 +175,18 @@ enum Commands {
},
}

#[derive(Subcommand)]
enum MachinesAction {
/// Forget a registered machine
Rm {
/// Machine id, unique id prefix, or unique name
machine: String,
/// Do not ask for confirmation
#[arg(long)]
yes: bool,
},
}

#[tokio::main]
async fn main() {
let cli = Cli::parse();
Expand Down Expand Up @@ -203,7 +220,12 @@ async fn run(cli: Cli) -> Result<(), CliError> {
let hub_client = client::HubClient::new(&resolved)?;

match cli.command {
Commands::Machines { json } => commands::machines::run(&hub_client, json).await,
Commands::Machines { action, json, all } => match action {
Some(MachinesAction::Rm { machine, yes }) => {
commands::machines::rm(&hub_client, &machine, yes).await
}
None => commands::machines::run(&hub_client, json, all).await,
},
Commands::Ls { machine, json } => commands::ls::run(&hub_client, machine, json).await,
Commands::Open {
machine,
Expand Down
Loading
Loading