lesavka/client/src/launcher/diagnostics.rs

483 lines
18 KiB
Rust
Raw Normal View History

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 probe_spread_ms: f32,
pub input_latency_ms: f32,
2026-04-16 21:18:34 -03:00
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<PerformanceSample>,
}
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<Item = &PerformanceSample> {
self.history.iter()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotReport {
pub client_version: String,
pub server_version: Option<String>,
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<String>,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
pub selected_keyboard: Option<String>,
pub selected_mouse: Option<String>,
pub status: String,
pub recent_samples: Vec<PerformanceSample>,
pub notes: Vec<String>,
pub recommendations: Vec<String>,
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<String, serde_json::Error> {
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/probe-spread/loss samples yet; this report is currently a launcher state snapshot."
);
} else {
for sample in &self.recent_samples {
let _ = writeln!(
text,
" rtt={:.1}ms probe-spread={:.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.probe_spread_ms,
sample.input_latency_ms,
2026-04-16 21:18:34 -03:00
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<String> {
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, probe spread, loss, and fps."
.to_string(),
);
}
2026-04-16 21:18:34 -03:00
if let Some(sample) = log.latest() {
if sample.probe_loss_pct >= 3.0 || sample.probe_spread_ms >= 18.0 {
2026-04-16 21:18:34 -03:00
items.push(
"Control-plane probe spread or loss is elevated. That can come from the network or from server stalls, so compare it against the eye fps before blaming the WAN."
2026-04-16 21:18:34 -03:00
.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,
probe_spread_ms: 3.0 + n as f32,
input_latency_ms: 10.0 + n as f32,
2026-04-16 21:18:34 -03:00
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<u64> = 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"));
}
}