plugin logging
This commit is contained in:
Generated
+12
@@ -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]]
|
||||
|
||||
+3
-2
@@ -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)'] }
|
||||
|
||||
@@ -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<PluginInfo> 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<String>,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
impl From<LogEntry> 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<PluginLogEntryDto>,
|
||||
/// 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<String>,
|
||||
/// Case-insensitive substring filter on the message.
|
||||
pub search: Option<String>,
|
||||
/// Max entries to return (clamped server-side).
|
||||
pub limit: Option<usize>,
|
||||
/// Newest-first entries to skip.
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<RetentionSettings> for PluginRetentionDto {
|
||||
fn from(s: RetentionSettings) -> Self {
|
||||
Self {
|
||||
retention_days: s.retention_days,
|
||||
max_bytes: s.max_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginRetentionDto> for RetentionSettings {
|
||||
fn from(d: PluginRetentionDto) -> Self {
|
||||
Self {
|
||||
retention_days: d.retention_days,
|
||||
max_bytes: d.max_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PluginInfo>;
|
||||
@@ -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<LogPage, PluginMgmtError>;
|
||||
|
||||
/// 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<RetentionSettings, PluginMgmtError>;
|
||||
|
||||
/// 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<PluginLogEvent>;
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Keep only entries whose message contains this substring (case-insensitive).
|
||||
pub search: Option<String>,
|
||||
/// 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<LogEntry>,
|
||||
/// 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.
|
||||
|
||||
@@ -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.<ts>.gz` + `retention.json`).
|
||||
/// Default: `{storage_path}/.plugin-logs`. Env: `OXICLOUD_PLUGIN_LOG_DIR`.
|
||||
pub log_dir: Option<PathBuf>,
|
||||
/// 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::<u64>())
|
||||
&& 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::<u32>())
|
||||
&& 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::<u32>())
|
||||
&& 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::<u64>())
|
||||
&& 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::<bool>())
|
||||
&& let Ok(val) = v
|
||||
|
||||
+23
-2
@@ -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<dyn crate::application::ports::plugin_ports::PluginDispatchPort> =
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PluginLogStore>,
|
||||
manager: Arc<dyn PluginManagementPort>,
|
||||
interval_hours: u64,
|
||||
}
|
||||
|
||||
impl PluginLogMaintenanceService {
|
||||
pub fn new(
|
||||
log_store: Arc<PluginLogStore>,
|
||||
manager: Arc<dyn PluginManagementPort>,
|
||||
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<dyn PluginManagementPort>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.<timestamp>.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<LogEntry>,
|
||||
},
|
||||
Read {
|
||||
plugin_id: String,
|
||||
query: LogQuery,
|
||||
reply: oneshot::Sender<LogPage>,
|
||||
},
|
||||
Clear {
|
||||
plugin_id: String,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
Remove {
|
||||
plugin_id: String,
|
||||
},
|
||||
GetRetention {
|
||||
plugin_id: String,
|
||||
reply: oneshot::Sender<RetentionSettings>,
|
||||
},
|
||||
SetRetention {
|
||||
plugin_id: String,
|
||||
settings: RetentionSettings,
|
||||
reply: oneshot::Sender<()>,
|
||||
},
|
||||
Sweep {
|
||||
plugin_id: String,
|
||||
now: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<LogCommand>,
|
||||
live: broadcast::Sender<PluginLogEvent>,
|
||||
}
|
||||
|
||||
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<LogEntry> = 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<Utc>) {
|
||||
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<PluginLogEvent> {
|
||||
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<String, FileRotate<AppendTimestamp>>,
|
||||
live: broadcast::Sender<PluginLogEvent>,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
fn run(mut self, mut rx: mpsc::Receiver<LogCommand>) {
|
||||
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<AppendTimestamp>> {
|
||||
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<LogEntry>) {
|
||||
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<PathBuf> = 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<LogEntry> = 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::<RetentionSettings>(&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<Utc>) {
|
||||
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<Utc> = (*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<LogEntry>) {
|
||||
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::<LogEntry>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<LoadedPlugin>>,
|
||||
/// Per-plugin structured log storage (shared with the maintenance task).
|
||||
log_store: Arc<PluginLogStore>,
|
||||
}
|
||||
|
||||
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<PluginLogStore> {
|
||||
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<LoadedPlugin, &'static str> {
|
||||
@@ -201,6 +236,17 @@ impl ExtismPluginManager {
|
||||
fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec<LoadedPlugin>> {
|
||||
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<PluginInfo> {
|
||||
let mut infos: Vec<PluginInfo> = 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<LogPage, PluginMgmtError> {
|
||||
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<RetentionSettings, PluginMgmtError> {
|
||||
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<PluginLogEvent> {
|
||||
self.log_store.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` is a single, traversal-free path component safe to use as a
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
.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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<PluginLogQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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, std::convert::Infallible>(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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<PluginRetentionDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -737,6 +737,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="plugin-detail-view" class="hidden">
|
||||
<button id="plugin-detail-back" class="btn btn-secondary btn-sm" style="margin-bottom:14px;">
|
||||
<i class="fas fa-arrow-left"></i> <span data-i18n="admin.plugins_back">Back to plugins</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span id="plugin-detail-name"></span>
|
||||
</h2>
|
||||
<dl class="plugin-detail-meta" id="plugin-detail-meta"></dl>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-clock-rotate-left"></i> <span data-i18n="admin.plugins_retention_title">Log retention</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.plugins_retention_intro">
|
||||
Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="plugin-retention-days" data-i18n="admin.plugins_retention_days">Retention (days)</label>
|
||||
<input id="plugin-retention-days" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plugin-retention-max-mb" data-i18n="admin.plugins_retention_max_mb">Max log size (MB)</label>
|
||||
<input id="plugin-retention-max-mb" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<button id="plugin-retention-save" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> <span data-i18n="admin.plugins_retention_save">Save retention</span>
|
||||
</button>
|
||||
<div id="plugin-retention-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-list"></i> <span data-i18n="admin.plugins_logs_title">Logs</span>
|
||||
</h2>
|
||||
<div class="plugin-logs-toolbar">
|
||||
<select id="plugin-logs-level" class="form-control">
|
||||
<option value="" data-i18n="admin.plugins_logs_level_all">All levels</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<input id="plugin-logs-search" type="search" class="form-control" data-i18n-placeholder="admin.plugins_logs_search" placeholder="Search messages…" />
|
||||
<label class="plugin-logs-live">
|
||||
<input id="plugin-logs-live" type="checkbox" checked />
|
||||
<span data-i18n="admin.plugins_logs_live">Live</span>
|
||||
</label>
|
||||
<button id="plugin-logs-refresh" class="btn btn-sm btn-secondary" title="Refresh">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<button id="plugin-logs-clear" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash-alt"></i> <span data-i18n="admin.plugins_logs_clear">Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.plugins_logs_col_time">Time</th>
|
||||
<th data-i18n="admin.plugins_logs_col_level">Level</th>
|
||||
<th data-i18n="admin.plugins_logs_col_kind">Kind</th>
|
||||
<th data-i18n="admin.plugins_logs_col_invocation">Invocation</th>
|
||||
<th data-i18n="admin.plugins_logs_col_message">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugin-logs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="plugin-logs-info" class="muted"></span>
|
||||
<button id="plugin-logs-prev" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button id="plugin-logs-next" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, PluginInfo>} */
|
||||
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 = `<tr><td colspan="6" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}</td></tr>`;
|
||||
return;
|
||||
@@ -1346,6 +1365,9 @@ function renderPluginRows(plugins) {
|
||||
const deleteBtn =
|
||||
`<button class="btn btn-sm btn-danger plugin-action-btn" data-action="delete" data-pid="${_escJs(p.id)}" data-pname="${_escJs(p.name)}" title="${escapeHtml(i18n.t('admin.plugins_delete') || 'Delete')}">` +
|
||||
'<i class="fas fa-trash-alt"></i></button>';
|
||||
const detailsBtn =
|
||||
`<button class="btn btn-sm btn-secondary plugin-action-btn" data-action="details" data-pid="${_escJs(p.id)}" title="${escapeHtml(i18n.t('admin.plugins_details') || 'Logs & details')}">` +
|
||||
'<i class="fas fa-list"></i></button>';
|
||||
return (
|
||||
'<tr>' +
|
||||
`<td>${escapeHtml(p.name)}</td>` +
|
||||
@@ -1353,7 +1375,7 @@ function renderPluginRows(plugins) {
|
||||
`<td>${escapeHtml(p.version)}</td>` +
|
||||
`<td>${events}</td>` +
|
||||
`<td>${statusBadge}</td>` +
|
||||
`<td><div class="actions-row">${toggleBtn}${deleteBtn}</div></td>` +
|
||||
`<td><div class="actions-row">${detailsBtn}${toggleBtn}${deleteBtn}</div></td>` +
|
||||
'</tr>'
|
||||
);
|
||||
})
|
||||
@@ -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) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
|
||||
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
|
||||
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
|
||||
/** @param {string} label @param {string} value */
|
||||
const row = (label, value) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`;
|
||||
meta.innerHTML =
|
||||
row(i18n.t('admin.plugins_col_id') || 'ID', `<code>${escapeHtml(p.id)}</code>`) +
|
||||
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 = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
/** @type {PluginLogPage} */
|
||||
const page = await resp.json();
|
||||
pluginLogsTotal = page.total;
|
||||
renderPluginLogRows(page.entries || []);
|
||||
updatePluginLogsPagination();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {PluginLogEntry[]} entries */
|
||||
function renderPluginLogRows(entries) {
|
||||
const tbody = document.getElementById('plugin-logs-tbody');
|
||||
if (!tbody) return;
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = entries.map(pluginLogRowHtml).join('');
|
||||
}
|
||||
|
||||
/** @param {PluginLogEntry} e */
|
||||
function pluginLogRowHtml(e) {
|
||||
const level = (e.level || 'info').toLowerCase();
|
||||
const levelBadge = `<span class="plugin-log-level plugin-log-level--${escapeHtml(level)}">${escapeHtml(level)}</span>`;
|
||||
const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log';
|
||||
return (
|
||||
'<tr>' +
|
||||
`<td class="plugin-log-ts">${escapeHtml(timeAgo(e.ts))}</td>` +
|
||||
`<td>${levelBadge}</td>` +
|
||||
`<td><code>${escapeHtml(kind)}</code></td>` +
|
||||
`<td><code class="plugin-log-inv">${escapeHtml(e.invocation_id)}</code></td>` +
|
||||
`<td class="plugin-log-msg">${escapeHtml(e.msg)}</td>` +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user