Files
ipodderx-rs/src/logbuf.rs
rays dc63d6acaf Cut what the audit found: dead columns, one-time upgrades, three deps
Works through TODO.md from the 2026-09-12 over-engineering audit. Drops the
entries.read/flagged/position columns (migrate() removes them from older
databases), migrate_opml_children, the legacy interval_mins key, the
contrib/ systemd units, test-only Db wrappers, a duplicate token generator,
redundant logbuf visitors, unused page state and CSS, and the infer, dirs
and tokio-stream dependencies. The icon is served once as /icon.png instead
of inlined four times, taking about 94 KB off the two pages.

The adoption's subscription half was not dead: it gives a fresh install's
first admin the config's feeds. It stays as adopt_catalogue, now tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 01:55:44 +00:00

148 lines
4.3 KiB
Rust

//! In-process ring buffer of log lines, so the UI can show what the daemon is doing.
//!
//! Tailing a file would not survive Docker, where logs go to stdout and there is no file
//! to read. Capturing inside the tracing pipeline works the same either way.
use std::collections::VecDeque;
use std::sync::{LazyLock, Mutex};
use tracing::field::{Field, Visit};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
/// Kept small enough to be cheap to hold and to serialise in one response.
const CAPACITY: usize = 5000;
#[derive(Clone, Debug, serde::Serialize)]
pub struct LogLine {
/// Monotonic, so a client can ask for "everything after N" without duplicates.
pub seq: u64,
pub ts: i64,
pub level: String,
pub target: String,
pub msg: String,
}
struct Ring {
lines: VecDeque<LogLine>,
next_seq: u64,
}
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
});
pub fn push(level: &str, target: &str, msg: String) {
let mut ring = match BUF.lock() {
Ok(r) => r,
Err(p) => p.into_inner(), // a poisoned log buffer must not take the process down
};
let seq = ring.next_seq;
ring.next_seq += 1;
if ring.lines.len() == CAPACITY {
ring.lines.pop_front();
}
ring.lines.push_back(LogLine {
seq,
ts: crate::db::now(),
level: level.to_owned(),
target: target.to_owned(),
msg,
});
}
/// Lines newer than `after`, oldest first, plus the highest seq now held.
pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
let ring = match BUF.lock() {
Ok(r) => r,
Err(p) => p.into_inner(),
};
let latest = ring.next_seq.saturating_sub(1);
let mut out: Vec<LogLine> = ring
.lines
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
// On a first load (after = 0) the tail is what matters, not the head.
if out.len() > limit {
out.drain(..out.len() - limit);
}
(out, latest)
}
/// A tracing layer that mirrors every event into the ring.
pub struct RingLayer;
impl<S: tracing::Subscriber> Layer<S> for RingLayer {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let mut v = Collect::default();
event.record(&mut v);
let meta = event.metadata();
push(meta.level().as_str(), meta.target(), v.finish());
}
}
#[derive(Default)]
struct Collect {
message: String,
fields: Vec<String>,
}
impl Collect {
fn finish(self) -> String {
if self.fields.is_empty() {
self.message
} else if self.message.is_empty() {
self.fields.join(" ")
} else {
format!("{} {}", self.message, self.fields.join(" "))
}
}
fn add(&mut self, field: &Field, value: String) {
if field.name() == "message" {
self.message = value;
} else {
self.fields.push(format!("{}={}", field.name(), value));
}
}
}
impl Visit for Collect {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.add(field, format!("{value:?}"));
}
// Numbers and bools reach record_debug through the trait's defaults, which prints them the
// same way. A string would print quoted there, hence its own method.
fn record_str(&mut self, field: &Field, value: &str) {
self.add(field, value.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ring_drops_oldest_and_keeps_sequence_stable() {
for i in 0..(CAPACITY + 50) {
push("INFO", "t", format!("line {i}"));
}
let (all, latest) = since(0, CAPACITY * 2);
assert_eq!(all.len(), CAPACITY, "bounded");
assert!(latest >= (CAPACITY + 50) as u64);
assert!(
all.first().unwrap().seq < all.last().unwrap().seq,
"oldest first"
);
// "everything after the last one I saw" must return nothing new.
let (none, _) = since(latest, 100);
assert!(none.is_empty());
// A first load takes the tail, not the head.
let (tail, _) = since(0, 5);
assert_eq!(tail.len(), 5);
assert_eq!(tail.last().unwrap().seq, latest);
}
}