236 lines
7.3 KiB
Rust
236 lines
7.3 KiB
Rust
//! 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<S>(level: &Level, serializer: S) -> Result<S::Ok, S::Error>
|
|
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<Level> {
|
|
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<LogView>,
|
|
}
|
|
|
|
/// 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<LogView>,
|
|
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<Item = LogObservation>,
|
|
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");
|
|
}
|
|
}
|