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
+108
View File
@@ -367,6 +367,44 @@ impl ClientDiagnostics {
pub const fn stats(&self) -> &TunnelStats {
&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)]
@@ -505,4 +543,74 @@ mod tests {
assert_eq!(stats.broadcast_frames_tx(), 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())
);
}
}