fix: show runtime logs in program logs

This commit is contained in:
boos
2026-06-06 16:40:55 +08:00
parent 023384b14b
commit 301d086cb6
5 changed files with 265 additions and 12 deletions
+6 -2
View File
@@ -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
View File
@@ -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 &params { for p in &params {
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),
}
}
}
+1
View File
@@ -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;
+174
View File
@@ -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"));
}
}
+1
View File
@@ -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) {