/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ use crate::{ logic::session::AdminSession, ui::{ error::MyResult, layout::{DynLayoutPage, LayoutPage}, }, uri, }; use chrono::{DateTime, Utc}; use log::Level; use markup::Render; use rocket::get; use rocket_ws::{Message, Stream, WebSocket}; use serde_json::json; use std::{ collections::VecDeque, fmt::Write, sync::{Arc, LazyLock, RwLock}, }; use tokio::sync::broadcast; const MAX_LOG_LEN: usize = 4096; static LOGGER: LazyLock = LazyLock::new(Log::default); pub fn enable_logging() { log::set_logger(&*LOGGER).unwrap(); log::set_max_level(log::LevelFilter::Debug); } type LogBuffer = VecDeque>; pub struct Log { inner: env_logger::Logger, stream: ( broadcast::Sender>, broadcast::Sender>, ), log: RwLock<(LogBuffer, LogBuffer)>, } pub struct LogLine { time: DateTime, module: Option<&'static str>, level: Level, message: String, } #[get("/admin/log?", rank = 2)] pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult> { Ok(LayoutPage { title: "Log".into(), class: Some("admin_log"), content: markup::new! { h1 { "Server Log" } a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }} code.log[id="log"] { @let g = LOGGER.log.read().unwrap(); table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } { tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] { td.time { @e.time.to_rfc3339() } td.loglevel { @format_level(e.level) } td.module { @e.module } td { @markup::raw(vt100_to_html(&e.message)) } } }} } }, }) } #[get("/admin/log?stream&", rank = 1)] pub fn r_admin_log_stream( _session: AdminSession, ws: WebSocket, warnonly: bool, ) -> Stream!['static] { let mut stream = if warnonly { LOGGER.stream.1.subscribe() } else { LOGGER.stream.0.subscribe() }; Stream! { ws => let _ = ws; while let Ok(line) = stream.recv().await { yield Message::Text(json!({ "time": line.time, "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(), "level_html": format_level_string(line.level), "module": line.module, "message": vt100_to_html(&line.message), }).to_string()); } } } impl Default for Log { fn default() -> Self { Self { inner: env_logger::builder() .filter_level(log::LevelFilter::Warn) .parse_env("LOG") .build(), stream: ( tokio::sync::broadcast::channel(1024).0, tokio::sync::broadcast::channel(1024).0, ), log: Default::default(), } } } impl Log { fn should_log(&self, metadata: &log::Metadata) -> bool { let level = metadata.level(); level <= match metadata.target() { x if x.starts_with("jelly") => Level::Debug, x if x.starts_with("rocket::") => Level::Info, _ => Level::Warn, } } fn do_log(&self, record: &log::Record) { let time = Utc::now(); let line = Arc::new(LogLine { time, module: record.module_path_static(), level: record.level(), message: record.args().to_string(), }); let mut w = self.log.write().unwrap(); w.0.push_back(line.clone()); let _ = self.stream.0.send(line.clone()); while w.0.len() > MAX_LOG_LEN { w.0.pop_front(); } if record.level() <= Level::Warn { let _ = self.stream.1.send(line.clone()); w.1.push_back(line); while w.1.len() > MAX_LOG_LEN { w.1.pop_front(); } } } } impl log::Log for Log { fn enabled(&self, metadata: &log::Metadata) -> bool { self.inner.enabled(metadata) || self.should_log(metadata) } fn log(&self, record: &log::Record) { match (record.module_path_static(), record.line()) { // TODO is there a better way to ignore those? (Some("rocket::rocket"), Some(670)) => return, (Some("rocket::server"), Some(401)) => return, _ => {} } if self.inner.enabled(record.metadata()) { self.inner.log(record); } if self.should_log(record.metadata()) { self.do_log(record) } } fn flush(&self) { self.inner.flush(); } } fn vt100_to_html(s: &str) -> String { let mut out = HtmlOut::default(); let mut st = vte::Parser::new(); st.advance(&mut out, s.as_bytes()); out.s } fn format_level(level: Level) -> impl markup::Render { let (s, c) = match level { Level::Debug => ("DEBUG", "blue"), Level::Error => ("ERROR", "red"), Level::Warn => ("WARN", "yellow"), Level::Info => ("INFO", "green"), Level::Trace => ("TRACE", "lightblue"), }; markup::new! { span[style=format!("color:{c}")] {@s} } } fn format_level_string(level: Level) -> String { let mut w = String::new(); format_level(level).render(&mut w).unwrap(); w } #[derive(Default)] pub struct HtmlOut { s: String, color: bool, } impl HtmlOut { pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { self.reset_color(); self.color = true; write!(self.s, "", r, g, b).unwrap() } pub fn reset_color(&mut self) { if self.color { write!(self.s, "").unwrap(); self.color = false; } } } impl vte::Perform for HtmlOut { fn print(&mut self, c: char) { match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), x => write!(self.s, "&#{};", x as u32).unwrap(), } } fn execute(&mut self, _byte: u8) {} fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} fn put(&mut self, _byte: u8) {} fn unhook(&mut self) {} fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} fn csi_dispatch( &mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char, ) { let mut k = params.iter(); #[allow(clippy::single_match)] match action { 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { c @ (30..=37 | 40..=47) => { let c = if *c >= 40 { *c - 10 } else { *c }; self.set_color(match c { 30 => [0, 0, 0], 31 => [255, 0, 0], 32 => [0, 255, 0], 33 => [255, 255, 0], 34 => [0, 0, 255], 35 => [255, 0, 255], 36 => [0, 255, 255], 37 => [255, 255, 255], _ => unreachable!(), }); } _ => (), }, _ => (), } } }