diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0dca1d..cb233065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ - The shim re-runs on DOM mutations and recurses into same-origin child frames (`about:blank`/`srcdoc`/`data:` with `allow-same-origin`), so ad content written into such frames after load is also matched. Cross-origin frames and closed shadow DOM remain out of reach. - Scriptlet error logging (debugging) - New opt-in `debug.scriptlet_console_logging` (off by default), toggleable from Settings → Debug, surfaces errors thrown by injected scriptlets in the page console as `[privaxy scriptlet]` entries instead of swallowing them. +- Live log streaming in the web UI + - Settings → Debug now shows the server's log output in real time + - The level can be changed in the webui - Fix cosmetic "modified responses" statistic undercount - Pages where only element-hiding (`display: none`) selectors were injected were not counted as modified; any injected cosmetic CSS now counts diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs index eec0b272..99315ef0 100644 --- a/privaxy/src/server/configuration/mod.rs +++ b/privaxy/src/server/configuration/mod.rs @@ -75,6 +75,10 @@ pub struct DebugConfig { /// Privaxy is in the path, so default off. #[serde(default)] pub scriptlet_console_logging: bool, + /// Verbosity of Privaxy's own logs, applied live (no restart). Dependency + /// logs remain governed by `RUST_LOG`. Defaults to `info`. + #[serde(default)] + pub log_level: crate::logging::LogLevel, } #[derive(Error, Debug)] diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs index 3216e77b..43eec62d 100644 --- a/privaxy/src/server/lib.rs +++ b/privaxy/src/server/lib.rs @@ -25,6 +25,7 @@ mod blocker_utils; mod ca; mod cert; pub mod configuration; +pub mod logging; mod proxy; pub mod statistics; mod web_gui; @@ -78,6 +79,11 @@ async fn handle_signals() -> (Arc, Arc) { } pub async fn start_privaxy() -> PrivaxyServer { + // Install the global logger first so every subsequent record is both + // written to stderr and made available to the `/api/logs` stream. The + // configured level is applied once the configuration is read below. + let log_handle = logging::init(logging::LogLevel::default().to_level_filter()); + // We use reqwest instead of hyper's client to perform most of the proxying as it's more convenient // to handle compression as well as offers a more convenient interface. let client = reqwest::Client::builder() @@ -120,6 +126,10 @@ pub async fn start_privaxy() -> PrivaxyServer { } }; + // Apply the persisted application log level now that configuration is + // available; the web UI can change it on the fly afterwards. + log_handle.set_level(configuration.debug.log_level.to_level_filter()); + let local_exclusion_store = LocalExclusionStore::new(Vec::from_iter(configuration.exclusions.clone().into_iter())); let local_exclusion_store_clone = local_exclusion_store.clone(); @@ -182,6 +192,7 @@ pub async fn start_privaxy() -> PrivaxyServer { let configuration_updater_tx_ref = configuration_updater_tx.clone(); let configuration_save_lock_ref = configuration_save_lock.clone(); let broadcast_tx_ref = broadcast_tx.clone(); + let log_handle_ref = log_handle.clone(); let notify_reload_clone = notify_reload.clone(); tokio::spawn(async move { @@ -197,6 +208,7 @@ pub async fn start_privaxy() -> PrivaxyServer { configuration_updater_tx_ref.clone(), cfg_lock_frontend.clone(), notify_reload_frontend.clone(), + log_handle_ref.clone(), ) .await; notify_reload_frontend.notified().await; @@ -262,6 +274,7 @@ async fn privaxy_frontend( configuration_updater_tx: tokio::sync::mpsc::Sender, configuration_save_lock: Arc>, notify_reload: Arc, + log_handle: logging::LogHandle, ) { let frontend = web_gui::get_frontend( broadcast_tx.clone(), @@ -271,6 +284,7 @@ async fn privaxy_frontend( &configuration_save_lock, &local_exclusion_store, notify_reload.clone(), + log_handle.clone(), ); let frontend_server = warp::serve(frontend); let config = read_configuration(&configuration_save_lock).await; diff --git a/privaxy/src/server/logging.rs b/privaxy/src/server/logging.rs new file mode 100644 index 00000000..877fda78 --- /dev/null +++ b/privaxy/src/server/logging.rs @@ -0,0 +1,231 @@ +//! Global logging setup that mirrors `env_logger`'s stderr output while also +//! fanning every emitted record out to in-process subscribers. +//! +//! The standard `log` facade only permits a single global logger, and +//! `env_logger` writes exclusively to stderr with no hook to observe records. +//! [`init`] therefore builds `env_logger::Logger`s for stderr formatting, then +//! wraps them so each record is additionally pushed into a bounded ring buffer +//! (for backlog replay) and broadcast to live WebSocket subscribers (see +//! `web_gui::logs`). +//! +//! Verbosity of the application's own (`privaxy`-targeted) records is governed +//! by a [`LogHandle`]'s atomic level, which the web UI can change on the fly +//! via the Debug settings — no restart or `RUST_LOG` change required. Records +//! from dependencies keep their `RUST_LOG`-derived filtering so the stream +//! isn't drowned in third-party noise. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use chrono::{DateTime, Utc}; +use log::{LevelFilter, Log, Metadata, Record}; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +/// Crate name used both as the logging target prefix for the application's own +/// records and to decide which records the dynamic level applies to. +const APP_TARGET: &str = "privaxy"; + +/// Number of recent records retained for replay to clients that connect after +/// the records were emitted. +const BACKLOG_CAPACITY: usize = 1000; + +/// Bound on the broadcast channel. Slow subscribers that fall further behind +/// than this are signalled with `Lagged` and skip the dropped records rather +/// than stalling the logger. +const CHANNEL_CAPACITY: usize = 512; + +/// Default level for dependency (non-`privaxy`) records when `RUST_LOG` doesn't +/// say otherwise. Keeps third-party crates quiet so the stream stays readable. +const DEPENDENCY_DEFAULT_LEVEL: LevelFilter = LevelFilter::Warn; + +/// A configurable log verbosity, persisted in the configuration and selectable +/// from the web UI. Mirrors `log::LevelFilter` but owns its serialized form. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Off, + Error, + Warn, + Info, + Debug, + Trace, +} + +impl Default for LogLevel { + fn default() -> Self { + Self::Info + } +} + +impl LogLevel { + pub fn to_level_filter(self) -> LevelFilter { + match self { + Self::Off => LevelFilter::Off, + Self::Error => LevelFilter::Error, + Self::Warn => LevelFilter::Warn, + Self::Info => LevelFilter::Info, + Self::Debug => LevelFilter::Debug, + Self::Trace => LevelFilter::Trace, + } + } +} + +/// A single formatted log record, serialized to WebSocket clients. +#[derive(Debug, Clone, Serialize)] +pub struct LogEntry { + pub now: DateTime, + pub level: String, + pub target: String, + pub message: String, +} + +/// Cheap-to-clone handle giving access to the live log broadcast, the retained +/// backlog, and the runtime-adjustable application log level. Shared with the +/// API layer so a new subscriber can be primed with recent history and the +/// Debug settings route can change verbosity live. +#[derive(Clone)] +pub struct LogHandle { + pub sender: broadcast::Sender, + pub backlog: Arc>>, + level: Arc, +} + +impl LogHandle { + /// Snapshot of the currently retained records, oldest first. + pub fn backlog_snapshot(&self) -> Vec { + match self.backlog.lock() { + Ok(backlog) => backlog.iter().cloned().collect(), + Err(poisoned) => poisoned.get_ref().iter().cloned().collect(), + } + } + + /// Changes the verbosity applied to the application's own records, + /// effective immediately for subsequent records. + pub fn set_level(&self, level: LevelFilter) { + self.level.store(level as usize, Ordering::Relaxed); + } + + fn app_level(&self) -> LevelFilter { + level_filter_from_usize(self.level.load(Ordering::Relaxed)) + } + + fn record(&self, entry: LogEntry) { + if let Ok(mut backlog) = self.backlog.lock() { + if backlog.len() == BACKLOG_CAPACITY { + backlog.pop_front(); + } + backlog.push_back(entry.clone()); + } + // A send error only means there are no live subscribers; the backlog + // still captured the record, so the error is intentionally ignored. + let _ = self.sender.send(entry); + } +} + +fn level_filter_from_usize(value: usize) -> LevelFilter { + match value { + 0 => LevelFilter::Off, + 1 => LevelFilter::Error, + 2 => LevelFilter::Warn, + 3 => LevelFilter::Info, + 4 => LevelFilter::Debug, + _ => LevelFilter::Trace, + } +} + +fn is_app_target(target: &str) -> bool { + target == APP_TARGET || target.starts_with(concat!("privaxy", "::")) +} + +struct BroadcastLogger { + /// Pass-through formatter/writer for the application's own records; its own + /// filter is wide open so the dynamic level is the sole gate. + app_writer: env_logger::Logger, + /// `RUST_LOG`-derived formatter/filter for dependency records. + dependency_logger: env_logger::Logger, + handle: LogHandle, +} + +impl BroadcastLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + if is_app_target(metadata.target()) { + metadata.level() <= self.handle.app_level() + } else { + self.dependency_logger.enabled(metadata) + } + } +} + +impl Log for BroadcastLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + self.enabled(metadata) + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + if is_app_target(record.target()) { + self.app_writer.log(record); + } else { + self.dependency_logger.log(record); + } + + self.handle.record(LogEntry { + now: Utc::now(), + level: record.level().to_string(), + target: record.target().to_string(), + message: record.args().to_string(), + }); + } + + fn flush(&self) { + self.app_writer.flush(); + self.dependency_logger.flush(); + } +} + +/// Installs the global logger and returns a [`LogHandle`] for streaming records +/// to clients and adjusting verbosity at runtime. +/// +/// `initial_level` seeds the application log level (typically from +/// configuration). Dependency records are filtered via `RUST_LOG`, defaulting +/// to [`DEPENDENCY_DEFAULT_LEVEL`]. +/// +/// Must be called exactly once, before any logging occurs. +pub fn init(initial_level: LevelFilter) -> LogHandle { + // Wide-open writer: every application record we hand it is already gated by + // the dynamic level, so its own filter must not drop anything. + let app_writer = env_logger::Builder::new() + .filter_level(LevelFilter::Trace) + .build(); + + // Dependency records keep RUST_LOG behaviour on top of a quiet default. + let dependency_logger = env_logger::Builder::new() + .filter_level(DEPENDENCY_DEFAULT_LEVEL) + .parse_default_env() + .build(); + + let (sender, _receiver) = broadcast::channel(CHANNEL_CAPACITY); + let handle = LogHandle { + sender, + backlog: Arc::new(Mutex::new(VecDeque::with_capacity(BACKLOG_CAPACITY))), + level: Arc::new(AtomicUsize::new(initial_level as usize)), + }; + + let logger = BroadcastLogger { + app_writer, + dependency_logger, + handle: handle.clone(), + }; + + log::set_boxed_logger(Box::new(logger)).expect("failed to install global logger"); + // Keep the facade permissive so the dynamic level can be raised to Trace at + // runtime; the wrapper performs the actual per-record gating. + log::set_max_level(LevelFilter::Trace); + + handle +} diff --git a/privaxy/src/server/main.rs b/privaxy/src/server/main.rs index 8474af7a..afb33946 100644 --- a/privaxy/src/server/main.rs +++ b/privaxy/src/server/main.rs @@ -9,8 +9,6 @@ async fn main() { std::env::set_var(RUST_LOG_ENV_KEY, "privaxy=info"); } - env_logger::init(); - start_privaxy().await; loop { diff --git a/privaxy/src/server/web_gui/logs.rs b/privaxy/src/server/web_gui/logs.rs new file mode 100644 index 00000000..5b664af2 --- /dev/null +++ b/privaxy/src/server/web_gui/logs.rs @@ -0,0 +1,39 @@ +use crate::logging::LogHandle; +use futures::{SinkExt, StreamExt}; +use tokio::sync::broadcast::error::RecvError; +use warp::ws::{Message, WebSocket}; + +pub(super) async fn logs(websocket: WebSocket, log_handle: LogHandle) { + let mut receiver = log_handle.sender.subscribe(); + + let (mut tx, mut rx) = websocket.split(); + + // To handle Ping / Pong messages + tokio::spawn(async move { while let Some(_message) = rx.next().await {} }); + + // Prime the client with recently retained records so the view isn't empty + // until the next record is emitted. + for entry in log_handle.backlog_snapshot() { + let message = Message::text(serde_json::to_string(&entry).unwrap()); + + if tx.send(message).await.is_err() { + return; + } + } + + loop { + match receiver.recv().await { + Ok(entry) => { + let message = Message::text(serde_json::to_string(&entry).unwrap()); + + if tx.send(message).await.is_err() { + break; + } + } + // The subscriber fell behind and the channel dropped records; keep + // streaming from the most recent retained record. + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } +} diff --git a/privaxy/src/server/web_gui/mod.rs b/privaxy/src/server/web_gui/mod.rs index 45012adf..0c0cb0c2 100644 --- a/privaxy/src/server/web_gui/mod.rs +++ b/privaxy/src/server/web_gui/mod.rs @@ -1,3 +1,4 @@ +use crate::logging::LogHandle; use crate::proxy::exclusions::LocalExclusionStore; use crate::statistics::Statistics; use crate::WEBAPP_FRONTEND_DIR; @@ -18,6 +19,7 @@ pub(crate) mod events; pub(crate) mod exclusions; mod filterlists; pub(crate) mod filters; +pub(crate) mod logs; mod pac; pub(crate) mod settings; pub(crate) mod statistics; @@ -34,6 +36,7 @@ pub(crate) fn get_frontend( configuration_save_lock: &Arc>, local_exclusions_store: &LocalExclusionStore, notify_reload: Arc, + log_handle: LogHandle, ) -> BoxedFilter<(impl warp::Reply,)> { let static_files_routes = create_static_routes(); @@ -57,6 +60,7 @@ pub(crate) fn get_frontend( local_exclusions_store, http_client, notify_reload, + log_handle, ); let pac_route = pac::create_routes(configuration_save_lock.clone()); @@ -106,6 +110,7 @@ fn create_api_routes( local_exclusions_store: &LocalExclusionStore, http_client: reqwest::Client, notify_reload: Arc, + log_handle: LogHandle, ) -> BoxedFilter<(impl Reply,)> { let def_headers = warp::filters::reply::default_header(http::header::CONTENT_TYPE, "application/json"); @@ -134,6 +139,15 @@ fn create_api_routes( ws.on_upgrade(move |websocket| statistics::statistics(websocket, statistics)) }); + let logs_handle = log_handle.clone(); + let logs_route = warp::path("logs") + .and(require_auth.clone()) + .and(warp::ws()) + .map(move |ws: warp::ws::Ws| { + let log_handle = logs_handle.clone(); + ws.on_upgrade(move |websocket| logs::logs(websocket, log_handle)) + }); + let filters_route = warp::path("filters") .and(require_auth.clone()) @@ -167,6 +181,7 @@ fn create_api_routes( configuration_updater_sender.clone(), configuration_save_lock.clone(), notify_reload.clone(), + log_handle.clone(), )); let blocking_enabled_route = warp::path("blocking-enabled") @@ -189,6 +204,7 @@ fn create_api_routes( let api_inner = auth_routes .or(events_route) .or(statistics_route) + .or(logs_route) .or(filters_route) .or(custom_filters_route) .or(exclusions_route) diff --git a/privaxy/src/server/web_gui/settings/debug.rs b/privaxy/src/server/web_gui/settings/debug.rs index da08cb5b..fb704993 100644 --- a/privaxy/src/server/web_gui/settings/debug.rs +++ b/privaxy/src/server/web_gui/settings/debug.rs @@ -1,5 +1,6 @@ use super::get_error_response; use crate::configuration::{Configuration, DebugConfig}; +use crate::logging::LogHandle; use crate::web_gui::with_configuration_save_lock; use crate::web_gui::with_configuration_updater_sender; use crate::web_gui::with_notify_reload; @@ -11,6 +12,12 @@ use warp::filters::BoxedFilter; use warp::http::Response; use warp::Filter as RouteFilter; +fn with_log_handle( + log_handle: LogHandle, +) -> impl RouteFilter + Clone { + warp::any().map(move || log_handle.clone()) +} + async fn get_debug_settings() -> Result, Infallible> { log::debug!("Getting debug settings"); let configuration = match Configuration::read_from_home().await { @@ -28,6 +35,7 @@ async fn put_debug_settings( configuration_updater_sender: Sender, configuration_save_lock: Arc>, notify_reload: Arc, + log_handle: LogHandle, ) -> Result, Infallible> { let guard = configuration_save_lock.lock().await; let mut configuration = match Configuration::read_from_home().await { @@ -38,6 +46,12 @@ async fn put_debug_settings( } }; + // The log level is applied live below, but the proxy only reads + // `scriptlet_console_logging` when it (re)starts, so only that toggle + // warrants the disruptive reload. + let scriptlet_logging_changed = + configuration.debug.scriptlet_console_logging != debug_settings.scriptlet_console_logging; + configuration.debug = debug_settings; if let Err(err) = configuration.save().await { @@ -51,9 +65,15 @@ async fn put_debug_settings( .unwrap(); drop(guard); - // The proxy reads `debug.scriptlet_console_logging` when it (re)starts, so a - // reload is what makes the toggle take effect on newly served pages. - notify_reload.notify_waiters(); + // Apply the log level immediately rather than waiting for the next + // process restart; verbosity is governed by the live atomic in LogHandle. + log_handle.set_level(configuration.debug.log_level.to_level_filter()); + + // Avoid bouncing the proxy/frontend (and dropping live connections) for a + // log-level-only change, which already took effect above. + if scriptlet_logging_changed { + notify_reload.notify_waiters(); + } Ok(Box::new( Response::builder() @@ -66,6 +86,7 @@ pub(super) fn create_routes( configuration_updater_sender: Sender, configuration_save_lock: Arc>, notify_reload: Arc, + log_handle: LogHandle, ) -> BoxedFilter<(impl warp::Reply,)> { let get_route = warp::get() .and(warp::path::end()) @@ -81,6 +102,7 @@ pub(super) fn create_routes( configuration_save_lock.clone(), )) .and(with_notify_reload(notify_reload.clone())) + .and(with_log_handle(log_handle)) .and_then(put_debug_settings); get_route.or(put_route).boxed() diff --git a/privaxy/src/server/web_gui/settings/mod.rs b/privaxy/src/server/web_gui/settings/mod.rs index 06b5e50f..2e79e12f 100644 --- a/privaxy/src/server/web_gui/settings/mod.rs +++ b/privaxy/src/server/web_gui/settings/mod.rs @@ -1,5 +1,6 @@ use super::get_error_response; use crate::configuration::Configuration; +use crate::logging::LogHandle; use std::sync::Arc; use tokio::sync::mpsc::Sender; use tokio::sync::Notify; @@ -15,6 +16,7 @@ pub(crate) fn create_routes( configuration_updater_sender: Sender, configuration_save_lock: Arc>, notify_reload: Arc, + log_handle: LogHandle, ) -> BoxedFilter<(impl warp::Reply,)> { let network_settings_route = warp::path("network").and(network::create_routes( configuration_updater_sender.clone(), @@ -38,6 +40,7 @@ pub(crate) fn create_routes( configuration_updater_sender.clone(), configuration_save_lock.clone(), notify_reload.clone(), + log_handle, )); network_settings_route diff --git a/web_frontend/src/debug.rs b/web_frontend/src/debug.rs index e065affe..8dea556b 100644 --- a/web_frontend/src/debug.rs +++ b/web_frontend/src/debug.rs @@ -1,20 +1,41 @@ +use crate::logs::LogStream; use reqwasm::http::Request; use serde::{Deserialize, Serialize}; use wasm_bindgen_futures::spawn_local; -use web_sys::HtmlInputElement; +use web_sys::{HtmlInputElement, HtmlSelectElement}; use yew::prelude::*; use yew::{html, Component, Context, Html}; +/// Selectable log verbosities, matching the backend `logging::LogLevel` +/// (serialized lowercase). +const LOG_LEVELS: [&str; 6] = ["off", "error", "warn", "info", "debug", "trace"]; + +fn default_log_level() -> String { + "info".to_string() +} + /// Mirrors the backend `configuration::DebugConfig`. -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct DebugConfig { #[serde(default)] pub scriptlet_console_logging: bool, + #[serde(default = "default_log_level")] + pub log_level: String, +} + +impl Default for DebugConfig { + fn default() -> Self { + Self { + scriptlet_console_logging: false, + log_level: default_log_level(), + } + } } pub enum Message { Loaded(DebugConfig), ToggleScriptletLogging(bool), + SetLogLevel(String), SaveSucceeded(DebugConfig), SaveFailed(String), } @@ -65,31 +86,14 @@ impl Component for DebugSettingsPage { // triggers a backend reload, so it takes effect on newly served // pages. self.config.scriptlet_console_logging = value; - self.saving = true; - self.saved = false; - self.error = None; - let config = self.config.clone(); - let link = ctx.link().clone(); - spawn_local(async move { - let body = serde_json::to_string(&config).unwrap(); - match Request::put("/api/settings/debug") - .header("Content-Type", "application/json") - .body(body) - .send() - .await - { - Ok(response) if response.ok() => { - link.send_message(Message::SaveSucceeded(config)); - } - Ok(response) => { - link.send_message(Message::SaveFailed(format!( - "Failed to save (HTTP {})", - response.status() - ))); - } - Err(err) => link.send_message(Message::SaveFailed(format!("{err}"))), - } - }); + self.persist(ctx); + true + } + Message::SetLogLevel(level) => { + // The backend applies the level live (no reload needed) on the + // PUT; we persist it so it survives restarts. + self.config.log_level = level; + self.persist(ctx); true } Message::SaveSucceeded(config) => { @@ -126,6 +130,12 @@ impl Component for DebugSettingsPage { html! {} }; + let on_level_change = ctx.link().callback(|e: Event| { + let select: HtmlSelectElement = e.target_unchecked_into(); + Message::SetLogLevel(select.value()) + }); + let current_level = self.config.log_level.clone(); + html! { <>
@@ -148,7 +158,7 @@ impl Component for DebugSettingsPage { type="checkbox" class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" /> - { status } + { status.clone() }
@@ -164,7 +174,75 @@ impl Component for DebugSettingsPage {
+ +
+ { "Logging" } +
+
+
+
+ { "Log level" } +
+
+ + { status } +
+
+
+

+ { "Verbosity of Privaxy's own logs, applied immediately and persisted. Dependency logs stay governed by the " } + { "RUST_LOG" } + { " environment variable." } +

+
+
+
+
+ + } } } + +impl DebugSettingsPage { + /// Persists the current config to the backend, reflecting save state via + /// `SaveSucceeded`/`SaveFailed` messages. + fn persist(&mut self, ctx: &Context) { + self.saving = true; + self.saved = false; + self.error = None; + + let config = self.config.clone(); + let link = ctx.link().clone(); + spawn_local(async move { + let body = serde_json::to_string(&config).unwrap(); + match Request::put("/api/settings/debug") + .header("Content-Type", "application/json") + .body(body) + .send() + .await + { + Ok(response) if response.ok() => { + link.send_message(Message::SaveSucceeded(config)); + } + Ok(response) => { + link.send_message(Message::SaveFailed(format!( + "Failed to save (HTTP {})", + response.status() + ))); + } + Err(err) => link.send_message(Message::SaveFailed(format!("{err}"))), + } + }); + } +} diff --git a/web_frontend/src/logs.rs b/web_frontend/src/logs.rs new file mode 100644 index 00000000..0eedb02c --- /dev/null +++ b/web_frontend/src/logs.rs @@ -0,0 +1,255 @@ +use futures::future::{AbortHandle, Abortable}; +use futures::StreamExt; +use reqwasm::websocket::futures::WebSocket; +use serde::Deserialize; +use wasm_bindgen_futures::spawn_local; +use web_sys::HtmlSelectElement; +use yew::{html, Component, Context, Html, TargetCast}; + +/// Upper bound on retained rows in the browser, mirroring the requests feed so +/// a long-running session can't grow unbounded. +const MAX_LOGS_SHOWN: usize = 1000; + +/// Mirrors the backend `logging::LogEntry`. +#[derive(Deserialize, Clone, PartialEq)] +pub struct LogEntry { + now: String, + level: String, + target: String, + message: String, +} + +impl LogEntry { + /// Severity rank, lower is more severe. Unknown levels sort last so they + /// are only hidden by the most permissive ("All") filter. + fn severity(&self) -> u8 { + match self.level.as_str() { + "ERROR" => 0, + "WARN" => 1, + "INFO" => 2, + "DEBUG" => 3, + "TRACE" => 4, + _ => 5, + } + } +} + +/// Minimum severity selected in the level dropdown. +#[derive(Clone, Copy, PartialEq)] +enum LevelFilter { + All, + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LevelFilter { + fn from_value(value: &str) -> Self { + match value { + "ERROR" => Self::Error, + "WARN" => Self::Warn, + "INFO" => Self::Info, + "DEBUG" => Self::Debug, + "TRACE" => Self::Trace, + _ => Self::All, + } + } + + /// Highest severity rank still shown. `All` keeps everything, including + /// unknown levels. + fn max_rank(&self) -> u8 { + match self { + Self::All => u8::MAX, + Self::Error => 0, + Self::Warn => 1, + Self::Info => 2, + Self::Debug => 3, + Self::Trace => 4, + } + } +} + +pub enum Message { + Received(LogEntry), + SetFilter(LevelFilter), + TogglePause, + Clear, +} + +pub struct LogStream { + entries: Vec, + filter: LevelFilter, + paused: bool, + ws_abort_handle: AbortHandle, +} + +impl Component for LogStream { + type Message = Message; + type Properties = (); + + fn create(ctx: &Context) -> Self { + let message_callback = ctx.link().callback(Message::Received); + + let ws = WebSocket::open("/api/logs").unwrap(); + let (_write, mut read) = ws.split(); + + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let future = Abortable::new( + async move { + while let Some(Ok(msg)) = read.next().await { + let entry = match msg { + reqwasm::websocket::Message::Text(s) => { + serde_json::from_str::(&s).unwrap() + } + reqwasm::websocket::Message::Bytes(_) => unreachable!(), + }; + + message_callback.emit(entry); + } + }, + abort_registration, + ); + + spawn_local(async { + let _result = future.await; + }); + + Self { + entries: Vec::new(), + filter: LevelFilter::All, + paused: false, + ws_abort_handle: abort_handle, + } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + match msg { + Message::Received(entry) => { + // While paused the live view is frozen; incoming records are + // dropped rather than buffered. + if self.paused { + return false; + } + + self.entries.insert(0, entry); + self.entries.truncate(MAX_LOGS_SHOWN); + true + } + Message::SetFilter(filter) => { + self.filter = filter; + true + } + Message::TogglePause => { + self.paused = !self.paused; + true + } + Message::Clear => { + self.entries.clear(); + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let link = ctx.link(); + + let on_filter_change = link.callback(|e: yew::events::Event| { + let select: HtmlSelectElement = e.target_unchecked_into(); + Message::SetFilter(LevelFilter::from_value(&select.value())) + }); + let on_toggle_pause = link.callback(|_| Message::TogglePause); + let on_clear = link.callback(|_| Message::Clear); + + let max_rank = self.filter.max_rank(); + let visible = self + .entries + .iter() + .filter(|entry| entry.severity() <= max_rank); + + let pause_label = if self.paused { "Resume" } else { "Pause" }; + let pause_classes = if self.paused { + "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700" + } else { + "inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" + }; + + html! { +
+ + { "Live logs" } + if !self.paused { +
+ } +
+

+ { "Streams the server's log output (controlled by " } + { "RUST_LOG" } + { "). Filtering and pausing are local to this view and don't affect what the server records." } +

+ +
+ + + + +
+ +
+ + + { for visible.map(Self::render_row) } + +
+
+
+ } + } + + fn destroy(&mut self, _ctx: &Context) { + self.ws_abort_handle.abort() + } +} + +impl LogStream { + fn render_row(entry: &LogEntry) -> Html { + let level_classes = match entry.level.as_str() { + "ERROR" => "text-red-400", + "WARN" => "text-yellow-400", + "INFO" => "text-green-400", + "DEBUG" => "text-blue-400", + "TRACE" => "text-gray-400", + _ => "text-gray-300", + }; + + html! { + + { &entry.now } + { &entry.level } + { &entry.target } + { &entry.message } + + } + } +} + +fn classes_for_level(level_classes: &str) -> String { + format!("px-3 py-1 whitespace-nowrap font-semibold {level_classes}") +} diff --git a/web_frontend/src/main.rs b/web_frontend/src/main.rs index d6c8bc3b..1908c072 100644 --- a/web_frontend/src/main.rs +++ b/web_frontend/src/main.rs @@ -14,6 +14,7 @@ mod debug; mod filterlists; mod filters; mod general; +mod logs; mod pac; mod requests; mod save_button;