story-kit: merge 292_story_show_server_logs_in_web_ui

This commit is contained in:
Dave
2026-03-19 01:29:33 +00:00
parent 2346602b30
commit 2f0d796b38
6 changed files with 384 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tokio::sync::broadcast;
const CAPACITY: usize = 1000;
@@ -72,16 +73,25 @@ impl LogEntry {
pub struct LogBuffer {
entries: Mutex<VecDeque<LogEntry>>,
log_file: Mutex<Option<PathBuf>>,
/// Broadcast channel for live log streaming to WebSocket subscribers.
broadcast_tx: broadcast::Sender<LogEntry>,
}
impl LogBuffer {
fn new() -> Self {
let (broadcast_tx, _) = broadcast::channel(512);
Self {
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
log_file: Mutex::new(None),
broadcast_tx,
}
}
/// Subscribe to live log entries as they are pushed.
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
self.broadcast_tx.subscribe()
}
/// Set the persistent log file path. Call once at startup after the
/// project root is known.
pub fn set_log_file(&self, path: PathBuf) {
@@ -112,8 +122,11 @@ impl LogBuffer {
if buf.len() >= CAPACITY {
buf.pop_front();
}
buf.push_back(entry);
buf.push_back(entry.clone());
}
// Best-effort broadcast to WebSocket subscribers.
let _ = self.broadcast_tx.send(entry);
}
/// Return up to `count` recent log lines as formatted strings,
@@ -140,6 +153,31 @@ impl LogBuffer {
let start = filtered.len().saturating_sub(count);
filtered[start..].to_vec()
}
/// Return up to `count` recent `LogEntry` structs (not formatted strings),
/// optionally filtered by substring and/or severity level.
/// Entries are returned in chronological order (oldest first).
pub fn get_recent_entries(
&self,
count: usize,
filter: Option<&str>,
severity: Option<&LogLevel>,
) -> Vec<LogEntry> {
let buf = match self.entries.lock() {
Ok(b) => b,
Err(_) => return vec![],
};
let filtered: Vec<LogEntry> = buf
.iter()
.filter(|entry| {
severity.is_none_or(|s| &entry.level == s)
&& filter.is_none_or(|f| entry.message.contains(f) || entry.formatted().contains(f))
})
.cloned()
.collect();
let start = filtered.len().saturating_sub(count);
filtered[start..].to_vec()
}
}
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
@@ -208,10 +246,7 @@ mod tests {
#[test]
fn evicts_oldest_at_capacity() {
let buf = LogBuffer {
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
log_file: Mutex::new(None),
};
let buf = LogBuffer::new();
// Fill past capacity
for i in 0..=CAPACITY {
buf.push_entry(LogLevel::Info, format!("line {i}"));