aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui/admin/log.rs
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
commit11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch)
tree44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/ui/admin/log.rs
parentd871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff)
downloadjellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst
move files around
Diffstat (limited to 'server/src/ui/admin/log.rs')
-rw-r--r--server/src/ui/admin/log.rs258
1 files changed, 258 insertions, 0 deletions
diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs
new file mode 100644
index 0000000..dff6d1b
--- /dev/null
+++ b/server/src/ui/admin/log.rs
@@ -0,0 +1,258 @@
+/*
+ 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 <metamuffin.org>
+*/
+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<Log> = LazyLock::new(Log::default);
+
+pub fn enable_logging() {
+ log::set_logger(&*LOGGER).unwrap();
+ log::set_max_level(log::LevelFilter::Debug);
+}
+
+type LogBuffer = VecDeque<Arc<LogLine>>;
+
+pub struct Log {
+ inner: env_logger::Logger,
+ stream: (
+ broadcast::Sender<Arc<LogLine>>,
+ broadcast::Sender<Arc<LogLine>>,
+ ),
+ log: RwLock<(LogBuffer, LogBuffer)>,
+}
+
+pub struct LogLine {
+ time: DateTime<Utc>,
+ module: Option<&'static str>,
+ level: Level,
+ message: String,
+}
+
+#[get("/admin/log?<warnonly>", rank = 2)]
+pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<DynLayoutPage<'a>> {
+ 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&<warnonly>", 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, "<span style=color:#{:02x}{:02x}{:02x}>", r, g, b).unwrap()
+ }
+ pub fn reset_color(&mut self) {
+ if self.color {
+ write!(self.s, "</span>").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!(),
+ });
+ }
+ _ => (),
+ },
+ _ => (),
+ }
+ }
+}