Skip to main content

yggdrasil/api/logs/
mod.rs

1//! 运行日志查看器接口。
2//!
3//! - 本文件:5 个 Leptos server function(查询 / 导出 / target 列表 / 设置读写),
4//!   双目标编译,DB 逻辑 gate 在 `#[cfg(feature = "server")]` 块内。
5//! - [`crate::api::logs::capture`]:进程内 tracing Layer(日志捕获 mpsc/broadcast),server-only。
6//! - [`crate::api::logs::sse`]:`GET /api/logs/stream` 实时流 axum handler,server-only。
7//!
8//! 数据源:logs 表(迁移 025),由 [`crate::tasks::log_writer`] 批量写入、
9//! [`crate::tasks::log_purge`] 按保留策略裁剪。
10
11// 与 settings 模块一致:Leptos `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
12#![allow(clippy::unused_unit, deprecated)]
13
14use crate::api::error::ApiError;
15use leptos::prelude::*;
16
17#[cfg(feature = "server")]
18use crate::api::auth::get_current_admin_user;
19#[cfg(feature = "server")]
20use crate::api::error::AppError;
21#[cfg(feature = "server")]
22use crate::db::pool::get_conn;
23// LogEntry 仅在 server 端的行映射里构造,wasm 端无人消费。
24#[cfg(feature = "server")]
25use crate::models::log::LogEntry;
26use crate::models::log::{LogSettings, LogsPage};
27
28#[cfg(feature = "server")]
29pub mod capture;
30#[cfg(feature = "server")]
31pub mod sse;
32
33/// 日志级别白名单(大写)。levels 空 vec = 全部级别。
34#[cfg(feature = "server")]
35pub(crate) const VALID_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
36
37/// `get_logs` 单页条数上限。
38#[cfg(feature = "server")]
39const MAX_PAGE_LIMIT: i32 = 500;
40
41/// `export_logs` 总行数上限。
42#[cfg(feature = "server")]
43const EXPORT_MAX_ROWS: i64 = 10_000;
44
45/// 规范化后的日志筛选条件(levels 已白名单校验,target/query 已 trim)。
46#[cfg(feature = "server")]
47struct LogFilter {
48    levels: Vec<String>,
49    target: Option<String>,
50    query: Option<String>,
51}
52
53#[cfg(feature = "server")]
54impl LogFilter {
55    /// 规范化 + 白名单校验:级别 trim 后大写,不在白名单内返回 BadRequest。
56    fn new(
57        levels: Vec<String>,
58        target: Option<String>,
59        query: Option<String>,
60    ) -> Result<Self, AppError> {
61        let levels: Vec<String> = levels
62            .iter()
63            .map(|l| l.trim().to_uppercase())
64            .filter(|l| !l.is_empty())
65            .collect();
66        for l in &levels {
67            if !VALID_LEVELS.contains(&l.as_str()) {
68                return Err(AppError::BadRequest(format!("非法日志级别: {l}")));
69            }
70        }
71        Ok(Self {
72            levels,
73            target: target
74                .map(|t| t.trim().to_string())
75                .filter(|t| !t.is_empty()),
76            query: query
77                .map(|q| q.trim().to_string())
78                .filter(|q| !q.is_empty()),
79        })
80    }
81
82    /// 生成 WHERE 片段与参数列表(参数按出现顺序编号,$1 起;全部参数化,无拼接值)。
83    /// 返回 (conditions, params),调用方追加自己的条件后 join 成 WHERE。
84    fn conditions(&self) -> (Vec<String>, Vec<&(dyn tokio_postgres::types::ToSql + Sync)>) {
85        let mut conditions: Vec<String> = Vec::new();
86        let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
87        if !self.levels.is_empty() {
88            params.push(&self.levels);
89            conditions.push(format!("level = ANY(${})", params.len()));
90        }
91        if let Some(t) = &self.target {
92            params.push(t);
93            conditions.push(format!("target = ${}", params.len()));
94        }
95        if let Some(q) = &self.query {
96            params.push(q);
97            conditions.push(format!("message ILIKE '%' || ${} || '%'", params.len()));
98        }
99        (conditions, params)
100    }
101}
102
103/// 把 conditions 拼成 WHERE 子句(无条件时为空串)。
104#[cfg(feature = "server")]
105fn where_clause(conditions: &[String]) -> String {
106    if conditions.is_empty() {
107        String::new()
108    } else {
109        format!("WHERE {}", conditions.join(" AND "))
110    }
111}
112
113/// 分页查询运行日志(id DESC 游标分页)。
114///
115/// - `levels`:级别白名单过滤,空 vec = 全部;
116/// - `target`:精确匹配 tracing target;
117/// - `query`:message 子串匹配(参数化 ILIKE);
118/// - `before_id`:游标(`WHERE id < before_id`),None = 从最新开始;
119/// - `limit`:clamp 到 1..=500。
120#[server(GetLogs, endpoint = "get_logs", input = leptos::server_fn::codec::Json)]
121pub async fn get_logs(
122    levels: Vec<String>,
123    target: Option<String>,
124    query: Option<String>,
125    before_id: Option<i64>,
126    limit: i32,
127) -> Result<LogsPage, ApiError> {
128    let _user = get_current_admin_user().await?;
129
130    #[cfg(feature = "server")]
131    {
132        let filter = LogFilter::new(levels, target, query)?;
133        let limit = limit.clamp(1, MAX_PAGE_LIMIT) as i64;
134        let client = get_conn().await.map_err(AppError::db_conn)?;
135
136        let (mut conditions, mut params) = filter.conditions();
137        if let Some(before) = &before_id {
138            params.push(before);
139            conditions.push(format!("id < ${}", params.len()));
140        }
141        params.push(&limit);
142        let limit_idx = params.len();
143
144        let rows = client
145            .query(
146                &format!(
147                    "SELECT id, ts, level, target, message FROM logs {} \
148                     ORDER BY id DESC LIMIT ${limit_idx}",
149                    where_clause(&conditions)
150                ),
151                &params,
152            )
153            .await
154            .map_err(AppError::query)?;
155
156        let entries: Vec<LogEntry> = rows
157            .iter()
158            .map(|r| LogEntry {
159                id: r.get(0),
160                ts: r.get(1),
161                level: r.get(2),
162                target: r.get(3),
163                message: r.get(4),
164            })
165            .collect();
166
167        // 满页才给游标:最后一行的 id 即下一页的 before_id。
168        let next_cursor = if entries.len() as i64 == limit {
169            entries.last().map(|e| e.id)
170        } else {
171            None
172        };
173
174        Ok(LogsPage {
175            entries,
176            next_cursor,
177            dropped: capture::dropped_count(),
178        })
179    }
180
181    #[cfg(not(feature = "server"))]
182    {
183        Ok(LogsPage {
184            entries: Vec::new(),
185            next_cursor: None,
186            dropped: 0,
187        })
188    }
189}
190
191/// 导出运行日志为纯文本(每行 `[RFC3339] LEVEL target: message`)。
192///
193/// 与 `get_logs` 同一筛选语义,按 id ASC(时间正序),上限 10000 行。
194#[server(ExportLogs, endpoint = "export_logs", input = leptos::server_fn::codec::Json)]
195pub async fn export_logs(
196    levels: Vec<String>,
197    target: Option<String>,
198    query: Option<String>,
199) -> Result<String, ApiError> {
200    let _user = get_current_admin_user().await?;
201
202    #[cfg(feature = "server")]
203    {
204        use std::fmt::Write as _;
205
206        let filter = LogFilter::new(levels, target, query)?;
207        let client = get_conn().await.map_err(AppError::db_conn)?;
208        let (conditions, params) = filter.conditions();
209
210        let rows = client
211            .query(
212                &format!(
213                    "SELECT id, ts, level, target, message FROM logs {} \
214                     ORDER BY id ASC LIMIT {EXPORT_MAX_ROWS}",
215                    where_clause(&conditions)
216                ),
217                &params,
218            )
219            .await
220            .map_err(AppError::query)?;
221
222        let mut out = String::new();
223        for r in &rows {
224            let ts: chrono::DateTime<chrono::Utc> = r.get(1);
225            let level: String = r.get(2);
226            let target: String = r.get(3);
227            let message: String = r.get(4);
228            // 写 String 不会失败,忽略 fmt::Result。
229            let _ = writeln!(
230                out,
231                "[{}] {} {}: {}",
232                ts.to_rfc3339(),
233                level,
234                target,
235                message
236            );
237        }
238        Ok(out)
239    }
240
241    #[cfg(not(feature = "server"))]
242    {
243        Ok(String::new())
244    }
245}
246
247/// 日志 target 去重列表(前端筛选项)。
248///
249/// 走 moka 缓存(60s TTL):DISTINCT 全表扫在日志表上不值得每开页面跑一次。
250#[server(GetLogTargets, endpoint = "get_log_targets", input = leptos::server_fn::codec::Json)]
251pub async fn get_log_targets() -> Result<Vec<String>, ApiError> {
252    let _user = get_current_admin_user().await?;
253
254    #[cfg(feature = "server")]
255    {
256        if let Some(cached) = crate::cache::get_log_targets().await {
257            return Ok(cached);
258        }
259        let client = get_conn().await.map_err(AppError::db_conn)?;
260        let rows = client
261            .query("SELECT DISTINCT target FROM logs ORDER BY target", &[])
262            .await
263            .map_err(AppError::query)?;
264        let targets: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
265        crate::cache::set_log_targets(targets.clone()).await;
266        Ok(targets)
267    }
268
269    #[cfg(not(feature = "server"))]
270    {
271        Ok(Vec::new())
272    }
273}
274
275/// 读取日志查看器配置。
276///
277/// settings 表缺失键时回退到默认值,保证向后兼容(trash 模式,不做 env 播种)。
278#[server(GetLogSettings, endpoint = "get_log_settings", input = leptos::server_fn::codec::Json)]
279pub async fn get_log_settings() -> Result<LogSettings, ApiError> {
280    let _user = get_current_admin_user().await?;
281
282    #[cfg(feature = "server")]
283    {
284        let client = get_conn().await.map_err(AppError::db_conn)?;
285
286        let retention_days: i32 = client
287            .query_opt(
288                "SELECT value FROM settings WHERE key = 'logs_retention_days'",
289                &[],
290            )
291            .await
292            .map_err(AppError::query)?
293            .and_then(|r| r.get::<_, String>("value").parse().ok())
294            .unwrap_or(crate::models::log::DEFAULT_LOGS_RETENTION_DAYS);
295
296        let max_rows: i32 = client
297            .query_opt(
298                "SELECT value FROM settings WHERE key = 'logs_max_rows'",
299                &[],
300            )
301            .await
302            .map_err(AppError::query)?
303            .and_then(|r| r.get::<_, String>("value").parse().ok())
304            .unwrap_or(crate::models::log::DEFAULT_LOGS_MAX_ROWS);
305
306        Ok(LogSettings {
307            retention_days: LogSettings::clamp_retention(retention_days),
308            max_rows: LogSettings::clamp_max_rows(max_rows),
309        })
310    }
311
312    #[cfg(not(feature = "server"))]
313    {
314        Ok(LogSettings::default())
315    }
316}
317
318/// 更新日志查看器配置。
319///
320/// 两个值都会被 clamp 到合法范围后写入。
321#[server(UpdateLogSettings, endpoint = "update_log_settings", input = leptos::server_fn::codec::Json)]
322pub async fn update_log_settings(
323    retention_days: i32,
324    max_rows: i32,
325) -> Result<LogSettings, ApiError> {
326    let _user = get_current_admin_user().await?;
327
328    let retention_days = LogSettings::clamp_retention(retention_days);
329    let max_rows = LogSettings::clamp_max_rows(max_rows);
330
331    #[cfg(feature = "server")]
332    {
333        let client = get_conn().await.map_err(AppError::db_conn)?;
334
335        crate::api::settings::upsert_setting(
336            &**client,
337            "logs_retention_days",
338            &retention_days.to_string(),
339        )
340        .await?;
341        crate::api::settings::upsert_setting(&**client, "logs_max_rows", &max_rows.to_string())
342            .await?;
343
344        // target 在 capture 排除名单内:此日志只到控制台,不进查看器管道。
345        tracing::info!(
346            "Log settings updated: retention_days={}, max_rows={}",
347            retention_days,
348            max_rows
349        );
350    }
351
352    Ok(LogSettings {
353        retention_days,
354        max_rows,
355    })
356}