2026-04-13 23:11:35 -03:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::VecDeque;
|
2026-04-16 19:19:37 -03:00
|
|
|
use std::fmt::Write as _;
|
2026-04-13 23:11:35 -03:00
|
|
|
|
|
|
|
|
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<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 {
|
2026-04-16 19:19:37 -03:00
|
|
|
pub client_version: String,
|
|
|
|
|
pub server_version: Option<String>,
|
|
|
|
|
pub server_available: bool,
|
2026-04-13 23:11:35 -03:00
|
|
|
pub routing: InputRouting,
|
|
|
|
|
pub view_mode: ViewMode,
|
|
|
|
|
pub remote_active: bool,
|
2026-04-16 19:19:37 -03:00
|
|
|
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,
|
2026-04-13 23:11:35 -03:00
|
|
|
pub selected_camera: Option<String>,
|
|
|
|
|
pub selected_microphone: Option<String>,
|
|
|
|
|
pub selected_speaker: Option<String>,
|
2026-04-16 19:19:37 -03:00
|
|
|
pub selected_keyboard: Option<String>,
|
|
|
|
|
pub selected_mouse: Option<String>,
|
2026-04-13 23:11:35 -03:00
|
|
|
pub status: String,
|
|
|
|
|
pub recent_samples: Vec<PerformanceSample>,
|
|
|
|
|
pub notes: Vec<String>,
|
2026-04-16 19:19:37 -03:00
|
|
|
pub recommendations: Vec<String>,
|
2026-04-13 23:11:35 -03:00
|
|
|
pub probe_command: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SnapshotReport {
|
|
|
|
|
pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self {
|
2026-04-16 19:19:37 -03:00
|
|
|
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);
|
2026-04-13 23:11:35 -03:00
|
|
|
Self {
|
2026-04-16 19:19:37 -03:00
|
|
|
client_version: crate::VERSION.to_string(),
|
|
|
|
|
server_version: state.server_version.clone(),
|
|
|
|
|
server_available: state.server_available,
|
2026-04-13 23:11:35 -03:00
|
|
|
routing: state.routing,
|
|
|
|
|
view_mode: state.view_mode,
|
|
|
|
|
remote_active: state.remote_active,
|
2026-04-16 19:19:37 -03:00
|
|
|
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
|
|
|
|
|
),
|
2026-04-13 23:11:35 -03:00
|
|
|
selected_camera: state.devices.camera.clone(),
|
|
|
|
|
selected_microphone: state.devices.microphone.clone(),
|
|
|
|
|
selected_speaker: state.devices.speaker.clone(),
|
2026-04-16 19:19:37 -03:00
|
|
|
selected_keyboard: state.devices.keyboard.clone(),
|
|
|
|
|
selected_mouse: state.devices.mouse.clone(),
|
2026-04-13 23:11:35 -03:00
|
|
|
status: state.status_line(),
|
|
|
|
|
recent_samples: log.iter().cloned().collect(),
|
|
|
|
|
notes: state.notes.clone(),
|
2026-04-16 19:19:37 -03:00
|
|
|
recommendations: recommendations_for(state, log),
|
2026-04-13 23:11:35 -03:00
|
|
|
probe_command,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
|
|
|
|
serde_json::to_string_pretty(self)
|
|
|
|
|
}
|
2026-04-16 19:19:37 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn quality_probe_command() -> &'static str {
|
|
|
|
|
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 19:19:37 -03:00
|
|
|
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 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[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<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()),
|
2026-04-16 12:58:05 -03:00
|
|
|
keyboard: Some("/dev/input/event10".to_string()),
|
|
|
|
|
mouse: Some("/dev/input/event11".to_string()),
|
2026-04-13 23:11:35 -03:00
|
|
|
};
|
|
|
|
|
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"));
|
2026-04-14 23:03:18 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
report.selected_microphone.as_deref(),
|
|
|
|
|
Some("alsa_input.usb")
|
|
|
|
|
);
|
2026-04-13 23:11:35 -03:00
|
|
|
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
2026-04-16 19:19:37 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
report.selected_keyboard.as_deref(),
|
|
|
|
|
Some("/dev/input/event10")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11"));
|
2026-04-13 23:11:35 -03:00
|
|
|
assert_eq!(report.recent_samples.len(), 1);
|
|
|
|
|
assert_eq!(report.notes, vec!["first note".to_string()]);
|
2026-04-14 18:44:40 -03:00
|
|
|
assert!(report.status.contains("mode=remote"));
|
2026-04-16 19:19:37 -03:00
|
|
|
assert!(report.client_version.starts_with("0."));
|
|
|
|
|
assert!(report.left_capture_profile.contains("fps"));
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 19:19:37 -03:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|