use std::collections::HashMap;
use std::io::Write;
use ff_rdp_core::{
NetworkResource, ProtocolError, RdpTransport, TabActor, WatcherActor,
parse_network_resource_updates, parse_network_resources,
};
use serde_json::{Value, json};
use crate::cli::args::Cli;
use crate::error::AppError;
use crate::hints::{HintContext, HintSource};
use crate::output;
use crate::output_controls::{OutputControls, SortDir};
use crate::output_pipeline::OutputPipeline;
use super::connect_tab::{ConnectedTab, connect_and_get_target};
use super::network_events::{
build_network_entries, drain_network_events, drain_network_from_daemon, merge_updates,
performance_api_fallback,
};
pub fn run(cli: &Cli, filter: Option<&str>, method: Option<&str>) -> Result<(), AppError> {
let mut ctx = connect_and_get_target(cli)?;
let via_daemon = ctx.via_daemon;
let (all_resources, all_updates) = if ctx.via_daemon {
drain_network_from_daemon(ctx.transport_mut())?
} else {
let tab_actor = ctx.target_tab_actor().clone();
let watcher_actor =
TabActor::get_watcher(ctx.transport_mut(), &tab_actor).map_err(AppError::from)?;
WatcherActor::watch_resources(ctx.transport_mut(), &watcher_actor, &["network-event"])
.map_err(AppError::from)?;
let result = drain_network_events(ctx.transport_mut()).map_err(AppError::from)?;
let _ = WatcherActor::unwatch_resources(
ctx.transport_mut(),
&watcher_actor,
&["network-event"],
);
result
};
let update_map = merge_updates(all_updates);
let apply_filters = |entries: Vec<serde_json::Value>| -> Vec<serde_json::Value> {
entries
.into_iter()
.filter(|entry| {
if let Some(f) = filter {
let url = entry["url"].as_str().unwrap_or_default();
if !url.contains(f) {
return false;
}
}
if let Some(m) = method {
let entry_method = entry["method"].as_str().unwrap_or_default();
if !entry_method.eq_ignore_ascii_case(m) {
return false;
}
}
true
})
.collect()
};
let watcher_entries = build_network_entries(&all_resources, &update_map);
let watcher_was_empty = watcher_entries.is_empty();
let filtered_watcher = apply_filters(watcher_entries);
let (results, used_perf_fallback) = if watcher_was_empty {
let fallback = performance_api_fallback(&mut ctx);
let filtered_fallback = apply_filters(fallback);
let used = !filtered_fallback.is_empty();
(filtered_fallback, used)
} else {
(filtered_watcher, false)
};
if results.is_empty() && watcher_was_empty {
eprintln!(
"hint: no network events captured. \
Navigate first or use `--follow` to stream events in real time."
);
}
let meta = if used_perf_fallback {
json!({"host": cli.host, "port": cli.port, "source": "performance-api"})
} else {
json!({"host": cli.host, "port": cli.port})
};
let use_detail = cli.detail
|| cli.jq.is_some()
|| cli.sort.is_some()
|| cli.limit.is_some()
|| cli.all
|| cli.fields.is_some();
let empty_hint = if results.is_empty() && watcher_was_empty {
let hint = if via_daemon {
"No network events captured. Events are buffered by the daemon; navigate first with: ff-rdp navigate <url> --with-network, or use --follow to stream events in real time."
} else {
"No network events captured. Connect before the page loads, use ff-rdp navigate <url> --with-network, or use --follow to stream events in real time."
};
Some(json!(hint))
} else if results.is_empty() && (filter.is_some() || method.is_some()) {
Some(json!(
"No requests matched the current --filter/--method. Remove the filter to see all captured events."
))
} else {
None
};
if use_detail {
let controls = OutputControls::from_cli(cli, SortDir::Desc);
let mut detail = results;
if cli.sort.is_none() {
let dir = controls.sort_dir;
detail.sort_by(|a, b| {
let da = a["duration_ms"].as_f64().unwrap_or(0.0);
let db = b["duration_ms"].as_f64().unwrap_or(0.0);
let cmp = da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal);
match dir {
SortDir::Asc => cmp,
SortDir::Desc => cmp.reverse(),
}
});
} else {
controls.apply_sort(&mut detail);
}
let (limited, total, truncated) = controls.apply_limit(detail, Some(20));
let shown = limited.len();
let limited = controls.apply_fields(limited);
let mut envelope =
output::envelope_with_truncation(&json!(limited), shown, total, truncated, &meta);
if let Some(hint) = empty_hint
&& let Some(obj) = envelope.as_object_mut()
{
obj.insert("hint".to_string(), hint);
}
let hint_ctx = HintContext::new(HintSource::Network).with_detail(cli.detail);
return OutputPipeline::from_cli(cli)?
.finalize_with_hints(&envelope, Some(&hint_ctx))
.map_err(AppError::from);
}
let summary = build_network_summary(&results, false);
if cli.format == "text" && cli.jq.is_none() {
render_network_summary_text(&summary);
return Ok(());
}
let mut envelope = output::envelope(&summary, results.len(), &meta);
if let Some(hint) = empty_hint
&& let Some(obj) = envelope.as_object_mut()
{
obj.insert("hint".to_string(), hint);
}
let hint_ctx = HintContext::new(HintSource::Network).with_detail(cli.detail);
OutputPipeline::from_cli(cli)?
.finalize_with_hints(&envelope, Some(&hint_ctx))
.map_err(AppError::from)
}
fn render_network_summary_text(summary: &Value) {
let total_requests = summary
.get("total_requests")
.and_then(Value::as_u64)
.unwrap_or(0);
let total_bytes = summary
.get("total_transfer_bytes")
.and_then(Value::as_f64)
.unwrap_or(0.0);
println!("=== Network Summary ===");
println!(" Total requests: {total_requests}");
println!(" Total transferred: {total_bytes:.0} bytes");
if let Some(by_cause) = summary.get("by_cause_type").and_then(Value::as_object)
&& !by_cause.is_empty()
{
println!();
println!("=== Requests by Cause Type ===");
let max_len = by_cause.keys().map(String::len).max().unwrap_or(4);
for (cause, count) in by_cause {
let n = count.as_u64().unwrap_or(0);
println!(" {cause:<max_len$} {n:>4}");
}
}
if let Some(slowest) = summary.get("slowest").and_then(Value::as_array)
&& !slowest.is_empty()
{
println!();
println!("=== Slowest Requests ===");
for (i, entry) in slowest.iter().enumerate() {
let url = entry.get("url").and_then(Value::as_str).unwrap_or("?");
let dur = entry
.get("duration_ms")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let status = entry.get("status").and_then(Value::as_u64).unwrap_or(0);
let size = entry
.get("transfer_size")
.and_then(Value::as_f64)
.unwrap_or(0.0);
println!(" {}. {url} ({dur:.0}ms, {status}, {size:.0}b)", i + 1);
}
}
if summary
.get("timeout_reached")
.and_then(Value::as_bool)
.unwrap_or(false)
&& let Some(hint) = summary.get("hint").and_then(Value::as_str)
{
println!();
println!("{hint}");
}
}
pub fn build_network_summary(
entries: &[serde_json::Value],
timeout_reached: bool,
) -> serde_json::Value {
let total_requests = entries.len();
let total_transfer_bytes: f64 = entries
.iter()
.filter_map(|e| e["transfer_size"].as_f64())
.sum();
let total_transfer_bytes = if total_transfer_bytes == 0.0 {
0.0_f64
} else {
total_transfer_bytes
};
let mut by_cause_type: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for entry in entries {
let cause = entry["cause_type"].as_str().unwrap_or("other").to_string();
*by_cause_type.entry(cause).or_insert(0) += 1;
}
let mut sorted_by_duration: Vec<&serde_json::Value> = entries.iter().collect();
sorted_by_duration.sort_by(|a, b| {
let da = a["duration_ms"].as_f64().unwrap_or(0.0);
let db = b["duration_ms"].as_f64().unwrap_or(0.0);
db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
});
let slowest: Vec<serde_json::Value> = sorted_by_duration
.iter()
.take(20)
.map(|e| {
json!({
"url": e["url"],
"duration_ms": e["duration_ms"],
"status": e["status"],
"transfer_size": e["transfer_size"],
})
})
.collect();
let mut summary = json!({
"total_requests": total_requests,
"total_transfer_bytes": total_transfer_bytes,
"by_cause_type": by_cause_type,
"slowest": slowest,
"timeout_reached": timeout_reached,
});
if timeout_reached {
summary["hint"] = json!(
"Network collection was still receiving events when the timeout was reached. \
Consider increasing --network-timeout for more complete results."
);
}
summary
}
pub fn run_follow(cli: &Cli, filter: Option<&str>, method: Option<&str>) -> Result<(), AppError> {
let mut ctx = connect_and_get_target(cli)?;
if ctx.via_daemon {
run_follow_daemon(&mut ctx, filter, method, cli.jq.as_deref())
} else {
run_follow_direct(&mut ctx, filter, method, cli.jq.as_deref())
}
}
fn run_follow_direct(
ctx: &mut ConnectedTab,
filter: Option<&str>,
method: Option<&str>,
jq_filter: Option<&str>,
) -> Result<(), AppError> {
let tab_actor = ctx.target_tab_actor().clone();
let watcher_actor =
TabActor::get_watcher(ctx.transport_mut(), &tab_actor).map_err(AppError::from)?;
WatcherActor::watch_resources(ctx.transport_mut(), &watcher_actor, &["network-event"])
.map_err(AppError::from)?;
let result = network_follow_loop(ctx.transport_mut(), filter, method, jq_filter);
let _ =
WatcherActor::unwatch_resources(ctx.transport_mut(), &watcher_actor, &["network-event"]);
result
}
fn run_follow_daemon(
ctx: &mut ConnectedTab,
filter: Option<&str>,
method: Option<&str>,
jq_filter: Option<&str>,
) -> Result<(), AppError> {
use crate::daemon::client::{start_daemon_stream, stop_daemon_stream};
start_daemon_stream(ctx.transport_mut(), "network-event").map_err(AppError::from)?;
let result = network_follow_loop(ctx.transport_mut(), filter, method, jq_filter);
let _ = stop_daemon_stream(ctx.transport_mut(), "network-event");
result
}
fn emit_ndjson(entry: &Value, jq_filter: Option<&str>) -> Result<(), AppError> {
if let Some(filter) = jq_filter {
let values = output::apply_jq_filter(entry, filter).map_err(AppError::from)?;
for v in values {
println!(
"{}",
serde_json::to_string(&v).map_err(|e| AppError::Internal(e.into()))?
);
}
} else {
println!(
"{}",
serde_json::to_string(entry).map_err(|e| AppError::Internal(e.into()))?
);
}
Ok(())
}
fn network_follow_loop(
transport: &mut RdpTransport,
filter: Option<&str>,
method: Option<&str>,
jq_filter: Option<&str>,
) -> Result<(), AppError> {
let mut pending: HashMap<u64, NetworkResource> = HashMap::new();
loop {
match transport.recv() {
Ok(msg) => {
let msg_type = msg.get("type").and_then(Value::as_str).unwrap_or_default();
match msg_type {
"resources-available-array" => {
let resources = parse_network_resources(&msg);
for res in resources {
if let Some(f) = filter
&& !res.url.contains(f)
{
continue;
}
if let Some(m) = method
&& !res.method.eq_ignore_ascii_case(m)
{
continue;
}
let entry = json!({
"event": "request",
"method": res.method,
"url": res.url,
"is_xhr": res.is_xhr,
"cause_type": res.cause_type,
"resource_id": res.resource_id,
});
emit_ndjson(&entry, jq_filter)?;
let _ = std::io::stdout().flush();
pending.insert(res.resource_id, res);
}
}
"resources-updated-array" => {
let updates = parse_network_resource_updates(&msg);
for update in updates {
let Some(res) = pending.remove(&update.resource_id) else {
continue;
};
let mut entry = json!({
"event": "response",
"method": res.method,
"url": res.url,
"is_xhr": res.is_xhr,
"cause_type": res.cause_type,
"resource_id": update.resource_id,
});
if let Some(ref status) = update.status {
if let Ok(code) = status.parse::<u16>() {
entry["status"] = json!(code);
} else {
entry["status"] = json!(status);
}
}
if let Some(ref mime) = update.mime_type {
entry["content_type"] = json!(mime);
}
if let Some(total) = update.total_time {
entry["duration_ms"] = json!(total);
}
if let Some(size) = update.content_size {
entry["size_bytes"] = json!(size);
}
if let Some(transferred) = update.transferred_size {
entry["transfer_size"] = json!(transferred);
}
emit_ndjson(&entry, jq_filter)?;
let _ = std::io::stdout().flush();
}
}
_ => {}
}
}
Err(ProtocolError::Timeout) => {
}
Err(ProtocolError::RecvFailed(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof
|| e.kind() == std::io::ErrorKind::ConnectionReset
|| e.kind() == std::io::ErrorKind::BrokenPipe =>
{
return Ok(());
}
Err(e) => return Err(AppError::from(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn render_network_summary_text_does_not_panic_empty() {
render_network_summary_text(&json!({
"total_requests": 0,
"total_transfer_bytes": 0.0,
"by_cause_type": {},
"slowest": [],
"timeout_reached": false,
}));
}
#[test]
fn render_network_summary_text_does_not_panic_full() {
let data = json!({
"total_requests": 3,
"total_transfer_bytes": 1600.0,
"by_cause_type": {"script": 2, "img": 1},
"slowest": [
{"url": "https://example.com/big.js", "duration_ms": 200.0, "status": 200, "transfer_size": 1000.0},
],
"timeout_reached": false,
});
render_network_summary_text(&data);
}
#[test]
fn build_network_summary_empty() {
let s = build_network_summary(&[], false);
assert_eq!(s["total_requests"], 0);
assert_eq!(s["total_transfer_bytes"], 0.0);
assert!(s["slowest"].as_array().unwrap().is_empty());
assert_eq!(s["timeout_reached"], false);
assert!(s.get("hint").is_none());
}
#[test]
fn build_network_summary_total_transfer_bytes_not_negative_zero() {
let s = build_network_summary(&[], false);
let v = s["total_transfer_bytes"]
.as_f64()
.expect("total_transfer_bytes is f64");
assert!(v == 0.0, "expected 0.0, got {v}");
assert!(
!v.is_sign_negative(),
"total_transfer_bytes should be positive zero, not negative zero"
);
let json_str = serde_json::to_string(&s["total_transfer_bytes"]).unwrap();
assert!(
!json_str.starts_with('-'),
"serialised total_transfer_bytes should not start with '-', got {json_str:?}"
);
}
#[test]
fn build_network_summary_null_transfer_sizes_give_zero_not_negative_zero() {
let entries = vec![
json!({"url": "a", "duration_ms": 10.0, "status": 200, "cause_type": "doc"}),
json!({"url": "b", "duration_ms": 20.0, "status": 200, "cause_type": "doc"}),
];
let s = build_network_summary(&entries, false);
let v = s["total_transfer_bytes"]
.as_f64()
.expect("total_transfer_bytes is f64");
assert!(v == 0.0, "expected 0.0, got {v}");
assert!(!v.is_sign_negative(), "should be +0.0, not -0.0");
}
#[test]
fn build_network_summary_counts_and_bytes() {
let entries = vec![
json!({"url": "a", "duration_ms": 100.0, "status": 200, "transfer_size": 500.0, "cause_type": "script"}),
json!({"url": "b", "duration_ms": 50.0, "status": 404, "transfer_size": 100.0, "cause_type": "script"}),
json!({"url": "c", "duration_ms": 200.0, "status": 200, "transfer_size": 1000.0, "cause_type": "img"}),
];
let s = build_network_summary(&entries, false);
assert_eq!(s["total_requests"], 3);
assert_eq!(s["total_transfer_bytes"], 1600.0);
assert_eq!(s["by_cause_type"]["script"], 2);
assert_eq!(s["by_cause_type"]["img"], 1);
let slowest = s["slowest"].as_array().unwrap();
assert_eq!(slowest[0]["url"], "c");
assert_eq!(slowest[1]["url"], "a");
assert_eq!(slowest[2]["url"], "b");
assert_eq!(s["timeout_reached"], false);
assert!(s.get("hint").is_none());
}
#[test]
fn build_network_summary_timeout_reached_adds_hint() {
let entries =
vec![json!({"url": "a", "duration_ms": 10.0, "status": 200, "cause_type": "doc"})];
let s = build_network_summary(&entries, true);
assert_eq!(s["timeout_reached"], true);
let hint = s["hint"]
.as_str()
.expect("hint should be a string when timeout_reached");
assert!(
hint.contains("--network-timeout"),
"hint should mention --network-timeout"
);
}
#[test]
fn build_network_summary_no_timeout_no_hint() {
let entries =
vec![json!({"url": "a", "duration_ms": 10.0, "status": 200, "cause_type": "doc"})];
let s = build_network_summary(&entries, false);
assert_eq!(s["timeout_reached"], false);
assert!(
s.get("hint").is_none(),
"hint should not be present when timeout_reached is false"
);
}
}