//! Bounded diagnostic-event history for the browser's live log view. use std::{collections::VecDeque, time::SystemTime}; use kaptan_log::Level; use kaptan_mavlink::generated::kaptan::LogLevel; use serde::{Serialize, Serializer}; const MAX_LOG_ENTRIES: usize = 512; fn serialize_level(level: &Level, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(level.as_str()) } /// Decodes a wire event level, rejecting the filter-only Off value. pub const fn event_level_from_wire(level: LogLevel) -> Option { match level { LogLevel::LOG_LEVEL_OFF => None, LogLevel::LOG_LEVEL_TRACE => Some(Level::Trace), LogLevel::LOG_LEVEL_DEBUG => Some(Level::Debug), LogLevel::LOG_LEVEL_INFO => Some(Level::Info), LogLevel::LOG_LEVEL_WARN => Some(Level::Warn), LogLevel::LOG_LEVEL_ERROR => Some(Level::Error), } } /// Human-readable packet-view representation. pub const fn wire_level_label(level: LogLevel) -> &'static str { match event_level_from_wire(level) { Some(level) => level.as_str(), None => "OFF", } } /// One diagnostic event decoded from a CRC-valid Kaptan MAVLink frame. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LogObservation { pub system_id: u8, pub component_id: u8, pub level: Level, pub truncated: bool, pub text: String, } /// Read-only browser projection of the current link session's log history. #[derive(Clone, Debug, Serialize)] pub struct LogSnapshot { pub session: u32, pub discarded: u64, pub entries: Vec, } /// One retained diagnostic event, ordered by `id` within a link session. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct LogView { pub id: u32, pub received_at_ms: u64, pub system_id: u8, pub component_id: u8, #[serde(serialize_with = "serialize_level")] pub level: Level, pub truncated: bool, pub text: String, } #[derive(Default)] pub struct LogMonitor { entries: VecDeque, session: u32, next_id: u32, discarded: u64, } impl LogMonitor { /// Retains every event in receive order up to the fixed host-side bound. pub fn observe( &mut self, observations: impl IntoIterator, now: SystemTime, ) { let received_at_ms = now .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_millis() .min(u128::from(u64::MAX)) as u64; for observation in observations { if self.next_id == u32::MAX { self.clear(); } if self.entries.len() == MAX_LOG_ENTRIES { self.entries.pop_front(); self.discarded = self.discarded.saturating_add(1); } self.entries.push_back(LogView { id: self.next_id, received_at_ms, system_id: observation.system_id, component_id: observation.component_id, level: observation.level, truncated: observation.truncated, text: observation.text, }); self.next_id += 1; } } pub fn snapshot(&self) -> LogSnapshot { LogSnapshot { session: self.session, discarded: self.discarded, entries: self.entries.iter().cloned().collect(), } } /// Starts a fresh browser history for a new serial-link session. pub fn clear(&mut self) { self.entries.clear(); self.session = self.session.wrapping_add(1); self.next_id = 0; self.discarded = 0; } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; fn observation(index: usize) -> LogObservation { LogObservation { system_id: 1, component_id: 2, level: Level::Info, truncated: false, text: format!("event {index}"), } } #[test] fn retains_distinct_events_in_receive_order() { let mut monitor = LogMonitor::default(); let now = SystemTime::UNIX_EPOCH + Duration::from_millis(1_234); monitor.observe([observation(0), observation(1)], now); let snapshot = monitor.snapshot(); assert_eq!(snapshot.entries.len(), 2); assert_eq!(snapshot.entries[0].id, 0); assert_eq!(snapshot.entries[0].received_at_ms, 1_234); assert!(!snapshot.entries[0].truncated); assert_eq!(snapshot.entries[0].text, "event 0"); assert_eq!(snapshot.entries[1].id, 1); assert_eq!(snapshot.entries[1].text, "event 1"); assert_eq!(snapshot.discarded, 0); assert_eq!( serde_json::to_value(&snapshot).unwrap()["entries"][0]["level"], "INFO" ); } #[test] fn drops_only_the_oldest_entries_at_the_fixed_bound() { let mut monitor = LogMonitor::default(); monitor.observe( (0..=MAX_LOG_ENTRIES).map(observation), SystemTime::UNIX_EPOCH, ); let snapshot = monitor.snapshot(); assert_eq!(snapshot.entries.len(), MAX_LOG_ENTRIES); assert_eq!(snapshot.entries[0].text, "event 1"); assert_eq!(snapshot.entries[MAX_LOG_ENTRIES - 1].text, "event 512"); assert_eq!(snapshot.discarded, 1); } #[test] fn clear_starts_a_new_link_session() { let mut monitor = LogMonitor::default(); monitor.observe([observation(0)], SystemTime::UNIX_EPOCH); monitor.clear(); let snapshot = monitor.snapshot(); assert_eq!(snapshot.session, 1); assert!(snapshot.entries.is_empty()); assert_eq!(snapshot.discarded, 0); } #[test] fn wire_level_accepts_only_event_values() { assert_eq!(event_level_from_wire(LogLevel::LOG_LEVEL_OFF), None); for (wire, expected) in [ (LogLevel::LOG_LEVEL_TRACE, Level::Trace), (LogLevel::LOG_LEVEL_DEBUG, Level::Debug), (LogLevel::LOG_LEVEL_INFO, Level::Info), (LogLevel::LOG_LEVEL_WARN, Level::Warn), (LogLevel::LOG_LEVEL_ERROR, Level::Error), ] { assert_eq!(event_level_from_wire(wire), Some(expected)); } assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_TRACE), "TRACE"); assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_OFF), "OFF"); } #[test] fn truncation_state_survives_the_browser_projection() { let mut event = observation(0); event.truncated = true; let mut monitor = LogMonitor::default(); monitor.observe([event], SystemTime::UNIX_EPOCH); let snapshot = monitor.snapshot(); assert!(snapshot.entries[0].truncated); assert_eq!( serde_json::to_value(&snapshot).unwrap()["entries"][0]["truncated"], true ); } #[test] fn identifier_exhaustion_starts_a_fresh_rendering_session() { let mut monitor = LogMonitor { next_id: u32::MAX, ..LogMonitor::default() }; monitor.observe([observation(0)], SystemTime::UNIX_EPOCH); let snapshot = monitor.snapshot(); assert_eq!(snapshot.session, 1); assert_eq!(snapshot.entries.len(), 1); assert_eq!(snapshot.entries[0].id, 0); assert_eq!(snapshot.entries[0].text, "event 0"); } }