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 input_latency_ms: f32, pub left_fps: f32, pub right_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 breakout_display: String, pub left_surface: String, pub left_capture_profile: String, pub left_breakout_profile: String, pub right_surface: String, pub right_capture_profile: 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 ), breakout_display: 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.as_id(), left_capture.width, left_capture.height, left_capture.fps, left_capture.max_bitrate_kbit ), left_breakout_profile: format!( "{} | {}x{}", left_breakout.preset.as_id(), 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.as_id(), right_capture.width, right_capture.height, right_capture.fps, right_capture.max_bitrate_kbit ), right_breakout_profile: format!( "{} | {}x{}", right_breakout.preset.as_id(), 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, "breakout display: {}", self.breakout_display); 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, " 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, " 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 input={:.1}ms left={:.1}fps right={:.1}fps dropped={} queue={}", sample.rtt_ms, sample.input_latency_ms, sample.left_fps, sample.right_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 RTT, jitter, packet-loss, and queue samples are the next diagnostics tranche; this panel currently reports launcher/session state." .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(), ); } 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, input_latency_ms: 10.0 + n as f32, left_fps: 30.0, right_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")); } #[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("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")); } }