1#![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#[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#[cfg(feature = "server")]
35pub(crate) const VALID_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
36
37#[cfg(feature = "server")]
39const MAX_PAGE_LIMIT: i32 = 500;
40
41#[cfg(feature = "server")]
43const EXPORT_MAX_ROWS: i64 = 10_000;
44
45#[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 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 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#[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#[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 ¶ms,
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 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#[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 ¶ms,
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 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#[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#[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#[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 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}