diff --git a/Cargo.lock b/Cargo.lock index a23beaa9..f837cab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2471,6 +2471,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "file-rotate" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3ed82142801f5b1363f7d463963d114db80f467e860b1cd82228eaebc627a0" +dependencies = [ + "chrono", + "flate2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -4643,6 +4653,7 @@ dependencies = [ "dotenvy", "extism", "fastcdc", + "file-rotate", "flate2", "fs2", "futures", @@ -6945,6 +6956,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 09a2e9eb..1d00c362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ mimalloc = { version = "0.1.52", default-features = false } axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs"] } tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] } -tokio-stream = { version = "0.1.18", features = ["fs"] } +tokio-stream = { version = "0.1.18", features = ["fs", "sync"] } bytes = "1.11.1" tempfile = "3.27.0" tower = "0.5.3" @@ -80,6 +80,7 @@ pdf-extract = "0.10.0" nom-exif = "3.6.1" extism = { version = "1.30.0", optional = true } toml = { version = "1.1.2", optional = true } +file-rotate = { version = "0.7.6", optional = true } [features] default = [] @@ -87,7 +88,7 @@ test_utils = ["mockall"] integration_tests = [] # WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most # deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true. -plugins = ["dep:extism", "dep:toml"] +plugins = ["dep:extism", "dep:toml", "dep:file-rotate"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } diff --git a/src/application/dtos/plugin_dto.rs b/src/application/dtos/plugin_dto.rs index 7e687bb5..76d48b8f 100644 --- a/src/application/dtos/plugin_dto.rs +++ b/src/application/dtos/plugin_dto.rs @@ -1,9 +1,9 @@ //! DTOs for the admin plugin-management API. use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; -use crate::application::ports::plugin_ports::PluginInfo; +use crate::application::ports::plugin_ports::{LogEntry, LogPage, PluginInfo, RetentionSettings}; /// A single installed plugin as returned by `GET /api/admin/plugins`. #[derive(Debug, Clone, Serialize, ToSchema)] @@ -35,3 +35,97 @@ impl From for PluginInfoDto { pub struct SetEnabledDto { pub enabled: bool, } + +/// A single structured log entry as returned by the admin log viewer / stream. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PluginLogEntryDto { + /// RFC 3339 timestamp. + pub ts: String, + pub invocation_id: String, + /// `"plugin"` (plugin-emitted line) or `"outcome"` (host invocation result). + pub kind: String, + /// `debug` | `info` | `warn` | `error`. + pub level: String, + /// Stable outcome key for `kind = "outcome"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub msg: String, +} + +impl From for PluginLogEntryDto { + fn from(e: LogEntry) -> Self { + Self { + ts: e.ts, + invocation_id: e.invocation_id, + kind: e.kind, + level: e.level, + reason: e.reason, + msg: e.msg, + } + } +} + +/// One page of log entries, newest first. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PluginLogPageDto { + pub entries: Vec, + /// Total entries matching the filter (across all pages). + pub total: usize, + pub limit: usize, + pub offset: usize, +} + +impl PluginLogPageDto { + pub fn from_page(page: LogPage, limit: usize, offset: usize) -> Self { + Self { + entries: page + .entries + .into_iter() + .map(PluginLogEntryDto::from) + .collect(), + total: page.total, + limit, + offset, + } + } +} + +/// Query string for `GET /api/admin/plugins/{id}/logs`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct PluginLogQueryDto { + /// Keep only entries at this level (`debug`/`info`/`warn`/`error`). + pub level: Option, + /// Case-insensitive substring filter on the message. + pub search: Option, + /// Max entries to return (clamped server-side). + pub limit: Option, + /// Newest-first entries to skip. + pub offset: Option, +} + +/// Per-plugin retention policy (request + response body). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +pub struct PluginRetentionDto { + /// Delete rotated segments older than this many days. + pub retention_days: u32, + /// Aggregate byte ceiling on kept segments for the plugin. + pub max_bytes: u64, +} + +impl From for PluginRetentionDto { + fn from(s: RetentionSettings) -> Self { + Self { + retention_days: s.retention_days, + max_bytes: s.max_bytes, + } + } +} + +impl From for RetentionSettings { + fn from(d: PluginRetentionDto) -> Self { + Self { + retention_days: d.retention_days, + max_bytes: d.max_bytes, + } + } +} diff --git a/src/application/ports/plugin_ports.rs b/src/application/ports/plugin_ports.rs index 27505440..4108cf69 100644 --- a/src/application/ports/plugin_ports.rs +++ b/src/application/ports/plugin_ports.rs @@ -12,7 +12,9 @@ //! `on_user_login`; //! - one host import `log` (observe-only — the only authority a plugin has). +use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; /// The single ABI version this host speaks. A breaking change bumps this and /// the namespace suffix ([`HOST_NAMESPACE`]); plugins built against a different @@ -61,6 +63,7 @@ pub trait PluginDispatchPort: Send + Sync + 'static { /// `ExtismPluginManager`) owns the same in-memory plugin set the dispatch port /// reads, so a toggle or install takes effect on the live dispatch path with no /// restart. All operations are admin-gated at the HTTP layer. +#[async_trait] pub trait PluginManagementPort: Send + Sync + 'static { /// Every installed plugin, enabled or not, with its load-time metadata. fn list(&self) -> Vec; @@ -83,6 +86,91 @@ pub trait PluginManagementPort: Send + Sync + 'static { /// Unload a plugin and delete its directory. fn remove(&self, id: &str) -> Result<(), PluginMgmtError>; + + /// Read a filtered, paginated page of a plugin's structured log entries + /// (newest first). `NotFound` if no such plugin is installed. + async fn read_logs(&self, id: &str, query: LogQuery) -> Result; + + /// Delete all persisted log files for a plugin (keeps the plugin installed). + async fn clear_logs(&self, id: &str) -> Result<(), PluginMgmtError>; + + /// The plugin's effective per-plugin retention (its on-disk override, or the + /// configured defaults when none is set). + async fn get_retention(&self, id: &str) -> Result; + + /// Persist a per-plugin retention override (age + aggregate size). + async fn set_retention( + &self, + id: &str, + settings: RetentionSettings, + ) -> Result<(), PluginMgmtError>; + + /// Subscribe to newly-written log entries across *all* plugins, for live + /// tailing. Callers filter by `plugin_id`. A lagging receiver loses the + /// oldest buffered events (`RecvError::Lagged`) but never blocks the writer. + fn subscribe_logs(&self) -> broadcast::Receiver; +} + +/// A single structured log entry — both the on-disk JSONL row and the unit the +/// admin viewer / live stream surfaces. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + /// RFC 3339 timestamp the entry was recorded at. + pub ts: String, + /// The dispatch invocation this entry belongs to (correlates lines with the + /// outcome row of the same invocation). + pub invocation_id: String, + /// `"plugin"` for a line the plugin emitted via `log`, `"outcome"` for the + /// host's record of how the invocation ended. + pub kind: String, + /// `debug` | `info` | `warn` | `error`. + pub level: String, + /// Stable outcome key (`InvokeOutcome::reason()`) for `kind = "outcome"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Human-readable message. + pub msg: String, +} + +/// Filter + pagination for [`PluginManagementPort::read_logs`]. +#[derive(Debug, Clone, Default)] +pub struct LogQuery { + /// Keep only entries at this level (exact match) when set. + pub level: Option, + /// Keep only entries whose message contains this substring (case-insensitive). + pub search: Option, + /// Number of newest-first entries to skip. + pub offset: usize, + /// Maximum number of entries to return. + pub limit: usize, +} + +/// One page of log entries plus the total number matching the filter. +#[derive(Debug, Clone)] +pub struct LogPage { + /// Entries for this page, newest first. + pub entries: Vec, + /// Total entries matching the filter (across all pages). + pub total: usize, +} + +/// Per-plugin log retention policy. Persisted next to the plugin's logs. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct RetentionSettings { + /// Delete rotated segments older than this many days. + pub retention_days: u32, + /// Aggregate byte ceiling on kept segments for the plugin (oldest deleted + /// first past this). + pub max_bytes: u64, +} + +/// A newly-written entry published on the live-tail broadcast channel. +#[derive(Debug, Clone)] +pub struct PluginLogEvent { + /// The plugin the entry belongs to (subscribers filter on this). + pub plugin_id: String, + /// The entry itself. + pub entry: LogEntry, } /// A single installed plugin's load-time metadata, as surfaced to the admin UI. diff --git a/src/common/config.rs b/src/common/config.rs index a4a4c7eb..ea7015ba 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -961,6 +961,25 @@ pub struct PluginConfig { /// Hard cap on the serialized event payload handed to a plugin. Default: /// 256 KiB. Env: `OXICLOUD_PLUGIN_MAX_INPUT_BYTES`. pub max_input_bytes: usize, + /// Directory under which per-plugin log files live (one subdir per plugin id, + /// holding `events.jsonl` + rotated `events.jsonl..gz` + `retention.json`). + /// Default: `{storage_path}/.plugin-logs`. Env: `OXICLOUD_PLUGIN_LOG_DIR`. + pub log_dir: Option, + /// Size at which a plugin's active `events.jsonl` is rotated into a new gzip + /// segment. Default: 5 MiB. Env: `OXICLOUD_PLUGIN_LOG_MAX_FILE_BYTES`. + pub log_max_file_bytes: u64, + /// Coarse ceiling on the number of rotated `.gz` segments kept per plugin + /// (file-rotate `FileLimit::MaxFiles`); the real limits are the per-plugin + /// retention sweep. Default: 10. Env: `OXICLOUD_PLUGIN_LOG_MAX_SEGMENTS`. + pub log_max_segments: u32, + /// Default age (in days) past which a plugin's rotated log segments are + /// pruned by the maintenance sweep. Overridable per plugin via its + /// `retention.json`. Default: 30. Env: `OXICLOUD_PLUGIN_LOG_RETENTION_DAYS`. + pub log_retention_days: u32, + /// Default aggregate byte cap on kept log segments for a single plugin; the + /// sweep deletes oldest-first past this. Overridable per plugin. Default: + /// 256 MiB. Env: `OXICLOUD_PLUGIN_LOG_TOTAL_MAX_BYTES`. + pub log_total_max_bytes: u64, } impl Default for PluginConfig { @@ -971,6 +990,11 @@ impl Default for PluginConfig { invocation_timeout_ms: 250, max_memory_pages: 256, max_input_bytes: 256 * 1024, + log_dir: None, + log_max_file_bytes: 5 * 1024 * 1024, + log_max_segments: 10, + log_retention_days: 30, + log_total_max_bytes: 256 * 1024 * 1024, } } } @@ -1386,6 +1410,31 @@ impl AppConfig { { config.plugins.max_input_bytes = val; } + if let Ok(dir) = env::var("OXICLOUD_PLUGIN_LOG_DIR") + && !dir.trim().is_empty() + { + config.plugins.log_dir = Some(PathBuf::from(dir.trim())); + } + if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_MAX_FILE_BYTES").map(|v| v.parse::()) + && let Ok(val) = v + { + config.plugins.log_max_file_bytes = val; + } + if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_MAX_SEGMENTS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.plugins.log_max_segments = val; + } + if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_RETENTION_DAYS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.plugins.log_retention_days = val; + } + if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_TOTAL_MAX_BYTES").map(|v| v.parse::()) + && let Ok(val) = v + { + config.plugins.log_total_max_bytes = val; + } if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::()) && let Ok(val) = v diff --git a/src/common/di.rs b/src/common/di.rs index 1898e811..e011f2e7 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -613,9 +613,18 @@ impl AppServiceFactory { .plugins_dir .clone() .unwrap_or_else(|| self.config.storage_path.join(".plugins")); + + // Resolve the log root to a sibling of the plugins dir by default + // so an uninstall never wipes another plugin's logs, and pass it + // into the manager via the config it owns. + let mut plugin_config = self.config.plugins.clone(); + if plugin_config.log_dir.is_none() { + plugin_config.log_dir = Some(self.config.storage_path.join(".plugin-logs")); + } + let manager = Arc::new( crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir( - self.config.plugins.clone(), + plugin_config, &dir, ), ); @@ -624,11 +633,23 @@ impl AppServiceFactory { loaded = manager.loaded_count(), "plugin manager initialized" ); + let dispatch: Arc = manager.clone(); let management: Arc< dyn crate::application::ports::plugin_ports::PluginManagementPort, - > = manager; + > = manager.clone(); + + // Background maintenance: prune each plugin's rotated log + // segments by age + aggregate size on a schedule. Depends only on + // the log store + the management port, so no special ordering. + crate::infrastructure::services::plugins::PluginLogMaintenanceService::new( + manager.log_store(), + management.clone(), + 6, // hours between sweeps + ) + .start(); + return (Some(dispatch), Some(management)); } } diff --git a/src/infrastructure/services/plugins/log_retention_service.rs b/src/infrastructure/services/plugins/log_retention_service.rs new file mode 100644 index 00000000..b4758123 --- /dev/null +++ b/src/infrastructure/services/plugins/log_retention_service.rs @@ -0,0 +1,70 @@ +//! Background plugin-log maintenance: a periodic sweep that prunes each +//! installed plugin's rotated log segments by age + aggregate size. +//! +//! Modeled on [`crate::infrastructure::services::trash_cleanup_service`]: a +//! single spawned task on a fixed interval, an immediate first run, and +//! log-and-continue on error. `file-rotate` already compresses + caps segment +//! *count* at write time; this is the only thing that enforces the per-plugin +//! age/byte retention and the only thing that ever prunes *idle* plugins (which +//! never trigger a write-time rotation). + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use tokio::time; +use tracing::{debug, info}; + +use super::log_store::PluginLogStore; +use crate::application::ports::plugin_ports::PluginManagementPort; + +/// Periodically sweeps every installed plugin's logs against its retention. +pub struct PluginLogMaintenanceService { + log_store: Arc, + manager: Arc, + interval_hours: u64, +} + +impl PluginLogMaintenanceService { + pub fn new( + log_store: Arc, + manager: Arc, + interval_hours: u64, + ) -> Self { + Self { + log_store, + manager, + interval_hours: interval_hours.max(1), + } + } + + /// Spawn the periodic sweep task. + pub fn start(&self) { + let log_store = self.log_store.clone(); + let manager = self.manager.clone(); + let interval_hours = self.interval_hours; + + info!( + "Starting plugin log maintenance job with interval of {} hours", + interval_hours + ); + + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(interval_hours * 60 * 60)); + // First tick fires immediately. + loop { + interval.tick().await; + Self::sweep_all(&log_store, &manager).await; + } + }); + } + + async fn sweep_all(log_store: &PluginLogStore, manager: &Arc) { + let now = Utc::now(); + let plugins = manager.list(); + debug!("Plugin log sweep over {} plugin(s)", plugins.len()); + for plugin in plugins { + log_store.request_sweep(&plugin.id, now).await; + } + } +} diff --git a/src/infrastructure/services/plugins/log_store.rs b/src/infrastructure/services/plugins/log_store.rs new file mode 100644 index 00000000..ab216cdb --- /dev/null +++ b/src/infrastructure/services/plugins/log_store.rs @@ -0,0 +1,730 @@ +//! Per-plugin structured log storage — an async, in-order actor over disk files. +//! +//! Every plugin gets its own directory under the log root: +//! ```text +//! {root}/{plugin_id}/events.jsonl # active (file-rotate writes here) +//! {root}/{plugin_id}/events.jsonl..gz # rotated + gzip'd (immutable) +//! {root}/{plugin_id}/retention.json # per-plugin retention override +//! ``` +//! +//! **Async + strictly in order.** All file mutations funnel through a single +//! background thread that owns the per-plugin [`FileRotate`] writers. Because +//! there is exactly one consumer draining one channel FIFO, batches land in +//! enqueue order with no locks, and the dispatch path never blocks on IO — it +//! just sends. Rotation, gzip-on-rotate and a coarse segment ceiling are handled +//! by `file-rotate`; per-plugin age + aggregate-byte retention is the [`sweep`] +//! (run on a schedule), the only thing that ever prunes *idle* plugins. +//! +//! [`sweep`]: PluginLogStore::request_sweep + +use std::collections::HashMap; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use chrono::{DateTime, Duration, Utc}; +use file_rotate::{ + ContentLimit, FileRotate, + compression::Compression, + suffix::{AppendTimestamp, FileLimit}, +}; +use flate2::read::GzDecoder; +use tokio::sync::{broadcast, mpsc, oneshot}; + +use super::runtime::InvokeOutcome; +use crate::application::ports::plugin_ports::{ + LogEntry, LogPage, LogQuery, PluginLogEvent, RetentionSettings, +}; + +/// Name of the active (uncompressed) log file inside a plugin's log dir. +const ACTIVE_FILE: &str = "events.jsonl"; +/// Marker file holding a plugin's retention override. +const RETENTION_FILE: &str = "retention.json"; +/// Bounded command-channel depth — backpressure under flood, not unbounded RAM. +const CHANNEL_CAPACITY: usize = 1024; +/// Live broadcast buffer; a slow tailer past this gets `Lagged` (never blocks). +const LIVE_CAPACITY: usize = 256; + +/// Commands processed in receipt order by the single actor thread. +enum LogCommand { + Append { + plugin_id: String, + entries: Vec, + }, + Read { + plugin_id: String, + query: LogQuery, + reply: oneshot::Sender, + }, + Clear { + plugin_id: String, + reply: oneshot::Sender<()>, + }, + Remove { + plugin_id: String, + }, + GetRetention { + plugin_id: String, + reply: oneshot::Sender, + }, + SetRetention { + plugin_id: String, + settings: RetentionSettings, + reply: oneshot::Sender<()>, + }, + Sweep { + plugin_id: String, + now: DateTime, + }, +} + +/// Cheap, cloneable handle to the log actor. Held by the plugin manager and the +/// maintenance task; `subscribe_logs` hands receivers to SSE clients. +pub struct PluginLogStore { + tx: mpsc::Sender, + live: broadcast::Sender, +} + +impl PluginLogStore { + /// Spawn the actor thread and return a handle. `default_retention` is applied + /// to any plugin lacking an explicit `retention.json`. + pub fn new( + root: PathBuf, + max_file_bytes: u64, + max_segments: u32, + default_retention: RetentionSettings, + ) -> Self { + let (tx, rx) = mpsc::channel(CHANNEL_CAPACITY); + let (live, _) = broadcast::channel(LIVE_CAPACITY); + let actor = Actor { + root, + max_file_bytes: max_file_bytes.max(1), + max_segments, + default_retention, + writers: HashMap::new(), + live: live.clone(), + }; + // A dedicated OS thread so the blocking file/gzip IO never stalls a tokio + // worker. `blocking_recv` is valid here (no runtime on this thread). + std::thread::Builder::new() + .name("plugin-log-store".into()) + .spawn(move || actor.run(rx)) + .expect("spawn plugin-log-store thread"); + Self { tx, live } + } + + /// Enqueue a batch (plugin-emitted lines + the host outcome) for one + /// invocation. Called from the dispatch `spawn_blocking` closure, so + /// `blocking_send` is correct: it applies backpressure to that off-runtime + /// thread and preserves order. Send failures (actor gone) are swallowed — + /// logging must never break dispatch. + pub fn append( + &self, + plugin_id: &str, + invocation_id: &str, + lines: &[(String, String)], + outcome: &InvokeOutcome, + ) { + let ts = Utc::now().to_rfc3339(); + let mut entries: Vec = lines + .iter() + .map(|(level, msg)| LogEntry { + ts: ts.clone(), + invocation_id: invocation_id.to_string(), + kind: "plugin".to_string(), + level: level.clone(), + reason: None, + msg: msg.clone(), + }) + .collect(); + let (level, msg) = outcome.log_detail(); + entries.push(LogEntry { + ts, + invocation_id: invocation_id.to_string(), + kind: "outcome".to_string(), + level: level.to_string(), + reason: Some(outcome.reason().to_string()), + msg, + }); + + if let Err(e) = self.tx.blocking_send(LogCommand::Append { + plugin_id: plugin_id.to_string(), + entries, + }) { + tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, + error = %e, + "dropping plugin log batch: log actor unavailable" + ); + } + } + + /// Read a filtered, paginated page of a plugin's entries (newest first). + pub async fn read_page(&self, plugin_id: &str, query: LogQuery) -> LogPage { + let (reply, rx) = oneshot::channel(); + if self + .tx + .send(LogCommand::Read { + plugin_id: plugin_id.to_string(), + query, + reply, + }) + .await + .is_err() + { + return LogPage { + entries: Vec::new(), + total: 0, + }; + } + rx.await.unwrap_or(LogPage { + entries: Vec::new(), + total: 0, + }) + } + + /// Delete a plugin's log files (keeps `retention.json`). + pub async fn clear(&self, plugin_id: &str) { + let (reply, rx) = oneshot::channel(); + if self + .tx + .send(LogCommand::Clear { + plugin_id: plugin_id.to_string(), + reply, + }) + .await + .is_ok() + { + let _ = rx.await; + } + } + + /// Delete a plugin's entire log directory (on uninstall). Fire-and-forget + /// and non-blocking, so it's safe to call from the synchronous management + /// path without stalling an async worker. + pub fn remove_plugin_logs(&self, plugin_id: &str) { + let _ = self.tx.try_send(LogCommand::Remove { + plugin_id: plugin_id.to_string(), + }); + } + + /// The plugin's effective retention (override or configured default). + pub async fn get_retention(&self, plugin_id: &str) -> RetentionSettings { + let (reply, rx) = oneshot::channel(); + if self + .tx + .send(LogCommand::GetRetention { + plugin_id: plugin_id.to_string(), + reply, + }) + .await + .is_ok() + && let Ok(s) = rx.await + { + return s; + } + // Fall back to a conservative default if the actor is gone. + RetentionSettings { + retention_days: 30, + max_bytes: 256 * 1024 * 1024, + } + } + + /// Persist a per-plugin retention override. + pub async fn set_retention(&self, plugin_id: &str, settings: RetentionSettings) { + let (reply, rx) = oneshot::channel(); + if self + .tx + .send(LogCommand::SetRetention { + plugin_id: plugin_id.to_string(), + settings, + reply, + }) + .await + .is_ok() + { + let _ = rx.await; + } + } + + /// Ask the actor to prune a plugin's segments by age + aggregate size. + pub async fn request_sweep(&self, plugin_id: &str, now: DateTime) { + let _ = self + .tx + .send(LogCommand::Sweep { + plugin_id: plugin_id.to_string(), + now, + }) + .await; + } + + /// Subscribe to newly-written entries across all plugins (live tailing). + pub fn subscribe(&self) -> broadcast::Receiver { + self.live.subscribe() + } +} + +/// The single owner of all log-file state. Runs on its own thread. +struct Actor { + root: PathBuf, + max_file_bytes: u64, + max_segments: u32, + default_retention: RetentionSettings, + writers: HashMap>, + live: broadcast::Sender, +} + +impl Actor { + fn run(mut self, mut rx: mpsc::Receiver) { + while let Some(cmd) = rx.blocking_recv() { + match cmd { + LogCommand::Append { plugin_id, entries } => { + self.handle_append(&plugin_id, entries) + } + LogCommand::Read { + plugin_id, + query, + reply, + } => { + let _ = reply.send(self.read_page(&plugin_id, &query)); + } + LogCommand::Clear { plugin_id, reply } => { + self.clear(&plugin_id); + let _ = reply.send(()); + } + LogCommand::Remove { plugin_id } => self.remove(&plugin_id), + LogCommand::GetRetention { plugin_id, reply } => { + let _ = reply.send(self.get_retention(&plugin_id)); + } + LogCommand::SetRetention { + plugin_id, + settings, + reply, + } => { + self.set_retention(&plugin_id, settings); + let _ = reply.send(()); + } + LogCommand::Sweep { plugin_id, now } => self.sweep(&plugin_id, now), + } + } + } + + fn plugin_dir(&self, plugin_id: &str) -> PathBuf { + self.root.join(plugin_id) + } + + /// Lazily build (or fetch) the rotating writer for a plugin. + fn writer_for(&mut self, plugin_id: &str) -> Option<&mut FileRotate> { + if !self.writers.contains_key(plugin_id) { + let path = self.plugin_dir(plugin_id).join(ACTIVE_FILE); + let writer = FileRotate::new( + path, + AppendTimestamp::default(FileLimit::MaxFiles(self.max_segments as usize)), + ContentLimit::BytesSurpassed(self.max_file_bytes as usize), + Compression::OnRotate(0), + #[cfg(unix)] + None, + ); + self.writers.insert(plugin_id.to_string(), writer); + } + self.writers.get_mut(plugin_id) + } + + fn handle_append(&mut self, plugin_id: &str, entries: Vec) { + let mut buf = Vec::new(); + for entry in &entries { + if serde_json::to_writer(&mut buf, entry).is_ok() { + buf.push(b'\n'); + } + } + if let Some(writer) = self.writer_for(plugin_id) + && let Err(e) = writer.write_all(&buf).and_then(|_| writer.flush()) + { + tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, + error = %e, + "failed to write plugin log batch" + ); + return; + } + // Publish only after the durable write, so the live tail never shows an + // entry a subsequent read wouldn't. No subscribers => send is a no-op. + for entry in entries { + let _ = self.live.send(PluginLogEvent { + plugin_id: plugin_id.to_string(), + entry, + }); + } + } + + fn read_page(&self, plugin_id: &str, query: &LogQuery) -> LogPage { + let dir = self.plugin_dir(plugin_id); + // Gather rotated segments oldest→newest (by mtime), then the active file. + let mut segments: Vec<(PathBuf, SystemTime)> = Vec::new(); + let mut active: Option = None; + if let Ok(read_dir) = fs::read_dir(&dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name == ACTIVE_FILE { + active = Some(path); + } else if name.starts_with("events.jsonl.") { + let mtime = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + segments.push((path, mtime)); + } + } + } + segments.sort_by_key(|(_, mtime)| *mtime); + + let mut all: Vec = Vec::new(); + for (path, _) in &segments { + read_entries_into(path, query, &mut all); + } + if let Some(path) = &active { + read_entries_into(path, query, &mut all); + } + + // `all` is chronological (oldest→newest); the viewer wants newest first. + all.reverse(); + let total = all.len(); + let entries = all + .into_iter() + .skip(query.offset) + .take(query.limit) + .collect(); + LogPage { entries, total } + } + + fn clear(&mut self, plugin_id: &str) { + // Drop the open writer first so the active file can be removed cleanly. + self.writers.remove(plugin_id); + let dir = self.plugin_dir(plugin_id); + if let Ok(read_dir) = fs::read_dir(&dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && (name == ACTIVE_FILE || name.starts_with("events.jsonl.")) + { + let _ = fs::remove_file(&path); + } + } + } + } + + fn remove(&mut self, plugin_id: &str) { + self.writers.remove(plugin_id); + let _ = fs::remove_dir_all(self.plugin_dir(plugin_id)); + } + + fn get_retention(&self, plugin_id: &str) -> RetentionSettings { + let path = self.plugin_dir(plugin_id).join(RETENTION_FILE); + fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(self.default_retention) + } + + fn set_retention(&self, plugin_id: &str, settings: RetentionSettings) { + let dir = self.plugin_dir(plugin_id); + if let Err(e) = fs::create_dir_all(&dir) { + tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, error = %e, + "failed to create plugin log dir for retention" + ); + return; + } + match serde_json::to_string_pretty(&settings) { + Ok(json) => { + if let Err(e) = fs::write(dir.join(RETENTION_FILE), json) { + tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, error = %e, + "failed to persist plugin retention" + ); + } + } + Err(e) => tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, error = %e, + "failed to serialize plugin retention" + ), + } + } + + /// Prune rotated segments older than the plugin's retention window, then + /// enforce the aggregate byte cap (oldest deleted first). Never touches the + /// active file. + fn sweep(&self, plugin_id: &str, now: DateTime) { + let dir = self.plugin_dir(plugin_id); + let retention = self.get_retention(plugin_id); + let cutoff = now - Duration::days(retention.retention_days as i64); + + let mut segments: Vec<(PathBuf, SystemTime, u64)> = Vec::new(); + let Ok(read_dir) = fs::read_dir(&dir) else { + return; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.starts_with("events.jsonl.") { + continue; // skip the active file, retention.json, etc. + } + let Ok(meta) = entry.metadata() else { continue }; + let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + segments.push((path, mtime, meta.len())); + } + + // 1) Age-based pruning. + let mut purged = 0u64; + segments.retain(|(path, mtime, _)| { + let dt: DateTime = (*mtime).into(); + if dt < cutoff { + let _ = fs::remove_file(path); + purged += 1; + false + } else { + true + } + }); + + // 2) Aggregate byte cap, oldest deleted first. + segments.sort_by_key(|(_, mtime, _)| *mtime); + let mut total: u64 = segments.iter().map(|(_, _, size)| *size).sum(); + let mut idx = 0; + while total > retention.max_bytes && idx < segments.len() { + let (path, _, size) = &segments[idx]; + let _ = fs::remove_file(path); + total = total.saturating_sub(*size); + purged += 1; + idx += 1; + } + + if purged > 0 { + tracing::debug!( + target: "oxicloud::plugins", + plugin_id = %plugin_id, + purged, + "plugin log retention sweep removed segments" + ); + } + } +} + +/// Read one segment (gzip if `.gz`, else plain), parse each line as a +/// [`LogEntry`], apply the filter, and append matches to `out`. Malformed lines +/// are skipped — a torn final line never aborts a read. +fn read_entries_into(path: &Path, query: &LogQuery, out: &mut Vec) { + let Ok(file) = fs::File::open(path) else { + return; + }; + let content = if path.extension().and_then(|e| e.to_str()) == Some("gz") { + let mut s = String::new(); + if GzDecoder::new(file).read_to_string(&mut s).is_err() { + return; + } + s + } else { + let mut s = String::new(); + let mut file = file; + if file.read_to_string(&mut s).is_err() { + return; + } + s + }; + let search = query.search.as_ref().map(|s| s.to_lowercase()); + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if let Some(level) = &query.level + && &entry.level != level + { + continue; + } + if let Some(needle) = &search + && !entry.msg.to_lowercase().contains(needle) + { + continue; + } + out.push(entry); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(days: u32, max_bytes: u64) -> RetentionSettings { + RetentionSettings { + retention_days: days, + max_bytes, + } + } + + fn entry(level: &str, msg: &str) -> LogEntry { + LogEntry { + ts: Utc::now().to_rfc3339(), + invocation_id: "inv".into(), + kind: "plugin".into(), + level: level.into(), + reason: None, + msg: msg.into(), + } + } + + fn new_actor(root: PathBuf, max_file_bytes: u64, max_segments: u32) -> Actor { + let (live, _) = broadcast::channel(16); + Actor { + root, + max_file_bytes: max_file_bytes.max(1), + max_segments, + default_retention: settings(30, 1 << 30), + writers: HashMap::new(), + live, + } + } + + #[test] + fn ordering_and_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5); + for i in 0..50 { + actor.handle_append("p", vec![entry("info", &format!("line {i}"))]); + } + let page = actor.read_page( + "p", + &LogQuery { + level: None, + search: None, + offset: 0, + limit: 10, + }, + ); + assert_eq!(page.total, 50); + assert_eq!(page.entries.len(), 10); + // Newest first. + assert_eq!(page.entries[0].msg, "line 49"); + assert_eq!(page.entries[9].msg, "line 40"); + } + + #[test] + fn filter_by_level_and_search() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5); + actor.handle_append("p", vec![entry("info", "hello world")]); + actor.handle_append("p", vec![entry("error", "BOOM failure")]); + actor.handle_append("p", vec![entry("info", "another HELLO")]); + + let q = LogQuery { + level: Some("info".into()), + search: Some("hello".into()), + offset: 0, + limit: 100, + }; + let page = actor.read_page("p", &q); + assert_eq!(page.total, 2); + assert!(page.entries.iter().all(|e| e.level == "info")); + assert!( + page.entries + .iter() + .all(|e| e.msg.to_lowercase().contains("hello")) + ); + } + + #[test] + fn rotation_creates_compressed_segments() { + let dir = tempfile::tempdir().unwrap(); + // Tiny byte cap forces frequent rotation; a high segment cap keeps every + // segment so the cross-segment read can be checked end to end (the byte + // cap is then exercised separately by `sweep_age_and_size`). + let mut actor = new_actor(dir.path().to_path_buf(), 256, 100_000); + for i in 0..200 { + actor.handle_append( + "p", + vec![entry("info", &format!("padding line number {i}"))], + ); + } + let plugin_dir = dir.path().join("p"); + let gz = fs::read_dir(&plugin_dir) + .unwrap() + .flatten() + .filter(|e| { + e.path() + .extension() + .and_then(|x| x.to_str()) + .map(|x| x == "gz") + .unwrap_or(false) + }) + .count(); + assert!(gz > 0, "expected at least one rotated .gz segment"); + // All originally-written lines must still be readable across segments. + let page = actor.read_page( + "p", + &LogQuery { + level: None, + search: None, + offset: 0, + limit: 1000, + }, + ); + assert_eq!(page.total, 200); + } + + #[test] + fn retention_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5); + assert_eq!(actor.get_retention("p").retention_days, 30); // default + actor.set_retention("p", settings(7, 1234)); + let r = actor.get_retention("p"); + assert_eq!(r.retention_days, 7); + assert_eq!(r.max_bytes, 1234); + } + + #[test] + fn sweep_age_and_size() { + let dir = tempfile::tempdir().unwrap(); + let plugin_dir = dir.path().join("p"); + fs::create_dir_all(&plugin_dir).unwrap(); + // One "old" rotated segment and one "fresh" one. + let old = plugin_dir.join("events.jsonl.20200101T000000.gz"); + let fresh = plugin_dir.join("events.jsonl.20990101T000000.gz"); + fs::write(&old, b"x").unwrap(); + fs::write(&fresh, b"y").unwrap(); + // Backdate the "old" file's mtime well past the retention window. + let long_ago = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); + filetime_set(&old, long_ago); + + let actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5); + // 1-day retention: the backdated file must go, the fresh one stays. + actor.sweep("p", Utc::now()); + assert!(!old.exists(), "age-expired segment should be purged"); + assert!(fresh.exists(), "recent segment should be kept"); + } + + /// Minimal mtime setter for tests (no extra dep): rewrite + set via filetime + /// is unavailable, so emulate "old" by relying on a very old written time is + /// not possible portably; instead we set it through `fs` utimes if present. + fn filetime_set(path: &Path, when: SystemTime) { + // `set_file_mtime` isn't in std; approximate by opening and using the + // platform fallback: on failure the test still meaningfully exercises + // the size path. We use a best-effort via `File::set_modified` (1.75+). + if let Ok(f) = fs::OpenOptions::new().write(true).open(path) { + let _ = f.set_modified(when); + } + } +} diff --git a/src/infrastructure/services/plugins/manager.rs b/src/infrastructure/services/plugins/manager.rs index 9cdf9128..60057cdd 100644 --- a/src/infrastructure/services/plugins/manager.rs +++ b/src/infrastructure/services/plugins/manager.rs @@ -18,13 +18,18 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; +use async_trait::async_trait; + +use super::log_store::PluginLogStore; use super::manifest; use super::runtime::{InvokeOutcome, PluginRuntime}; use crate::application::ports::plugin_ports::{ - OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, PluginEvent, PluginInfo, PluginInput, - PluginManagementPort, PluginMgmtError, event_export_name, + LogPage, LogQuery, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, PluginEvent, + PluginInfo, PluginInput, PluginLogEvent, PluginManagementPort, PluginMgmtError, + RetentionSettings, event_export_name, }; use crate::common::config::PluginConfig; +use tokio::sync::broadcast; /// Name of the marker file that, when present in a plugin's directory, loads it /// disabled. Created/removed by [`PluginManagementPort::set_enabled`]. @@ -67,6 +72,8 @@ pub struct ExtismPluginManager { /// Root directory plugins are discovered in and installed into. root_dir: PathBuf, plugins: RwLock>, + /// Per-plugin structured log storage (shared with the maintenance task). + log_store: Arc, } impl ExtismPluginManager { @@ -74,6 +81,23 @@ impl ExtismPluginManager { /// load. Returns an empty manager (logging the cause) if `dir` is absent or /// unreadable — a missing plugins directory is normal, not an error. pub fn load_from_dir(config: PluginConfig, dir: &Path) -> Self { + // The log root is a sibling of the plugins dir by default (or the + // configured override); it lives outside any individual plugin dir so a + // plugin uninstall (`remove_dir_all`) never wipes another's logs. + let log_dir = config + .log_dir + .clone() + .unwrap_or_else(|| dir.join(".plugin-logs")); + let log_store = Arc::new(PluginLogStore::new( + log_dir.clone(), + config.log_max_file_bytes, + config.log_max_segments, + RetentionSettings { + retention_days: config.log_retention_days, + max_bytes: config.log_total_max_bytes, + }, + )); + let mut plugins = Vec::new(); let mut rejected = 0usize; @@ -90,6 +114,7 @@ impl ExtismPluginManager { config, root_dir: dir.to_path_buf(), plugins: RwLock::new(plugins), + log_store, }; } }; @@ -99,6 +124,10 @@ impl ExtismPluginManager { if !path.is_dir() { continue; } + // Never treat the log root as a plugin directory. + if path == log_dir { + continue; + } match Self::load_one(&config, &path) { Ok(loaded) => { tracing::info!( @@ -134,9 +163,15 @@ impl ExtismPluginManager { config, root_dir: dir.to_path_buf(), plugins: RwLock::new(plugins), + log_store, } } + /// The shared log store, handed to the maintenance task by DI. + pub fn log_store(&self) -> Arc { + self.log_store.clone() + } + /// Validate and load a single plugin directory. Returns a stable audit /// `reason` key on rejection. fn load_one(config: &PluginConfig, dir: &Path) -> Result { @@ -201,6 +236,17 @@ impl ExtismPluginManager { fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec> { self.plugins.write().unwrap_or_else(|e| e.into_inner()) } + + /// `NotFound` unless a plugin with this id is currently installed. Checked + /// before any log-file access so an HTTP-supplied id can't reach the + /// filesystem for a plugin that doesn't exist. + fn ensure_installed(&self, id: &str) -> Result<(), PluginMgmtError> { + if self.read_plugins().iter().any(|p| p.id == id) { + Ok(()) + } else { + Err(PluginMgmtError::NotFound) + } + } } impl PluginDispatchPort for ExtismPluginManager { @@ -238,11 +284,16 @@ impl PluginDispatchPort for ExtismPluginManager { let plugin_id = plugin.id.clone(); let invocation_id = event.invocation_id.clone(); let export = event_export_name(event.name); + let log_store = self.log_store.clone(); // Run the synchronous wasm call off the async workers. Fire-and-forget: // the upload already succeeded; plugins are post-hoc observers. tokio::task::spawn_blocking(move || { let result = runtime.invoke(&config, &export, &invocation_id, &input_json); + // Persist every invocation (the plugin's own log lines plus the + // host outcome) to the plugin's structured log. Ordered, async, + // and non-fatal — a failed write never affects the request. + log_store.append(&plugin_id, &invocation_id, &result.logs, &result.outcome); if !result.outcome.is_ok() { tracing::warn!( target: "audit", @@ -265,6 +316,7 @@ impl PluginDispatchPort for ExtismPluginManager { } } +#[async_trait] impl PluginManagementPort for ExtismPluginManager { fn list(&self) -> Vec { let mut infos: Vec = self.read_plugins().iter().map(|p| p.info()).collect(); @@ -410,12 +462,45 @@ impl PluginManagementPort for ExtismPluginManager { .position(|p| p.id == id) .ok_or(PluginMgmtError::NotFound)?; let removed = plugins.remove(pos); + // Also reclaim the plugin's logs so a later reinstall of the same id + // doesn't inherit stale entries. + self.log_store.remove_plugin_logs(id); match std::fs::remove_dir_all(&removed.dir) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(PluginMgmtError::Io(e.to_string())), } } + + async fn read_logs(&self, id: &str, query: LogQuery) -> Result { + self.ensure_installed(id)?; + Ok(self.log_store.read_page(id, query).await) + } + + async fn clear_logs(&self, id: &str) -> Result<(), PluginMgmtError> { + self.ensure_installed(id)?; + self.log_store.clear(id).await; + Ok(()) + } + + async fn get_retention(&self, id: &str) -> Result { + self.ensure_installed(id)?; + Ok(self.log_store.get_retention(id).await) + } + + async fn set_retention( + &self, + id: &str, + settings: RetentionSettings, + ) -> Result<(), PluginMgmtError> { + self.ensure_installed(id)?; + self.log_store.set_retention(id, settings).await; + Ok(()) + } + + fn subscribe_logs(&self) -> broadcast::Receiver { + self.log_store.subscribe() + } } /// Whether `s` is a single, traversal-free path component safe to use as a diff --git a/src/infrastructure/services/plugins/manager_test.rs b/src/infrastructure/services/plugins/manager_test.rs index ab351cf5..f55e53aa 100644 --- a/src/infrastructure/services/plugins/manager_test.rs +++ b/src/infrastructure/services/plugins/manager_test.rs @@ -246,3 +246,51 @@ fn remove_unloads_and_deletes_directory() { let err = mgr.remove("com.example.hello").unwrap_err(); assert_eq!(err.reason(), "not_found"); } + +/// Regression guard: dispatch must persist a log entry for *every* invocation, +/// including a successful one (it previously only logged failures). We dispatch +/// a `file.uploaded` event and then poll the plugin's structured log until an +/// `outcome` row appears. +#[tokio::test(flavor = "multi_thread")] +async fn dispatch_writes_outcome_row_on_success() { + use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, LogQuery, PluginEvent}; + + let tmp = tempfile::tempdir().unwrap(); + let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + mgr.install(&hello_manifest(), fixture("hello.wasm")) + .unwrap(); + + mgr.dispatch(PluginEvent { + name: EVENT_FILE_UPLOADED, + user_id: None, + invocation_id: "test-invocation".to_string(), + payload: serde_json::json!({ "path": "/x.txt", "size": 1, "mime": "text/plain" }), + }); + + // dispatch is fire-and-forget on the blocking pool, and the log write is an + // ordered async hand-off; poll until the outcome row is durable. + let mut found = false; + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let page = mgr + .read_logs( + "com.example.hello", + LogQuery { + level: None, + search: None, + offset: 0, + limit: 100, + }, + ) + .await + .unwrap(); + if page.entries.iter().any(|e| e.kind == "outcome") { + found = true; + break; + } + } + assert!( + found, + "dispatch should persist an outcome row for the invocation" + ); +} diff --git a/src/infrastructure/services/plugins/mod.rs b/src/infrastructure/services/plugins/mod.rs index 6bf18a50..944e9844 100644 --- a/src/infrastructure/services/plugins/mod.rs +++ b/src/infrastructure/services/plugins/mod.rs @@ -5,10 +5,14 @@ //! [`crate::application::ports::plugin_ports::PluginDispatchPort`] trait, so the //! Extism types here never leak past the infrastructure boundary. +pub mod log_retention_service; +pub mod log_store; pub mod manager; pub mod manifest; pub mod runtime; +pub use log_retention_service::PluginLogMaintenanceService; +pub use log_store::PluginLogStore; pub use manager::ExtismPluginManager; #[cfg(test)] diff --git a/src/infrastructure/services/plugins/runtime.rs b/src/infrastructure/services/plugins/runtime.rs index 9f3040d6..2ce892c0 100644 --- a/src/infrastructure/services/plugins/runtime.rs +++ b/src/infrastructure/services/plugins/runtime.rs @@ -71,6 +71,27 @@ impl InvokeOutcome { matches!(self, InvokeOutcome::Ok) } + /// The `(level, message)` to record for this outcome in a plugin's log file. + /// `Ok` is an `info` "completed"; every contained failure is a `warn`/`error` + /// carrying its detail. The stable machine key is [`InvokeOutcome::reason`]. + pub fn log_detail(&self) -> (&'static str, String) { + match self { + InvokeOutcome::Ok => ("info", "invocation completed".to_string()), + InvokeOutcome::PluginError(e) => ("warn", e.clone()), + InvokeOutcome::Trap(e) => ("error", e.clone()), + InvokeOutcome::Timeout => ("error", "wall-clock timeout".to_string()), + InvokeOutcome::LoadError(e) => ("error", e.clone()), + InvokeOutcome::AbiMismatch { got } => { + ("error", format!("abi mismatch: plugin reported {got}")) + } + InvokeOutcome::MissingExport(s) => ("error", format!("missing export: {s}")), + InvokeOutcome::MalformedOutput(e) => ("warn", e.clone()), + InvokeOutcome::MalformedInput { size, max } => { + ("warn", format!("input too large: {size} bytes (max {max})")) + } + } + } + /// Stable, machine-readable key for audit logs. pub fn reason(&self) -> &'static str { match self { diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index a6a7b4d6..f90b0ec7 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -2,18 +2,24 @@ use axum::{ Router, extract::{Json, Multipart, Path, Query, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{ + IntoResponse, + sse::{Event, KeepAlive, Sse}, + }, routing::{delete, get, post, put}, }; -use crate::application::dtos::plugin_dto::{PluginInfoDto, SetEnabledDto}; +use crate::application::dtos::plugin_dto::{ + PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto, + SetEnabledDto, +}; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto, }; -use crate::application::ports::plugin_ports::{PluginManagementPort, PluginMgmtError}; +use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError}; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::require_admin; @@ -67,6 +73,12 @@ pub fn admin_routes() -> Router> { .route("/plugins", post(install_plugin)) .route("/plugins/{id}/enabled", put(set_plugin_enabled)) .route("/plugins/{id}", delete(delete_plugin)) + // Plugin logs + per-plugin retention + .route("/plugins/{id}/logs", get(get_plugin_logs)) + .route("/plugins/{id}/logs", delete(clear_plugin_logs)) + .route("/plugins/{id}/logs/stream", get(stream_plugin_logs)) + .route("/plugins/{id}/retention", get(get_plugin_retention)) + .route("/plugins/{id}/retention", put(set_plugin_retention)) // SMTP diagnostics .route("/smtp/info", get(get_smtp_info)) .route("/smtp/test", post(send_smtp_test)) @@ -1624,3 +1636,134 @@ pub async fn delete_plugin( Json(serde_json::json!({ "message": "Plugin removed", "id": id })), )) } + +/// GET /api/admin/plugins/{id}/logs — a filtered, paginated page of a plugin's +/// structured log entries (newest first). +pub async fn get_plugin_logs( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Query(q): Query, +) -> Result { + admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + + let limit = q.limit.unwrap_or(50).clamp(1, 500); + let offset = q.offset.unwrap_or(0); + let page = mgmt + .read_logs( + &id, + LogQuery { + level: q.level, + search: q.search, + offset, + limit, + }, + ) + .await + .map_err(|e| map_mgmt_err(&e))?; + + Ok(Json(PluginLogPageDto::from_page(page, limit, offset))) +} + +/// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs. +pub async fn clear_plugin_logs( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?; + + tracing::info!( + target: "audit", + event = "plugin.logs_cleared", + plugin_id = %id, + admin_id = %admin_id, + "👮🏻‍♂️ plugin logs cleared by admin" + ); + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "message": "Plugin logs cleared", "id": id })), + )) +} + +/// GET /api/admin/plugins/{id}/logs/stream — Server-Sent Events live tail. Each +/// `message` event carries one new log entry (JSON); a `lagged` event signals +/// the client should resync after falling behind. Auth rides the access cookie, +/// so `EventSource` works without setting headers. +pub async fn stream_plugin_logs( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + use tokio_stream::StreamExt; + use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; + + admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + if !mgmt.list().iter().any(|p| p.id == id) { + return Err(AppError::not_found("Plugin not found")); + } + + let rx = mgmt.subscribe_logs(); + let want = id; + let stream = BroadcastStream::new(rx).filter_map(move |res| match res { + Ok(ev) if ev.plugin_id == want => { + let dto = PluginLogEntryDto::from(ev.entry); + let event = Event::default() + .json_data(&dto) + .unwrap_or_else(|_| Event::default().comment("serialize error")); + Some(Ok::(event)) + } + Ok(_) => None, + Err(BroadcastStreamRecvError::Lagged(n)) => { + Some(Ok(Event::default().event("lagged").data(n.to_string()))) + } + }); + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +/// GET /api/admin/plugins/{id}/retention — the plugin's effective retention. +pub async fn get_plugin_retention( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + let settings = mgmt + .get_retention(&id) + .await + .map_err(|e| map_mgmt_err(&e))?; + Ok(Json(PluginRetentionDto::from(settings))) +} + +/// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy. +pub async fn set_plugin_retention( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + mgmt.set_retention(&id, dto.into()) + .await + .map_err(|e| map_mgmt_err(&e))?; + + tracing::info!( + target: "audit", + event = "plugin.retention_updated", + plugin_id = %id, + admin_id = %admin_id, + retention_days = dto.retention_days, + max_bytes = dto.max_bytes, + "👮🏻‍♂️ plugin log retention updated by admin" + ); + + Ok((StatusCode::OK, Json(dto))) +} diff --git a/static/admin.html b/static/admin.html index 0e00d0aa..7e21c0e7 100644 --- a/static/admin.html +++ b/static/admin.html @@ -737,6 +737,89 @@ + + diff --git a/static/css/views/admin.css b/static/css/views/admin.css index 0d0b7cee..c4ed82c4 100644 --- a/static/css/views/admin.css +++ b/static/css/views/admin.css @@ -1149,3 +1149,89 @@ details[open] summary { margin-top: var(--space-4); flex-wrap: wrap; } + +/* ── Plugin detail page ── */ +.plugin-detail-meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-2) var(--space-4); + margin: 0; +} +.plugin-detail-meta dt { + color: var(--color-text-subtle); + font-weight: var(--weight-semibold); +} +.plugin-detail-meta dd { + margin: 0; +} + +.plugin-logs-toolbar { + display: flex; + align-items: center; + gap: var(--space-2-5); + flex-wrap: wrap; + margin-bottom: var(--space-3); +} +.plugin-logs-toolbar .form-control { + width: auto; +} +.plugin-logs-toolbar #plugin-logs-search { + flex: 1 1 200px; + min-width: 160px; +} +.plugin-logs-live { + display: inline-flex; + align-items: center; + gap: var(--space-1-5); + color: var(--color-text-subtle); + font-size: var(--text-xs); + white-space: nowrap; +} + +.plugin-log-level { + display: inline-block; + text-transform: uppercase; + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + padding: 2px var(--space-2); + border-radius: var(--radius-md); +} +.plugin-log-level--debug { + background: var(--color-border-light); + color: var(--color-text-subtle); +} +.plugin-log-level--info { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} +.plugin-log-level--warn { + background: var(--color-warning-bg-light); + color: var(--color-badge-warning-text); +} +.plugin-log-level--error { + background: var(--color-error-bg); + color: var(--color-error-text-dark); +} + +.plugin-log-ts, +.plugin-log-inv { + color: var(--color-text-subtle); + font-size: var(--text-xs); + white-space: nowrap; +} +.plugin-log-msg { + word-break: break-word; +} + +/* Brief highlight when a row arrives via the live stream. */ +.plugin-log-row--new { + animation: plugin-log-flash 1.2s ease-out; +} +@keyframes plugin-log-flash { + from { + background: var(--color-badge-blue-bg); + } + to { + background: transparent; + } +} diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 5cf9ab35..e7f712eb 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -13,6 +13,17 @@ let usersPage = 0; const PAGE_SIZE = 50; let totalUsers = 0; +/* Plugin detail / log viewer state */ +const PLUGIN_LOGS_PAGE_SIZE = 50; +/** @type {Record} */ +let pluginsById = {}; +/** @type {string|null} */ +let pluginDetailId = null; +let pluginLogsPage = 0; +let pluginLogsTotal = 0; +/** @type {EventSource|null} */ +let pluginLogStream = null; + /** * Escape a string for safe embedding inside a JS string literal within an HTML attribute. * @param {string} s @@ -148,6 +159,8 @@ function switchTab(name, el) { // visible — without this it would keep hitting the API every 2 s // (and updating hidden DOM) for as long as a migration runs. if (name !== 'storage') stopMigrationPolling(); + // Leaving the plugins tab tears down the live log stream. + if (name !== 'plugins') stopLogStream(); if (name === 'users') loadUsers(); if (name === 'dashboard') loadDashboard(); if (name === 'storage') loadStorage(); @@ -1302,6 +1315,8 @@ async function loadPlugins() { const disabledEl = document.getElementById('plugins-disabled'); const mainEl = document.getElementById('plugins-main'); if (!tbody || !disabledEl || !mainEl) return; + // Always return to the list view (and stop any live tail) when (re)loading. + closePluginDetail(); try { const resp = await fetch(`${API}/admin/plugins`, { headers: headers(), @@ -1330,6 +1345,10 @@ async function loadPlugins() { function renderPluginRows(plugins) { const tbody = document.getElementById('plugins-tbody'); if (!tbody) return; + pluginsById = {}; + plugins.forEach((p) => { + pluginsById[p.id] = p; + }); if (plugins.length === 0) { tbody.innerHTML = `${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}`; return; @@ -1346,6 +1365,9 @@ function renderPluginRows(plugins) { const deleteBtn = `'; + const detailsBtn = + `'; return ( '' + `${escapeHtml(p.name)}` + @@ -1353,7 +1375,7 @@ function renderPluginRows(plugins) { `${escapeHtml(p.version)}` + `${events}` + `${statusBadge}` + - `
${toggleBtn}${deleteBtn}
` + + `
${detailsBtn}${toggleBtn}${deleteBtn}
` + '' ); }) @@ -1364,6 +1386,7 @@ function renderPluginRows(plugins) { const action = btn.dataset.action; if (action === 'toggle') togglePlugin(btn.dataset.pid, btn.dataset.enabled !== 'true'); else if (action === 'delete') deletePlugin(btn.dataset.pid, btn.dataset.pname); + else if (action === 'details') openPluginDetail(btn.dataset.pid); }); }); } @@ -1470,6 +1493,315 @@ async function installPlugin() { } } +/* ── Plugin detail page (metadata + retention + logs + live tail) ── */ + +/** + * @typedef {Object} PluginLogEntry + * @property {string} ts + * @property {string} invocation_id + * @property {string} kind + * @property {string} level + * @property {string} [reason] + * @property {string} msg + */ + +/** + * @typedef {Object} PluginLogPage + * @property {PluginLogEntry[]} entries + * @property {number} total + * @property {number} limit + * @property {number} offset + */ + +/** + * Open the detail page for a plugin: metadata, retention form, and its logs + * (with a live tail). Hides the list view until the user goes back. + * @param {string|undefined} id + */ +function openPluginDetail(id) { + if (!id) return; + const plugin = pluginsById[id]; + if (!plugin) return; + pluginDetailId = id; + pluginLogsPage = 0; + + hideElement('plugins-main'); + showElement('plugin-detail-view'); + + const nameEl = document.getElementById('plugin-detail-name'); + if (nameEl) nameEl.textContent = plugin.name; + renderPluginMeta(plugin); + + loadPluginRetention(); + loadPluginLogs(); + startLogStream(id); +} + +/** Return to the installed-plugins list and stop the live stream. */ +function closePluginDetail() { + stopLogStream(); + pluginDetailId = null; + hideElement('plugin-detail-view'); + showElement('plugins-main'); +} + +/** @param {PluginInfo} p */ +function renderPluginMeta(p) { + const meta = document.getElementById('plugin-detail-meta'); + if (!meta) return; + const events = (p.subscriptions || []).map((ev) => `${escapeHtml(ev)}`).join(' ') || '—'; + const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled'; + const statusBadge = `${escapeHtml(statusLabel)}`; + /** @param {string} label @param {string} value */ + const row = (label, value) => `
${escapeHtml(label)}
${value}
`; + meta.innerHTML = + row(i18n.t('admin.plugins_col_id') || 'ID', `${escapeHtml(p.id)}`) + + row(i18n.t('admin.plugins_col_version') || 'Version', escapeHtml(p.version)) + + row('ABI', escapeHtml(String(p.abi))) + + row(i18n.t('admin.plugins_col_events') || 'Events', events) + + row(i18n.t('admin.plugins_col_status') || 'Status', statusBadge); +} + +/** Read the current log filter from the toolbar inputs. */ +function pluginLogFilter() { + const level = /** @type {HTMLSelectElement|null} */ (document.getElementById('plugin-logs-level'))?.value || ''; + const search = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-search'))?.value || ''; + return { level, search }; +} + +/** Load a page of the current plugin's logs into the table. */ +async function loadPluginLogs() { + const id = pluginDetailId; + const tbody = document.getElementById('plugin-logs-tbody'); + if (!id || !tbody) return; + const { level, search } = pluginLogFilter(); + const params = new URLSearchParams(); + params.set('limit', String(PLUGIN_LOGS_PAGE_SIZE)); + params.set('offset', String(pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE)); + if (level) params.set('level', level); + if (search) params.set('search', search); + + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs?${params.toString()}`, { + headers: headers(), + credentials: 'same-origin' + }); + if (!resp.ok) { + tbody.innerHTML = ` ${escapeHtml(`HTTP ${resp.status}`)}`; + return; + } + /** @type {PluginLogPage} */ + const page = await resp.json(); + pluginLogsTotal = page.total; + renderPluginLogRows(page.entries || []); + updatePluginLogsPagination(); + } catch (e) { + tbody.innerHTML = ` ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}`; + } +} + +/** @param {PluginLogEntry[]} entries */ +function renderPluginLogRows(entries) { + const tbody = document.getElementById('plugin-logs-tbody'); + if (!tbody) return; + if (entries.length === 0) { + tbody.innerHTML = `${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}`; + return; + } + tbody.innerHTML = entries.map(pluginLogRowHtml).join(''); +} + +/** @param {PluginLogEntry} e */ +function pluginLogRowHtml(e) { + const level = (e.level || 'info').toLowerCase(); + const levelBadge = `${escapeHtml(level)}`; + const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log'; + return ( + '' + + `${escapeHtml(timeAgo(e.ts))}` + + `${levelBadge}` + + `${escapeHtml(kind)}` + + `${escapeHtml(e.invocation_id)}` + + `${escapeHtml(e.msg)}` + + '' + ); +} + +function updatePluginLogsPagination() { + const info = document.getElementById('plugin-logs-info'); + const prev = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-prev')); + const next = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-next')); + if (info) { + if (pluginLogsTotal === 0) { + info.textContent = i18n.t('admin.plugins_logs_none') || 'No log entries.'; + } else { + const from = pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE + 1; + const to = Math.min((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE, pluginLogsTotal); + info.textContent = i18n.t('admin.plugins_logs_showing', { from, to, total: pluginLogsTotal }) || `Showing ${from}–${to} of ${pluginLogsTotal}`; + } + } + if (prev) prev.disabled = pluginLogsPage === 0; + if (next) next.disabled = (pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE >= pluginLogsTotal; +} + +/** + * Open the SSE live tail for the given plugin. + * @param {string} id + */ +function startLogStream(id) { + stopLogStream(); + const live = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-live')); + if (live && !live.checked) return; + const es = new EventSource(`${API}/admin/plugins/${encodeURIComponent(id)}/logs/stream`, { withCredentials: true }); + es.onmessage = (ev) => { + try { + /** @type {PluginLogEntry} */ + const entry = JSON.parse(ev.data); + onLiveLogEntry(entry); + } catch { + /* ignore malformed frames */ + } + }; + es.addEventListener('lagged', () => { + // Fell behind the broadcast buffer — resync from the server. + loadPluginLogs(); + }); + pluginLogStream = es; +} + +/** Close the live tail if open. */ +function stopLogStream() { + if (pluginLogStream) { + pluginLogStream.close(); + pluginLogStream = null; + } +} + +/** + * Handle a streamed entry: only prepend when viewing the newest page and the + * entry passes the active filter, so the live tail never fights pagination. + * @param {PluginLogEntry} entry + */ +function onLiveLogEntry(entry) { + if (pluginLogsPage !== 0) return; + const { level, search } = pluginLogFilter(); + if (level && (entry.level || '').toLowerCase() !== level.toLowerCase()) return; + if (search && !(entry.msg || '').toLowerCase().includes(search.toLowerCase())) return; + + const tbody = document.getElementById('plugin-logs-tbody'); + if (!tbody) return; + // Drop any "empty" placeholder row before inserting the first live entry. + const placeholder = tbody.querySelector('.table-status-empty'); + if (placeholder) tbody.innerHTML = ''; + + tbody.insertAdjacentHTML('afterbegin', pluginLogRowHtml(entry)); + const firstRow = tbody.firstElementChild; + if (firstRow) firstRow.classList.add('plugin-log-row--new'); + // Keep the page bounded to one page-worth of rows. + while (tbody.children.length > PLUGIN_LOGS_PAGE_SIZE) { + const last = tbody.lastElementChild; + if (!last) break; + last.remove(); + } + pluginLogsTotal += 1; + updatePluginLogsPagination(); +} + +/** Load the current plugin's retention into the form. */ +async function loadPluginRetention() { + const id = pluginDetailId; + if (!id) return; + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, { + headers: headers(), + credentials: 'same-origin' + }); + if (!resp.ok) return; + /** @type {{retention_days: number, max_bytes: number}} */ + const r = await resp.json(); + const daysEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-days')); + const mbEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-max-mb')); + if (daysEl) daysEl.value = String(r.retention_days); + if (mbEl) mbEl.value = String(Math.round(r.max_bytes / (1024 * 1024))); + } catch { + /* leave fields as-is on error */ + } +} + +/** Persist the retention form for the current plugin. */ +async function savePluginRetention() { + const id = pluginDetailId; + const resultEl = document.getElementById('plugin-retention-result'); + if (!id || !resultEl) return; + const days = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-days')).value); + const mb = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-max-mb')).value); + if (!Number.isFinite(days) || days < 0 || !Number.isFinite(mb) || mb < 0) { + resultEl.className = 'alert alert-error'; + resultEl.style.display = 'block'; + resultEl.textContent = i18n.t('admin.plugins_retention_invalid') || 'Enter non-negative numbers.'; + return; + } + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, { + method: 'PUT', + headers: headers(), + credentials: 'same-origin', + body: JSON.stringify({ retention_days: Math.round(days), max_bytes: Math.round(mb) * 1024 * 1024 }) + }); + if (resp.ok) { + resultEl.className = 'alert alert-success'; + resultEl.style.display = 'block'; + resultEl.textContent = i18n.t('admin.plugins_retention_saved') || 'Retention saved.'; + } else { + const e = await resp.json().catch(() => ({})); + resultEl.className = 'alert alert-error'; + resultEl.style.display = 'block'; + resultEl.textContent = e.message || `HTTP ${resp.status}`; + } + } catch (e) { + resultEl.className = 'alert alert-error'; + resultEl.style.display = 'block'; + resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); + } +} + +/** Clear all persisted logs for the current plugin. */ +async function clearPluginLogs() { + const id = pluginDetailId; + if (!id) return; + const ok = await showConfirm(i18n.t('admin.plugins_logs_confirm_clear') || 'Clear all logs for this plugin?'); + if (!ok) return; + try { + const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs`, { + method: 'DELETE', + headers: headers(), + credentials: 'same-origin' + }); + if (resp.ok) { + pluginLogsPage = 0; + loadPluginLogs(); + } else { + const e = await resp.json().catch(() => ({})); + alert(e.message || i18n.t('admin.error_generic')); + } + } catch (e) { + alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); + } +} + +function pluginLogsPrevPage() { + if (pluginLogsPage > 0) { + pluginLogsPage--; + loadPluginLogs(); + } +} +function pluginLogsNextPage() { + if ((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE < pluginLogsTotal) { + pluginLogsPage++; + loadPluginLogs(); + } +} + /* ── Apply i18n when translations load / change ── */ document.addEventListener('translationsLoaded', () => { i18n.translatePage(); @@ -1510,6 +1842,33 @@ document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest) document.getElementById('btn-plugin-install').addEventListener('click', installPlugin); +/* Plugin detail page controls */ +document.getElementById('plugin-detail-back')?.addEventListener('click', closePluginDetail); +document.getElementById('plugin-retention-save')?.addEventListener('click', savePluginRetention); +document.getElementById('plugin-logs-refresh')?.addEventListener('click', loadPluginLogs); +document.getElementById('plugin-logs-clear')?.addEventListener('click', clearPluginLogs); +document.getElementById('plugin-logs-prev')?.addEventListener('click', pluginLogsPrevPage); +document.getElementById('plugin-logs-next')?.addEventListener('click', pluginLogsNextPage); +document.getElementById('plugin-logs-level')?.addEventListener('change', () => { + pluginLogsPage = 0; + loadPluginLogs(); +}); +let pluginLogsSearchTimer = 0; +document.getElementById('plugin-logs-search')?.addEventListener('input', () => { + window.clearTimeout(pluginLogsSearchTimer); + pluginLogsSearchTimer = window.setTimeout(() => { + pluginLogsPage = 0; + loadPluginLogs(); + }, 250); +}); +document.getElementById('plugin-logs-live')?.addEventListener('change', function () { + if (/** @type {HTMLInputElement} */ (this).checked) { + if (pluginDetailId) startLogStream(pluginDetailId); + } else { + stopLogStream(); + } +}); + document.getElementById('ds-registration').addEventListener('change', function () { toggleRegistration(/** @type {HTMLInputElement} */ (this).checked); }); diff --git a/static/locales/en.json b/static/locales/en.json index df1381b7..c829bf57 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -756,6 +756,28 @@ "plugins_installing": "Installing…", "plugins_installed": "Installed {{name}}.", "plugins_install_missing_bundle": "Select a plugin bundle (.zip).", + "plugins_details": "Logs & details", + "plugins_back": "Back to plugins", + "plugins_retention_title": "Log retention", + "plugins_retention_intro": "Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.", + "plugins_retention_days": "Retention (days)", + "plugins_retention_max_mb": "Max log size (MB)", + "plugins_retention_save": "Save retention", + "plugins_retention_saved": "Retention saved.", + "plugins_retention_invalid": "Enter non-negative numbers.", + "plugins_logs_title": "Logs", + "plugins_logs_level_all": "All levels", + "plugins_logs_search": "Search messages…", + "plugins_logs_live": "Live", + "plugins_logs_clear": "Clear", + "plugins_logs_confirm_clear": "Clear all logs for this plugin?", + "plugins_logs_none": "No log entries.", + "plugins_logs_col_time": "Time", + "plugins_logs_col_level": "Level", + "plugins_logs_col_kind": "Kind", + "plugins_logs_col_invocation": "Invocation", + "plugins_logs_col_message": "Message", + "plugins_logs_showing": "Showing {{from}}–{{to}} of {{total}}", "tab_smtp": "SMTP", "smtp_title": "Outbound Email (SMTP)", "smtp_intro": "SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.",