fix: show runtime logs in program logs
This commit is contained in:
+6
-2
@@ -5,7 +5,7 @@ use clap::{Parser, Subcommand};
|
|||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -104,7 +104,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
|
||||||
|
.init();
|
||||||
|
|
||||||
tracing::info!("Loaded configuration from {}", args.config);
|
tracing::info!("Loaded configuration from {}", args.config);
|
||||||
|
|
||||||
|
|||||||
+83
-10
@@ -1,14 +1,15 @@
|
|||||||
// Events routes — system event notifications
|
// Events routes — system event notifications
|
||||||
|
|
||||||
use crate::{db::models::event::Event, error::AppResult, AppState};
|
use crate::{db::models::event::Event, error::AppResult, services::runtime_logs, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ListEventsQuery {
|
pub struct ListEventsQuery {
|
||||||
@@ -32,17 +33,15 @@ async fn list_events(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListEventsQuery>,
|
Query(q): Query<ListEventsQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Listing events");
|
debug!("Listing events");
|
||||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||||
let offset = q.offset.unwrap_or(0).max(0);
|
let offset = q.offset.unwrap_or(0).max(0);
|
||||||
|
|
||||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
|
||||||
let mut bind_idx = 0;
|
|
||||||
let mut params: Vec<String> = vec![];
|
let mut params: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(ref t) = q.filter_type {
|
if let Some(ref t) = q.filter_type {
|
||||||
bind_idx += 1;
|
sql.push_str(" AND type = ?");
|
||||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
|
||||||
params.push(t.clone());
|
params.push(t.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref r) = q.read {
|
if let Some(ref r) = q.read {
|
||||||
@@ -53,16 +52,41 @@ async fn list_events(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.push_str(" ORDER BY created_at DESC");
|
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
|
||||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
|
||||||
|
|
||||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||||
for p in ¶ms {
|
for p in ¶ms {
|
||||||
query = query.bind(p);
|
query = query.bind(p);
|
||||||
}
|
}
|
||||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||||
|
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "data": events })))
|
if q.filter_type
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|filter_type| filter_type == "runtime")
|
||||||
|
{
|
||||||
|
rows.extend(
|
||||||
|
runtime_logs::list()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| q.read.as_deref() != Some("false") || !entry.is_read)
|
||||||
|
.map(EventListItem::from),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
let start = offset as usize;
|
||||||
|
let end = start.saturating_add(limit as usize).min(rows.len());
|
||||||
|
let page = if start < rows.len() {
|
||||||
|
rows[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({ "data": page })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/events/count — unread count
|
/// GET /api/events/count — unread count
|
||||||
@@ -98,5 +122,54 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|||||||
sqlx::query("DELETE FROM events")
|
sqlx::query("DELETE FROM events")
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
runtime_logs::clear();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EventListItem {
|
||||||
|
id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
title: String,
|
||||||
|
message: Option<String>,
|
||||||
|
level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
is_read: bool,
|
||||||
|
related_id: Option<i64>,
|
||||||
|
related_type: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Event> for EventListItem {
|
||||||
|
fn from(event: Event) -> Self {
|
||||||
|
Self {
|
||||||
|
id: event.id,
|
||||||
|
event_type: event.event_type,
|
||||||
|
title: event.title,
|
||||||
|
message: event.message,
|
||||||
|
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||||
|
is_read: event.is_read.unwrap_or(false),
|
||||||
|
related_id: event.related_id,
|
||||||
|
related_type: event.related_type,
|
||||||
|
created_at: event.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
||||||
|
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id,
|
||||||
|
event_type: entry.event_type,
|
||||||
|
title: entry.title,
|
||||||
|
message: entry.message,
|
||||||
|
level: entry.level,
|
||||||
|
is_read: entry.is_read,
|
||||||
|
related_id: entry.related_id,
|
||||||
|
related_type: entry.related_type,
|
||||||
|
created_at: Some(entry.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod oauth;
|
|||||||
pub mod register_captcha;
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
|
pub mod runtime_logs;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod update_center;
|
pub mod update_center;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use tracing::{
|
||||||
|
field::{Field, Visit},
|
||||||
|
Level, Subscriber,
|
||||||
|
};
|
||||||
|
use tracing_subscriber::{layer::Context, Layer};
|
||||||
|
|
||||||
|
const MAX_RUNTIME_LOGS: usize = 500;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicI64 = AtomicI64::new(1);
|
||||||
|
static RUNTIME_LOGS: Lazy<RwLock<VecDeque<RuntimeLogEntry>>> =
|
||||||
|
Lazy::new(|| RwLock::new(VecDeque::with_capacity(MAX_RUNTIME_LOGS)));
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeLogEntry {
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub event_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
pub is_read: bool,
|
||||||
|
pub related_id: Option<i64>,
|
||||||
|
pub related_type: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RuntimeLogLayer;
|
||||||
|
|
||||||
|
impl<S> Layer<S> for RuntimeLogLayer
|
||||||
|
where
|
||||||
|
S: Subscriber,
|
||||||
|
{
|
||||||
|
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
let level = metadata.level();
|
||||||
|
if !matches!(*level, Level::ERROR | Level::WARN | Level::INFO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visitor = LogFieldVisitor::default();
|
||||||
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
let title = visitor
|
||||||
|
.message
|
||||||
|
.unwrap_or_else(|| metadata.name().to_string());
|
||||||
|
let mut details = Vec::with_capacity(visitor.fields.len() + 1);
|
||||||
|
details.push(metadata.target().to_string());
|
||||||
|
details.extend(visitor.fields);
|
||||||
|
|
||||||
|
push(RuntimeLogEntry {
|
||||||
|
id: -NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||||
|
event_type: "runtime".to_string(),
|
||||||
|
title,
|
||||||
|
message: Some(details.join(" · ")).filter(|value| !value.trim().is_empty()),
|
||||||
|
level: level_name(level).to_string(),
|
||||||
|
is_read: true,
|
||||||
|
related_id: None,
|
||||||
|
related_type: None,
|
||||||
|
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Vec<RuntimeLogEntry> {
|
||||||
|
RUNTIME_LOGS.read().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear() {
|
||||||
|
RUNTIME_LOGS.write().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(entry: RuntimeLogEntry) {
|
||||||
|
let mut logs = RUNTIME_LOGS.write();
|
||||||
|
if logs.len() >= MAX_RUNTIME_LOGS {
|
||||||
|
logs.pop_front();
|
||||||
|
}
|
||||||
|
logs.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_name(level: &Level) -> &'static str {
|
||||||
|
match *level {
|
||||||
|
Level::ERROR => "error",
|
||||||
|
Level::WARN => "warning",
|
||||||
|
_ => "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct LogFieldVisitor {
|
||||||
|
message: Option<String>,
|
||||||
|
fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFieldVisitor {
|
||||||
|
fn record_value(&mut self, field: &Field, value: String) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = Some(value);
|
||||||
|
} else {
|
||||||
|
self.fields.push(format!("{}={}", field.name(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visit for LogFieldVisitor {
|
||||||
|
fn record_str(&mut self, field: &Field, value: &str) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
||||||
|
self.record_value(field, normalize_debug_value(format!("{value:?}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_debug_value(value: String) -> String {
|
||||||
|
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
|
||||||
|
value[1..value.len() - 1].replace("\\\"", "\"")
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_log_layer_captures_info_events() {
|
||||||
|
clear();
|
||||||
|
let subscriber = tracing_subscriber::registry().with(RuntimeLogLayer);
|
||||||
|
|
||||||
|
tracing::subscriber::with_default(subscriber, || {
|
||||||
|
tracing::info!(key_id = 42, "Built-in key ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
let logs = list();
|
||||||
|
let entry = logs.last().expect("runtime log entry");
|
||||||
|
assert_eq!(entry.event_type, "runtime");
|
||||||
|
assert_eq!(entry.title, "Built-in key ready");
|
||||||
|
assert_eq!(entry.level, "info");
|
||||||
|
assert_eq!(entry.is_read, true);
|
||||||
|
assert!(entry
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("key_id=42"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'proxy', label: '代理' },
|
{ value: 'proxy', label: '代理' },
|
||||||
{ value: 'status', label: '状态' },
|
{ value: 'status', label: '状态' },
|
||||||
{ value: 'site_notice', label: '站点公告' },
|
{ value: 'site_notice', label: '站点公告' },
|
||||||
|
{ value: 'runtime', label: '运行日志' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function levelLabel(level: string) {
|
function levelLabel(level: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user