use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::fmt::Write as _; use super::state::{InputRouting, LauncherState, ViewMode}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PerformanceSample { pub rtt_ms: f32, pub jitter_ms: f32, pub input_latency_ms: f32, pub probe_loss_pct: f32, pub video_loss_pct: f32, pub left_receive_fps: f32, pub left_present_fps: f32, pub left_server_fps: f32, pub right_receive_fps: f32, pub right_present_fps: f32, pub right_server_fps: f32, pub dropped_frames: u64, pub queue_depth: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiagnosticsLog { capacity: usize, history: VecDeque, } impl DiagnosticsLog { pub fn new(capacity: usize) -> Self { let capacity = capacity.max(1); Self { capacity, history: VecDeque::with_capacity(capacity), } } pub fn record(&mut self, sample: PerformanceSample) { if self.history.len() == self.capacity { let _ = self.history.pop_front(); } self.history.push_back(sample); } pub fn latest(&self) -> Option<&PerformanceSample> { self.history.back() } pub fn len(&self) -> usize { self.history.len() } pub fn is_empty(&self) -> bool { self.history.is_empty() } pub fn iter(&self) -> impl Iterator { self.history.iter() } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotReport { pub client_version: String, pub server_version: Option, pub server_available: bool, pub routing: InputRouting, pub view_mode: ViewMode, pub remote_active: bool, pub power_state: String, pub preview_source: String, pub client_display_limit: String, pub left_surface: String, pub left_capture_profile: String, pub left_capture_transport: String, pub left_breakout_profile: String, pub right_surface: String, pub right_capture_profile: String, pub right_capture_transport: String, pub right_breakout_profile: String, pub selected_camera: Option, pub selected_microphone: Option, pub selected_speaker: Option, pub selected_keyboard: Option, pub selected_mouse: Option, pub status: String, pub recent_samples: Vec, pub notes: Vec, pub recommendations: Vec, pub probe_command: String, } impl SnapshotReport { pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self { let left_capture = state.capture_size_choice(0); let right_capture = state.capture_size_choice(1); let left_breakout = state.breakout_size_choice(0); let right_breakout = state.breakout_size_choice(1); Self { client_version: crate::VERSION.to_string(), server_version: state.server_version.clone(), server_available: state.server_available, routing: state.routing, view_mode: state.view_mode, remote_active: state.remote_active, power_state: format!( "{} | {} | leases {}", state.capture_power.mode, state.capture_power.detail, state.capture_power.active_leases ), preview_source: format!( "{}x{} @ {} fps", state.preview_source.width, state.preview_source.height, state.preview_source.fps ), client_display_limit: format!( "{}x{}", state.breakout_display.width, state.breakout_display.height ), left_surface: state.display_surface(0).label().to_string(), left_capture_profile: format!( "{} | {}x{} | {} fps | {} kbit", left_capture.preset.label(), left_capture.width, left_capture.height, left_capture.fps, left_capture.max_bitrate_kbit ), left_capture_transport: left_capture.preset.transport_label().to_string(), left_breakout_profile: format!( "{} | {}x{}", left_breakout.preset.label(), left_breakout.width, left_breakout.height ), right_surface: state.display_surface(1).label().to_string(), right_capture_profile: format!( "{} | {}x{} | {} fps | {} kbit", right_capture.preset.label(), right_capture.width, right_capture.height, right_capture.fps, right_capture.max_bitrate_kbit ), right_capture_transport: right_capture.preset.transport_label().to_string(), right_breakout_profile: format!( "{} | {}x{}", right_breakout.preset.label(), right_breakout.width, right_breakout.height ), selected_camera: state.devices.camera.clone(), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), selected_keyboard: state.devices.keyboard.clone(), selected_mouse: state.devices.mouse.clone(), status: state.status_line(), recent_samples: log.iter().cloned().collect(), notes: state.notes.clone(), recommendations: recommendations_for(state, log), probe_command, } } pub fn to_pretty_json(&self) -> Result { serde_json::to_string_pretty(self) } pub fn to_pretty_text(&self) -> String { let mut text = String::new(); let server_version = self.server_version.as_deref().unwrap_or("unknown"); let server_state = if self.server_available { "reachable" } else { "unreachable" }; let _ = writeln!(text, "Lesavka Diagnostics"); let _ = writeln!(text, "client: v{}", self.client_version); let _ = writeln!(text, "server: {server_version} ({server_state})"); let _ = writeln!( text, "session: routing={:?} view={:?} relay_active={} power={}", self.routing, self.view_mode, self.remote_active, self.power_state ); let _ = writeln!(text, "source feed: {}", self.preview_source); let _ = writeln!(text, "client display limit: {}", self.client_display_limit); let _ = writeln!(text); let _ = writeln!(text, "left eye"); let _ = writeln!(text, " surface: {}", self.left_surface); let _ = writeln!(text, " capture: {}", self.left_capture_profile); let _ = writeln!(text, " transport: {}", self.left_capture_transport); let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); let _ = writeln!(text, "right eye"); let _ = writeln!(text, " surface: {}", self.right_surface); let _ = writeln!(text, " capture: {}", self.right_capture_profile); let _ = writeln!(text, " transport: {}", self.right_capture_transport); let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); let _ = writeln!(text); let _ = writeln!(text, "device staging"); let _ = writeln!( text, " camera: {}", self.selected_camera.as_deref().unwrap_or("auto") ); let _ = writeln!( text, " microphone: {}", self.selected_microphone.as_deref().unwrap_or("auto") ); let _ = writeln!( text, " speaker: {}", self.selected_speaker.as_deref().unwrap_or("auto") ); let _ = writeln!( text, " keyboard: {}", self.selected_keyboard.as_deref().unwrap_or("all") ); let _ = writeln!( text, " mouse: {}", self.selected_mouse.as_deref().unwrap_or("all") ); let _ = writeln!(text); let _ = writeln!(text, "launcher status"); let _ = writeln!(text, " {}", self.status); let _ = writeln!(text); let _ = writeln!(text, "recent samples"); if self.recent_samples.is_empty() { let _ = writeln!( text, " no live RTT/jitter/loss samples yet; this report is currently a launcher state snapshot." ); } else { for sample in &self.recent_samples { let _ = writeln!( text, " rtt={:.1}ms jitter={:.1}ms input-floor={:.1}ms probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}", sample.rtt_ms, sample.jitter_ms, sample.input_latency_ms, sample.probe_loss_pct, sample.video_loss_pct, sample.left_receive_fps, sample.left_present_fps, sample.left_server_fps, sample.right_receive_fps, sample.right_present_fps, sample.right_server_fps, sample.dropped_frames, sample.queue_depth ); } } let _ = writeln!(text); let _ = writeln!(text, "recommendations"); for item in &self.recommendations { let _ = writeln!(text, " - {item}"); } if !self.notes.is_empty() { let _ = writeln!(text); let _ = writeln!(text, "notes"); for item in &self.notes { let _ = writeln!(text, " - {item}"); } } let _ = writeln!(text); let _ = writeln!(text, "quality probe"); let _ = writeln!(text, " {}", self.probe_command); text } } pub fn quality_probe_command() -> &'static str { "scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh" } fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec { let mut items = Vec::new(); if !state.server_available { items.push( "The server is not reachable from this launcher yet, so stream-quality results would not be meaningful." .to_string(), ); } if log.is_empty() { items.push( "Live stream samples will appear here after the launcher collects a few probe windows. Leave the relay up for a few seconds to populate RTT, jitter, loss, and fps." .to_string(), ); } if let Some(sample) = log.latest() { if sample.probe_loss_pct >= 3.0 || sample.jitter_ms >= 18.0 { items.push( "Probe jitter/loss is elevated. A wired client connection or a gentler capture profile will usually help more than changing the breakout size." .to_string(), ); } if sample.video_loss_pct >= 2.0 || sample.dropped_frames > 0 { items.push( "Video packets are arriving with gaps or server-side drops. Try 900p or 720p capture first, then watch whether dropped frames and video-loss fall." .to_string(), ); } if sample.left_present_fps + 1.0 < sample.left_receive_fps || sample.right_present_fps + 1.0 < sample.right_receive_fps { items.push( "The client is receiving more frames than it is presenting. That points at local decode/render pressure, so prefer lighter breakout sizes or hardware decode." .to_string(), ); } if sample.queue_depth > 8 { items.push( "The preview queue is backing up. When queue depth climbs, expect laggy mouse feel and delayed visual response even if raw fps still looks okay." .to_string(), ); } } let heavy_capture = state.capture_sizes.iter().any(|preset| { matches!( preset, super::state::CaptureSizePreset::Source | super::state::CaptureSizePreset::P1080 | super::state::CaptureSizePreset::P1440 ) }); if heavy_capture { items.push( "If motion artifacting spikes, try a 900p or 720p capture profile before shrinking the breakout window; that usually lowers WAN pressure faster." .to_string(), ); } let source_passthrough = state .capture_sizes .iter() .any(|preset| matches!(preset, super::state::CaptureSizePreset::Source)); if source_passthrough { items.push( "Source capture uses the HDMI device's own H.264 stream. If motion damage lingers for seconds, switch that eye to 1080p, 900p, or 720p so the server re-encodes with a tighter keyframe cadence." .to_string(), ); } if let Some(sample) = log.latest() && sample.video_loss_pct < 0.5 && sample.dropped_frames == 0 && ((sample.left_server_fps - sample.left_receive_fps) > 6.0 || (sample.right_server_fps - sample.right_receive_fps) > 6.0) { items.push( "Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss, so compare Source against 1080p/900p and watch which side stays steadier." .to_string(), ); } if state.breakout_count() == 2 { items.push( "Both eye feeds are broken out right now. If the client starts struggling, compare in-launcher preview smoothness against full-window decode." .to_string(), ); } if items.is_empty() { items.push("Session state looks stable. Collect a few real samples before changing capture settings.".to_string()); } items } #[cfg(test)] mod tests { use super::*; use crate::launcher::state::{DeviceSelection, LauncherState}; fn sample(n: u64) -> PerformanceSample { PerformanceSample { rtt_ms: 20.0 + n as f32, jitter_ms: 3.0 + n as f32, input_latency_ms: 10.0 + n as f32, probe_loss_pct: n as f32, video_loss_pct: (n as f32) * 0.5, left_receive_fps: 30.0, left_present_fps: 29.0, left_server_fps: 30.0, right_receive_fps: 30.0, right_present_fps: 28.0, right_server_fps: 30.0, dropped_frames: n, queue_depth: n as u32, } } #[test] fn diagnostics_log_keeps_only_latest_samples_with_capacity() { let mut log = DiagnosticsLog::new(2); log.record(sample(1)); log.record(sample(2)); log.record(sample(3)); let kept: Vec = log.iter().map(|item| item.dropped_frames).collect(); assert_eq!(kept, vec![2, 3]); assert_eq!(log.latest().map(|s| s.dropped_frames), Some(3)); } #[test] fn diagnostics_log_enforces_minimum_capacity() { let mut log = DiagnosticsLog::new(0); log.record(sample(1)); log.record(sample(2)); assert_eq!(log.len(), 1); assert_eq!(log.latest().map(|s| s.dropped_frames), Some(2)); } #[test] fn snapshot_report_contains_state_fields_and_samples() { let mut state = LauncherState::new(); state.devices = DeviceSelection { camera: Some("/dev/video0".to_string()), microphone: Some("alsa_input.usb".to_string()), speaker: Some("alsa_output.usb".to_string()), keyboard: Some("/dev/input/event10".to_string()), mouse: Some("/dev/input/event11".to_string()), }; state.push_note("first note"); let mut log = DiagnosticsLog::new(4); log.record(sample(7)); let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0")); assert_eq!( report.selected_microphone.as_deref(), Some("alsa_input.usb") ); assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); assert_eq!( report.selected_keyboard.as_deref(), Some("/dev/input/event10") ); assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11")); assert_eq!(report.recent_samples.len(), 1); assert_eq!(report.notes, vec!["first note".to_string()]); assert!(report.status.contains("mode=remote")); assert!(report.client_version.starts_with("0.")); assert!(report.left_capture_profile.contains("fps")); assert_eq!(report.left_capture_transport, "source pass-through"); } #[test] fn snapshot_json_is_serializable_and_mentions_probe_command() { let report = SnapshotReport::from_state( &LauncherState::new(), &DiagnosticsLog::new(1), quality_probe_command().to_string(), ); let json = report.to_pretty_json().expect("serialize"); assert!(json.contains("quality_gate.sh")); assert!(json.contains("routing")); assert!(json.contains("view_mode")); } #[test] fn snapshot_text_mentions_versions_profiles_and_recommendations() { let report = SnapshotReport::from_state( &LauncherState::new(), &DiagnosticsLog::new(1), quality_probe_command().to_string(), ); let text = report.to_pretty_text(); assert!(text.contains("Lesavka Diagnostics")); assert!(text.contains("client: v")); assert!(text.contains("left eye")); assert!(text.contains("transport:")); assert!(text.contains("recommendations")); } #[test] fn quality_probe_command_mentions_both_gates() { let cmd = quality_probe_command(); assert!(cmd.contains("hygiene_gate.sh")); assert!(cmd.contains("quality_gate.sh")); } }