lesavka/client/src/launcher/diagnostics/snapshot_report.rs

466 lines
20 KiB
Rust

impl SnapshotReport {
pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self {
let left_capture = state
.display_capture_size_choice(0)
.unwrap_or_else(|| state.capture_size_choice(0));
let right_capture = state
.display_capture_size_choice(1)
.unwrap_or_else(|| state.capture_size_choice(1));
let left_breakout = state.breakout_size_choice(0);
let right_breakout = state.breakout_size_choice(1);
let latest = log.latest();
let left_stream_caps = latest
.map(|sample| sample.left_stream_caps_label.clone())
.unwrap_or_default();
let right_stream_caps = latest
.map(|sample| sample.right_stream_caps_label.clone())
.unwrap_or_default();
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
),
client_process_cpu_pct: latest
.map(|sample| sample.client_process_cpu_pct)
.unwrap_or(0.0),
server_process_cpu_pct: latest
.map(|sample| sample.server_process_cpu_pct)
.unwrap_or(0.0),
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_feed_source: match state.feed_source_preset(0) {
super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(),
super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(),
super::state::FeedSourcePreset::Off => "Off".to_string(),
},
left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps),
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
),
left_decoder_label: latest
.map(|sample| {
if sample.left_decoder_label.is_empty() {
"pending".to_string()
} else {
sample.left_decoder_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
left_stream_spread_ms: latest
.map(|sample| sample.left_stream_spread_ms)
.unwrap_or(0.0),
left_packet_gap_peak_ms: latest
.map(|sample| sample.left_packet_gap_peak_ms)
.unwrap_or(0.0),
left_present_gap_peak_ms: latest
.map(|sample| sample.left_present_gap_peak_ms)
.unwrap_or(0.0),
left_queue_depth: latest.map(|sample| sample.left_queue_depth).unwrap_or(0),
left_queue_peak: latest.map(|sample| sample.left_queue_peak).unwrap_or(0),
left_server_source_gap_peak_ms: latest
.map(|sample| sample.left_server_source_gap_peak_ms)
.unwrap_or(0.0),
left_server_send_gap_peak_ms: latest
.map(|sample| sample.left_server_send_gap_peak_ms)
.unwrap_or(0.0),
left_server_queue_peak: latest
.map(|sample| sample.left_server_queue_peak)
.unwrap_or(0),
left_server_encoder_label: latest
.map(|sample| {
if sample.left_server_encoder_label.is_empty() {
"pending".to_string()
} else {
sample.left_server_encoder_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
left_stream_caps_label: latest
.map(|sample| {
if sample.left_stream_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_stream_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
left_decoded_caps_label: latest
.map(|sample| {
if sample.left_decoded_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_decoded_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
left_rendered_caps_label: latest
.map(|sample| {
if sample.left_rendered_caps_label.is_empty() {
"pending".to_string()
} else {
sample.left_rendered_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_surface: state.display_surface(1).label().to_string(),
right_feed_source: match state.feed_source_preset(1) {
super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(),
super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(),
super::state::FeedSourcePreset::Off => "Off".to_string(),
},
right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps),
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
),
right_decoder_label: latest
.map(|sample| {
if sample.right_decoder_label.is_empty() {
"pending".to_string()
} else {
sample.right_decoder_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_stream_spread_ms: latest
.map(|sample| sample.right_stream_spread_ms)
.unwrap_or(0.0),
right_packet_gap_peak_ms: latest
.map(|sample| sample.right_packet_gap_peak_ms)
.unwrap_or(0.0),
right_present_gap_peak_ms: latest
.map(|sample| sample.right_present_gap_peak_ms)
.unwrap_or(0.0),
right_queue_depth: latest.map(|sample| sample.right_queue_depth).unwrap_or(0),
right_queue_peak: latest.map(|sample| sample.right_queue_peak).unwrap_or(0),
right_server_source_gap_peak_ms: latest
.map(|sample| sample.right_server_source_gap_peak_ms)
.unwrap_or(0.0),
right_server_send_gap_peak_ms: latest
.map(|sample| sample.right_server_send_gap_peak_ms)
.unwrap_or(0.0),
right_server_queue_peak: latest
.map(|sample| sample.right_server_queue_peak)
.unwrap_or(0),
right_server_encoder_label: latest
.map(|sample| {
if sample.right_server_encoder_label.is_empty() {
"pending".to_string()
} else {
sample.right_server_encoder_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_stream_caps_label: latest
.map(|sample| {
if sample.right_stream_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_stream_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_decoded_caps_label: latest
.map(|sample| {
if sample.right_decoded_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_decoded_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
right_rendered_caps_label: latest
.map(|sample| {
if sample.right_rendered_caps_label.is_empty() {
"pending".to_string()
} else {
sample.right_rendered_caps_label.clone()
}
})
.unwrap_or_else(|| "pending".to_string()),
selected_camera: state.devices.camera.clone(),
camera_quality_label: state
.camera_quality
.map(CameraMode::short_label)
.unwrap_or_else(|| "default".to_string()),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
media_channels: MediaChannelState {
camera: state.channels.camera,
microphone: state.channels.microphone,
audio: state.channels.audio,
},
audio_gain_label: state.audio_gain_label(),
mic_gain_label: state.mic_gain_label(),
upstream_camera: latest
.map(|sample| sample.upstream_camera.clone())
.unwrap_or_default(),
upstream_microphone: latest
.map(|sample| sample.upstream_microphone.clone())
.unwrap_or_default(),
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={} capture_power={}",
self.routing,
self.view_mode,
if self.remote_active { "active" } else { "idle" },
self.power_state
);
let _ = writeln!(
text,
"runtime: client CPU {:.1}% | server CPU {:.1}%",
self.client_process_cpu_pct, self.server_process_cpu_pct
);
let _ = writeln!(text, "source feed: {}", self.preview_source);
let _ = writeln!(text, "display limit: {}", self.client_display_limit);
let _ = writeln!(text);
let _ = writeln!(text, "left eye");
let _ = writeln!(text, " surface: {}", self.left_surface);
let _ = writeln!(text, " source: {}", self.left_feed_source);
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,
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
self.left_decoder_label,
self.left_stream_spread_ms,
self.left_packet_gap_peak_ms,
self.left_present_gap_peak_ms,
self.left_queue_depth,
self.left_queue_peak
);
let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
self.left_server_encoder_label,
self.server_process_cpu_pct,
self.left_server_source_gap_peak_ms,
self.left_server_send_gap_peak_ms,
self.left_server_queue_peak
);
let _ = writeln!(text, "right eye");
let _ = writeln!(text, " surface: {}", self.right_surface);
let _ = writeln!(text, " source: {}", self.right_feed_source);
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,
" live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}",
self.right_decoder_label,
self.right_stream_spread_ms,
self.right_packet_gap_peak_ms,
self.right_present_gap_peak_ms,
self.right_queue_depth,
self.right_queue_peak
);
let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label);
let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label);
let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label);
let _ = writeln!(
text,
" server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}",
self.right_server_encoder_label,
self.server_process_cpu_pct,
self.right_server_source_gap_peak_ms,
self.right_server_send_gap_peak_ms,
self.right_server_queue_peak
);
let _ = writeln!(text);
let _ = writeln!(text, "media staging");
let _ = writeln!(
text,
" camera: {} | quality={} | enabled={}",
self.selected_camera.as_deref().unwrap_or("auto"),
self.camera_quality_label,
self.media_channels.camera
);
let _ = writeln!(
text,
" speaker: {} | volume={} | enabled={}",
self.selected_speaker.as_deref().unwrap_or("auto"),
self.audio_gain_label,
self.media_channels.audio
);
let _ = writeln!(
text,
" microphone: {} | gain={} | enabled={}",
self.selected_microphone.as_deref().unwrap_or("auto"),
self.mic_gain_label,
self.media_channels.microphone
);
let _ = writeln!(
text,
" uplink camera: {}",
uplink_summary(&self.upstream_camera)
);
let _ = writeln!(
text,
" uplink microphone: {}",
uplink_summary(&self.upstream_microphone)
);
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, "current UI state");
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 cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}",
sample.rtt_ms,
sample.probe_spread_ms,
sample.input_latency_ms,
sample.client_process_cpu_pct,
sample.server_process_cpu_pct,
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,
sample.left_queue_peak.max(sample.right_queue_peak),
sample.left_packet_gap_peak_ms,
sample.left_present_gap_peak_ms,
sample.right_packet_gap_peak_ms,
sample.right_present_gap_peak_ms,
sample.left_server_encoder_label,
sample.left_server_source_gap_peak_ms,
sample.left_server_send_gap_peak_ms,
sample.left_server_queue_peak,
sample.right_server_encoder_label,
sample.right_server_source_gap_peak_ms,
sample.right_server_send_gap_peak_ms,
sample.right_server_queue_peak
);
let _ = writeln!(
text,
" uplink: cam={} mic={}",
uplink_summary(&sample.upstream_camera),
uplink_summary(&sample.upstream_microphone)
);
}
}
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
}
}
fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String {
if !stream.enabled {
return "disabled".to_string();
}
let connection = if stream.connected {
"live"
} else if stream.reconnect_count > 0 {
"reconnecting"
} else {
"idle"
};
let error = if stream.last_error.is_empty() {
"ok".to_string()
} else {
stream.last_error.clone()
};
format!(
"{connection} queue={}/{} enq-age={:.0}/{:.0}ms delivery={:.0}/{:.0}ms block-peak={:.0}ms reconnects={} streamed={} drops(total/full/stale)={}/{}/{} error={error}",
stream.queue_depth,
stream.queue_peak,
stream.latest_enqueue_age_ms,
stream.enqueue_age_peak_ms,
stream.latest_delivery_age_ms,
stream.delivery_age_peak_ms,
stream.enqueue_block_peak_ms,
stream.reconnect_count,
stream.packets_streamed,
stream.dropped_packets,
stream.dropped_queue_full_packets,
stream.dropped_stale_packets
)
}