feat(client): print user-facing diagnostics

The PLAN calls out client diagnostics that a user can read directly, not only
machine-shaped counters. The Windows client already built ClientDiagnostics
snapshots, but it printed a dense status line and left the UserDiagnostic model
unused.

Derive user-facing diagnostics from ClientDiagnostics in lanparty-obs so a
future GUI and the current CLI can share the same status vocabulary. The
messages report the states the runtime actually observes: relay reachability,
LAN gateway presence, TAP IP presence, and observed broadcast traffic. TAP IPs
are only described as DHCP when they are non-link-local IPv4 addresses, because
link-local IPv4 and IPv6 addresses do not prove DHCP success.

The client now prints those user-facing lines after the existing detailed
counter line. Gateway latency is intentionally not reported here; the current
protocol does not measure gateway RTT.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-obs -p lanparty-client-win
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check
- git diff --cached --check

Refs: PLAN.md Logging / diagnostics
This commit is contained in:
2026-05-21 22:22:43 +02:00
parent 881dee5491
commit 733badd2a8
3 changed files with 154 additions and 5 deletions
+7 -4
View File
@@ -216,7 +216,10 @@ It prints and reports client diagnostics snapshots with relay reachability,
LAN-gateway presence, route-pinning, QUIC datagram budget, TAP status/IP, LAN-gateway presence, route-pinning, QUIC datagram budget, TAP status/IP,
broadcast frame flow, frame/datagram counters, and drops. The periodic broadcast frame flow, frame/datagram counters, and drops. The periodic
diagnostics refresh the TAP unicast IP so DHCP results that arrive after diagnostics refresh the TAP unicast IP so DHCP results that arrive after
bridging starts become visible in later status lines. Relay lifecycle events bridging starts become visible in later status lines. Each snapshot also emits
are logged as they arrive, including gateway joins and peer leaves. The client short user-facing lines such as relay/gateway connection status, DHCP address
remembers peer identities from join and catch-up events so later leave logs can presence, and broadcast-flow confirmation when those signals are observed.
identify a disconnected LAN gateway or client MAC when that peer was known. Relay lifecycle events are logged as they arrive, including gateway joins and
peer leaves. The client remembers peer identities from join and catch-up events
so later leave logs can identify a disconnected LAN gateway or client MAC when
that peer was known.
+39 -1
View File
@@ -22,7 +22,9 @@ use lanparty_client_route::{
use lanparty_client_tap::TapAdapter; use lanparty_client_tap::TapAdapter;
use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode}; use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode};
use lanparty_net::RelayEndpoint; use lanparty_net::RelayEndpoint;
use lanparty_obs::{ClientDiagnostics, RelayDiagnostics, TapDiagnostics}; use lanparty_obs::{
ClientDiagnostics, RelayDiagnostics, TapDiagnostics, UserDiagnostic, UserDiagnosticLevel,
};
use lanparty_proto::MacAddr; use lanparty_proto::MacAddr;
#[cfg(windows)] #[cfg(windows)]
@@ -438,6 +440,9 @@ fn tap_diagnostics_with_ip(base: &TapDiagnostics, ip: Option<IpAddr>) -> TapDiag
fn print_client_diagnostics(diagnostics: &ClientDiagnostics) { fn print_client_diagnostics(diagnostics: &ClientDiagnostics) {
println!("{}", format_client_diagnostics(diagnostics)); println!("{}", format_client_diagnostics(diagnostics));
for diagnostic in diagnostics.user_diagnostics() {
println!("{}", format_user_diagnostic(&diagnostic));
}
} }
async fn print_and_report_client_diagnostics( async fn print_and_report_client_diagnostics(
@@ -482,6 +487,14 @@ fn optional_label<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string()) value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
} }
fn format_user_diagnostic(diagnostic: &UserDiagnostic) -> String {
match diagnostic.level() {
UserDiagnosticLevel::Info => diagnostic.message().to_owned(),
UserDiagnosticLevel::Warning => format!("Warning: {}", diagnostic.message()),
UserDiagnosticLevel::Error => format!("Error: {}", diagnostic.message()),
}
}
async fn run_control_event_log( async fn run_control_event_log(
session: &ClientSession, session: &ClientSession,
relay_status: ClientRelayStatus, relay_status: ClientRelayStatus,
@@ -887,6 +900,31 @@ mod tests {
); );
} }
#[test]
fn formats_user_diagnostic_levels() {
assert_eq!(
format_user_diagnostic(&UserDiagnostic::new(
UserDiagnosticLevel::Info,
"Connected to relay"
)),
"Connected to relay"
);
assert_eq!(
format_user_diagnostic(&UserDiagnostic::new(
UserDiagnosticLevel::Warning,
"Waiting for LAN gateway"
)),
"Warning: Waiting for LAN gateway"
);
assert_eq!(
format_user_diagnostic(&UserDiagnostic::new(
UserDiagnosticLevel::Error,
"Relay not reachable"
)),
"Error: Relay not reachable"
);
}
#[test] #[test]
fn refreshes_tap_diagnostics_ip_without_losing_static_fields() { fn refreshes_tap_diagnostics_ip_without_losing_static_fields() {
let base = TapDiagnostics::new( let base = TapDiagnostics::new(
+108
View File
@@ -367,6 +367,44 @@ impl ClientDiagnostics {
pub const fn stats(&self) -> &TunnelStats { pub const fn stats(&self) -> &TunnelStats {
&self.stats &self.stats
} }
#[must_use]
pub fn user_diagnostics(&self) -> Vec<UserDiagnostic> {
let mut diagnostics = Vec::new();
diagnostics.push(if self.relay.reachable() {
UserDiagnostic::new(UserDiagnosticLevel::Info, "Connected to relay")
} else {
UserDiagnostic::new(UserDiagnosticLevel::Error, "Relay not reachable")
});
diagnostics.push(if self.relay.gateway_connected() {
UserDiagnostic::new(UserDiagnosticLevel::Info, "Connected to LAN gateway")
} else {
UserDiagnostic::new(UserDiagnosticLevel::Warning, "Waiting for LAN gateway")
});
if let Some(message) = tap_ip_user_message(self.tap.ip()) {
diagnostics.push(UserDiagnostic::new(UserDiagnosticLevel::Info, message));
}
if self.stats.broadcast_frames_tx() > 0 || self.stats.broadcast_frames_rx() > 0 {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
"Broadcast traffic flowing",
));
}
diagnostics
}
}
fn tap_ip_user_message(ip: Option<IpAddr>) -> Option<String> {
match ip {
Some(IpAddr::V4(ip)) if !ip.is_link_local() => Some(format!("DHCP received: {ip}")),
Some(ip) => Some(format!("TAP IP detected: {ip}")),
None => None,
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
@@ -505,4 +543,74 @@ mod tests {
assert_eq!(stats.broadcast_frames_tx(), 0); assert_eq!(stats.broadcast_frames_tx(), 0);
assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.broadcast_frames_rx(), 0);
} }
#[test]
fn derives_user_diagnostics_from_client_snapshot() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
Some(1200),
Some("10.73.42.51".parse().unwrap()),
),
TunnelStats::new(1, 2, 3, 4, 5, 6).with_broadcast_frames(7, 8),
);
let user_diagnostics = diagnostics.user_diagnostics();
let messages: Vec<_> = user_diagnostics
.iter()
.map(UserDiagnostic::message)
.collect();
assert_eq!(
messages,
[
"Connected to relay",
"Connected to LAN gateway",
"DHCP received: 10.73.42.51",
"Broadcast traffic flowing",
]
);
assert!(
user_diagnostics
.iter()
.all(|diagnostic| diagnostic.level() == UserDiagnosticLevel::Info)
);
}
#[test]
fn reports_user_diagnostic_warnings_for_missing_connections() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(false, false, false),
QuicDiagnostics::new(false, None),
TapDiagnostics::new(false, None, None, None),
TunnelStats::default(),
);
let user_diagnostics = diagnostics.user_diagnostics();
assert_eq!(
user_diagnostics
.iter()
.map(UserDiagnostic::message)
.collect::<Vec<_>>(),
["Relay not reachable", "Waiting for LAN gateway"]
);
assert_eq!(user_diagnostics[0].level(), UserDiagnosticLevel::Error);
assert_eq!(user_diagnostics[1].level(), UserDiagnosticLevel::Warning);
}
#[test]
fn avoids_calling_link_local_tap_ip_dhcp() {
assert_eq!(
tap_ip_user_message(Some("169.254.10.20".parse().unwrap())),
Some("TAP IP detected: 169.254.10.20".to_string())
);
assert_eq!(
tap_ip_user_message(Some("fe80::1".parse().unwrap())),
Some("TAP IP detected: fe80::1".to_string())
);
}
} }