cli refactor and more tracing

This commit is contained in:
lda
2026-04-06 17:58:47 +07:00 Unverified
parent c393111cb0
commit 93a88741f9
7 changed files with 507 additions and 501 deletions
+365
View File
@@ -0,0 +1,365 @@
//! CLI argument parsing, dispatch, rendering, and tracing defaults.
pub mod table;
use std::net::{IpAddr, SocketAddr};
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
pub struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
pub host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
pub port: u16,
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
pub include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
pub struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
pub query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
pub struct StatusArgs {
/// Free-form device query.
pub query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
pub name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
pub ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
pub devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
pub nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
pub struct DevsArgs {
/// Optional interface name to show.
pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
pub up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
}
}
pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
debug!(?query, json = as_json, "dispatching status command");
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", table::render_status_table(&status));
}
}
Command::Leases(args) => {
debug!(
include_state = args.include_state,
json = args.json,
"dispatching leases command"
);
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
debug!(
has_query = args.query.is_some(),
has_mac = args.mac.is_some(),
has_ip = args.ip.is_some(),
json = as_json,
"dispatching wake command"
);
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", table::render_wake_table(&result));
}
}
Command::Devs(args) => {
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", table::render_devs_table(&devs));
}
}
}
Ok(())
}
fn status_args_to_query(args: StatusArgs) -> DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
#[cfg(test)]
mod tests {
use super::{WakeArgs, default_filter_for_verbosity};
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info,tower_http=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug,tower_http=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace,tower_http=trace");
}
}
+112
View File
@@ -0,0 +1,112 @@
use chrono::{DateTime, Local};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, Status, WakeResult};
pub fn render_status_table(status: &Status<wakey_core::NeighborEntry>) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Dev", "State"]);
for row in &status.table {
table.add_row(vec![
Cell::new(row.ip.to_string()),
Cell::new(row.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.dev.clone().unwrap_or_default()),
Cell::new(row.state.to_string()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(vec!["Expires", "IP", "MAC", "Name", "State"]);
for lease in leases {
table.add_row(vec![
Cell::new(format_epoch(lease.lease_line.expires_epoch)),
Cell::new(lease.lease_line.ip.to_string()),
Cell::new(lease.lease_line.mac.to_string()),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(
lease
.nud_state
.map(|v| v.to_string())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row(vec![
Cell::new(row.target.ip.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status)),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header(vec!["Ifname", "State", "MAC", "Family", "CIDR", "Broadcast", "Scope/Label"]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row(vec![
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let lead = idx == 0;
table.add_row(vec![
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|v| v.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} / {label}"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
}),
]);
}
}
table
}
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
fn format_epoch(epoch: u64) -> String {
DateTime::from_timestamp(epoch as i64, 0)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| epoch.to_string())
}
-123
View File
@@ -1,123 +0,0 @@
use chrono::{DateTime, Local, Utc};
use comfy_table::{Cell, ContentArrangement, Table, presets};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, WakeResult};
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(presets::UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
pub fn render_status_table(status: &wakey::StatusResponse) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "State", "IF"]);
for row in &status.table {
table.add_row([
Cell::new(row.ip),
Cell::new(row.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.state).to_lowercase()),
Cell::new(row.dev.clone().unwrap_or_default()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Name", "Expires", "NUD"]);
for lease in leases {
let expires = format_epoch_local(lease.lease_line.expires_epoch);
table.add_row([
Cell::new(lease.lease_line.ip),
Cell::new(lease.lease_line.mac),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(expires),
Cell::new(
lease
.nud_state
.map(|s| format!("{:?}", s).to_lowercase())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row([
Cell::new(row.target.ip.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status).to_lowercase()),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header([
"Interface",
"State",
"MAC",
"Family",
"Address",
"Broadcast",
"Scope/Label",
]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row([
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let scope_label = match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} ({label})"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
};
let lead = idx == 0;
table.add_row([
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|m| m.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(scope_label),
]);
}
}
table
}
fn format_epoch_local(epoch: u64) -> String {
match DateTime::<Utc>::from_timestamp(epoch as i64, 0) {
Some(dt) => dt
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string(),
None => epoch.to_string(),
}
}
+3 -372
View File
@@ -1,198 +1,6 @@
mod cli_table;
mod cli;
use std::net::{IpAddr, SocketAddr};
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
port: u16,
}
#[derive(Args)]
struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
struct StatusArgs {
/// Free-form device query.
query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
struct DevsArgs {
/// Optional interface name to show.
dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
fn status_args_to_query(args: StatusArgs) -> wakey_core::DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> anyhow::Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> anyhow::Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
use clap::Parser;
#[cfg(not(target_os = "linux"))]
fn main() -> anyhow::Result<()> {
@@ -204,182 +12,5 @@ fn main() -> anyhow::Result<()> {
#[cfg(target_os = "linux")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
debug!(?query, json = as_json, "dispatching status command");
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", cli_table::render_status_table(&status));
}
}
Command::Leases(args) => {
debug!(
include_state = args.include_state,
json = args.json,
"dispatching leases command"
);
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", cli_table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
debug!(
has_query = args.query.is_some(),
has_mac = args.mac.is_some(),
has_ip = args.ip.is_some(),
json = as_json,
"dispatching wake command"
);
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", cli_table::render_wake_table(&result));
}
}
Command::Devs(args) => {
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", cli_table::render_devs_table(&devs));
}
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn init_tracing(verbose: u8) {
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
#[cfg(target_os = "linux")]
fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
}
}
#[cfg(test)]
mod tests {
use super::{WakeArgs, default_filter_for_verbosity};
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(
default_filter_for_verbosity(0),
"wakey=info,tower_http=info"
);
assert_eq!(
default_filter_for_verbosity(1),
"wakey=debug,tower_http=debug"
);
assert_eq!(
default_filter_for_verbosity(2),
"wakey=trace,tower_http=trace"
);
assert_eq!(
default_filter_for_verbosity(9),
"wakey=trace,tower_http=trace"
);
}
cli::run(cli::Cli::parse()).await
}
+10 -3
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::InterfaceSummary;
/// Return interface names only.
@@ -9,16 +10,22 @@ pub async fn list_interfaces() -> Result<Vec<String>> {
}
/// Return condensed interface summaries useful for CLI and wake routing.
#[instrument(skip_all)]
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
wakey_linux::devices::list_interface_summaries().await
let summaries = wakey_linux::devices::list_interface_summaries().await?;
debug!(count = summaries.len(), "loaded interface summaries");
Ok(summaries)
}
/// Return one named interface summary when present.
#[instrument(skip_all, fields(ifname = name))]
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
Ok(get_interface_summaries()
let summary = get_interface_summaries()
.await?
.into_iter()
.find(|iface| iface.ifname == name))
.find(|iface| iface.ifname == name);
debug!(found = summary.is_some(), "resolved interface summary");
Ok(summary)
}
/// Resolve a hostname through the local resolver and collect all returned IPs.
+5 -1
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
use crate::service::inventory::inventory;
@@ -11,13 +12,15 @@ pub type StatusResponse = Status<NeighborEntry>;
///
/// This keeps the old status response shape alive while the underlying model is
/// increasingly device-centered.
#[instrument(skip_all, fields(name = ?query.name))]
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?;
let table = inventory
let table: Vec<NeighborEntry> = inventory
.devices
.iter()
.flat_map(device_to_status_rows)
.collect();
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response");
Ok(Status {
name: query.name,
table,
@@ -26,6 +29,7 @@ pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
}
/// Convenience wrapper around [`get_status`] for free-form user input.
#[instrument(skip_all)]
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
+12 -2
View File
@@ -1,20 +1,24 @@
use anyhow::{Context, Result};
use macaddr::MacAddr;
use std::net::IpAddr;
use tracing::{debug, instrument};
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices;
/// Send Wake-on-LAN packets for already-concrete wake targets.
#[instrument(skip_all, fields(targets = targets.len()))]
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets)
.await
.context("failed to send wake packets")?;
debug!(results = result.len(), "wake packets sent");
Ok(WakeResult { result })
}
/// Resolve free-form input into wake targets and send the packets.
#[instrument(skip_all)]
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
@@ -23,12 +27,14 @@ pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
/// Build broadcast wake targets for every broadcast-capable interface.
///
/// This is used by explicit manual wake mode when only a MAC address is supplied.
#[instrument(skip_all)]
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
let interfaces = get_interface_summaries().await?;
broadcast_wake_targets_from_interfaces(&interfaces, mac)
}
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
#[instrument(skip_all, fields(has_ip = ip.is_some()))]
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
let targets = match ip {
Some(ip) => explicit_wake_targets_for_ip(mac, ip),
@@ -41,9 +47,10 @@ pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResul
///
/// The current resolution strategy fans out one wake target per resolved device IP,
/// using the first known MAC address for that device.
#[instrument(skip_all)]
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?;
Ok(devices
let targets: Vec<WakeTarget> = devices
.into_iter()
.flat_map(|device| {
let mac = device.macs.first().copied();
@@ -52,7 +59,9 @@ pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTa
.into_iter()
.map(move |ip| WakeTarget { ip: Some(ip), mac })
})
.collect())
.collect();
debug!(targets = targets.len(), "resolved wake targets");
Ok(targets)
}
fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> {
@@ -80,6 +89,7 @@ fn broadcast_wake_targets_from_interfaces(
anyhow::bail!("no broadcast-capable interfaces found");
}
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets");
Ok(targets)
}