yggdrasil/pages/admin/logs/
stream.rs1use crate::api::logs::get_logs;
3use crate::components::skeletons::{atoms::SkeletonBox, delayed_skeleton::DelayedSkeleton};
4use crate::components::ui::{button_class, ButtonSize, ButtonVariant};
5use crate::models::log::LogEntry;
6use crate::utils::time::{hms_millis, to_local};
7use leptos::prelude::*;
8use leptos::task::spawn_local;
9
10const INITIAL_LIMIT: i32 = 200;
12const OLDER_PAGE_LIMIT: i32 = 200;
14#[cfg(target_arch = "wasm32")]
16const STICK_THRESHOLD_PX: i32 = 48;
17#[cfg(target_arch = "wasm32")]
19const MAX_BUFFER_ROWS: usize = 2000;
20#[cfg(target_arch = "wasm32")]
22const BUFFER_TRIM: usize = 500;
23
24#[derive(Clone)]
28struct LogRow {
29 key: u64,
30 kind: LogRowKind,
31}
32
33#[derive(Clone)]
34enum LogRowKind {
35 Entry(LogEntry),
36 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
39 Gap(Option<u64>),
40}
41
42#[derive(Clone)]
44struct StreamFilter {
45 levels: Vec<String>,
46 target: Option<String>,
47 query: String,
48}
49
50impl StreamFilter {
51 fn query_opt(&self) -> Option<String> {
53 (!self.query.is_empty()).then(|| self.query.clone())
54 }
55}
56
57fn next_row_key(counter: StoredValue<u64>) -> u64 {
59 counter
60 .try_update_value(|next| {
61 let key = *next;
62 *next += 1;
63 key
64 })
65 .unwrap_or(0)
66}
67
68#[component]
74pub(super) fn LogsStreamPane(
75 levels: Vec<String>,
76 target: Option<String>,
77 query: String,
78 #[prop(into)] follow: Signal<bool>,
79 connected: RwSignal<bool>,
80 dropped: RwSignal<u64>,
81 #[prop(into)] on_pick_target: Callback<String>,
82) -> impl IntoView {
83 let filter = StoredValue::new(StreamFilter {
84 levels,
85 target,
86 query,
87 });
88 let rows = RwSignal::new(Vec::<LogRow>::new());
89 let next_cursor = RwSignal::new(None::<i64>);
90 let initial_loaded = RwSignal::new(false);
91 let load_error = RwSignal::new(false);
92 let stream_error = RwSignal::new(false);
93 let loading_older = RwSignal::new(false);
94 let stick = RwSignal::new(true);
96 let prepend_base = RwSignal::new(None::<i32>);
98 let row_key = StoredValue::new(1_u64);
99 let scroll_ref = NodeRef::<leptos::html::Div>::new();
100
101 #[cfg(target_arch = "wasm32")]
104 let es_slot = StoredValue::<Option<LogStreamHandle>, LocalStorage>::new_local(None);
105 #[cfg(target_arch = "wasm32")]
106 on_cleanup(move || {
107 let _ = es_slot.try_update_value(Option::take);
108 });
109
110 Effect::new(move |_| {
112 let snapshot = filter.get_value();
113 spawn_local(async move {
114 connected.try_set(false);
116 let result = get_logs(
117 snapshot.levels.clone(),
118 snapshot.target.clone(),
119 snapshot.query_opt(),
120 None,
121 INITIAL_LIMIT,
122 )
123 .await;
124 if rows.try_with_untracked(|_| ()).is_none() {
126 return;
127 }
128 match result {
129 Ok(page) => {
130 dropped.try_set(page.dropped);
131 next_cursor.try_set(page.next_cursor);
132 let mut entries = page.entries;
133 entries.sort_by_key(|e| e.id);
134 rows.try_set(
135 entries
136 .into_iter()
137 .map(|e| LogRow {
138 key: next_row_key(row_key),
139 kind: LogRowKind::Entry(e),
140 })
141 .collect(),
142 );
143 }
144 Err(_) => {
145 load_error.try_set(true);
146 }
147 }
148 initial_loaded.try_set(true);
149 #[cfg(target_arch = "wasm32")]
151 if open_log_stream(&snapshot, es_slot, rows, row_key, connected).is_err() {
152 stream_error.try_set(true);
153 }
154 });
155 });
156
157 Effect::new(move |_| {
160 rows.with(Vec::len);
161 let follow_now = follow.get();
162 let stick_now = stick.get();
163 let pending = prepend_base.get();
164 #[cfg(target_arch = "wasm32")]
165 if let Some(el) = scroll_ref.get() {
166 if let Some(base) = pending {
167 let delta = el.scroll_height() - base;
169 if delta > 0 {
170 el.set_scroll_top(el.scroll_top() + delta);
171 }
172 prepend_base.set(None);
173 } else if follow_now && stick_now {
174 el.set_scroll_top(el.scroll_height());
175 }
176 }
177 #[cfg(not(target_arch = "wasm32"))]
178 let _ = (follow_now, stick_now, pending);
179 });
180
181 let load_older = move |_| {
183 let Some(cursor) = next_cursor.get_untracked() else {
184 return;
185 };
186 if loading_older.get_untracked() {
187 return;
188 }
189 loading_older.set(true);
190 #[cfg(target_arch = "wasm32")]
191 let base = scroll_ref
192 .get_untracked()
193 .map(|el| el.scroll_height())
194 .unwrap_or(0);
195 #[cfg(not(target_arch = "wasm32"))]
196 let base = 0;
197 let snapshot = filter.get_value();
198 spawn_local(async move {
199 if let Ok(page) = get_logs(
200 snapshot.levels.clone(),
201 snapshot.target.clone(),
202 snapshot.query_opt(),
203 Some(cursor),
204 OLDER_PAGE_LIMIT,
205 )
206 .await
207 {
208 next_cursor.try_set(page.next_cursor);
209 let mut older = page.entries;
210 older.sort_by_key(|e| e.id);
211 let merged = rows
213 .try_with_untracked(|current| {
214 let mut merged: Vec<LogRow> = older
215 .into_iter()
216 .filter(|e| {
217 e.id == 0
218 || !current.iter().any(|r| {
219 matches!(&r.kind, LogRowKind::Entry(cur) if cur.id == e.id)
220 })
221 })
222 .map(|e| LogRow {
223 key: next_row_key(row_key),
224 kind: LogRowKind::Entry(e),
225 })
226 .collect();
227 if merged.is_empty() {
228 return None;
229 }
230 merged.extend(current.iter().cloned());
231 Some(merged)
232 })
233 .flatten();
234 if let Some(merged) = merged {
235 rows.try_set(merged);
236 prepend_base.try_set(Some(base));
237 }
238 }
239 loading_older.try_set(false);
240 });
241 };
242
243 view! {
244 <section class="admin-panel logs-pane" aria-label="日志">
245 {move || {
247 stream_error
248 .get()
249 .then(|| {
250 view! {
251 <p class="logs-banner" role="status">
252 "实时流连接失败,只显示历史快照。"
253 </p>
254 }
255 })
256 }}
257 <div
259 node_ref=scroll_ref
260 data-vt-scroll="admin-logs"
261 class="logs-scroll"
262 on:scroll=move |_| {
263 #[cfg(target_arch = "wasm32")]
264 if let Some(el) = scroll_ref.get_untracked() {
265 let near_bottom = el.scroll_height() - el.scroll_top() - el.client_height()
266 < STICK_THRESHOLD_PX;
267 if near_bottom != stick.get_untracked() {
268 stick.set(near_bottom);
269 }
270 }
271 }
272 >
273 {move || {
275 next_cursor
276 .get()
277 .is_some()
278 .then(|| {
279 view! {
280 <div class="logs-older">
281 <button
282 type="button"
283 class=button_class(ButtonVariant::Ghost, ButtonSize::Xs)
284 disabled=move || loading_older.get()
285 on:click=load_older
286 >
287 {move || if loading_older.get() { "加载中…" } else { "加载更早" }}
288 </button>
289 </div>
290 }
291 })
292 }}
293 {move || {
295 let empty = rows.with(Vec::is_empty);
296 if !initial_loaded.get() {
297 view! {
298 <DelayedSkeleton>
299 <div class="logs-skeleton">
300 <SkeletonBox class="h-4 w-[88%] rounded"/>
301 <SkeletonBox class="h-4 w-[72%] rounded"/>
302 <SkeletonBox class="h-4 w-[81%] rounded"/>
303 <SkeletonBox class="h-4 w-[64%] rounded"/>
304 <SkeletonBox class="h-4 w-[77%] rounded"/>
305 </div>
306 </DelayedSkeleton>
307 }
308 .into_any()
309 } else if load_error.get() && empty {
310 view! {
311 <p class="logs-empty" data-error="true">
312 "历史日志加载失败,请调整筛选后重试。"
313 </p>
314 }
315 .into_any()
316 } else if empty {
317 view! { <p class="logs-empty">"当前筛选条件下没有日志。"</p> }.into_any()
318 } else {
319 ().into_any()
320 }
321 }}
322 <For each=move || rows.get() key=|row| row.key let:row>
323 {render_row(row, on_pick_target)}
324 </For>
325 </div>
326 {move || {
328 (!stick.get())
329 .then(|| {
330 view! {
331 <button
332 type="button"
333 class="logs-to-bottom"
334 on:click=move |_| {
335 #[cfg(target_arch = "wasm32")]
336 if let Some(el) = scroll_ref.get_untracked() {
337 el.set_scroll_top(el.scroll_height());
338 }
339 stick.set(true);
340 }
341 >
342 "回到底部"
343 </button>
344 }
345 })
346 }}
347 </section>
348 }
349}
350
351fn render_row(row: LogRow, on_pick_target: Callback<String>) -> AnyView {
353 match row.kind {
354 LogRowKind::Gap(missed) => {
355 let gap_text = match missed {
356 Some(n) => format!("…部分日志已跳过({n} 条)…"),
357 None => "…部分日志已跳过…".to_string(),
358 };
359 view! { <div class="logs-gap">{gap_text}</div> }.into_any()
360 }
361 LogRowKind::Entry(entry) => {
362 let time_hms = hms_millis(&to_local(entry.ts));
363 let time_full = to_local(entry.ts).to_rfc3339();
364 let LogEntry {
365 level,
366 target,
367 message,
368 ..
369 } = entry;
370 let target_name = target.clone();
371 let row_level = level.clone();
372 view! {
373 <div class="logs-row" data-level=row_level>
374 <time title=time_full>{time_hms}</time>
375 <span class="logs-level">{level}</span>
376 <button
377 type="button"
378 class="logs-target"
379 title="只看这个 target"
380 on:click=move |_| on_pick_target.run(target_name.clone())
381 >
382 {target}
383 </button>
384 <span class="logs-message">{message}</span>
385 </div>
386 }
387 .into_any()
388 }
389 }
390}
391
392#[cfg(target_arch = "wasm32")]
394struct LogStreamHandle {
395 es: web_sys::EventSource,
396 _on_open: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
397 _on_log: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
398 _on_gap: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::MessageEvent)>,
399 _on_error: wasm_bindgen::closure::Closure<dyn FnMut(web_sys::Event)>,
400}
401
402#[cfg(target_arch = "wasm32")]
403impl Drop for LogStreamHandle {
404 fn drop(&mut self) {
405 self.es.close();
406 }
407}
408
409#[cfg(target_arch = "wasm32")]
418fn open_log_stream(
419 filter: &StreamFilter,
420 es_slot: StoredValue<Option<LogStreamHandle>, LocalStorage>,
421 rows: RwSignal<Vec<LogRow>>,
422 row_key: StoredValue<u64>,
423 connected: RwSignal<bool>,
424) -> Result<(), wasm_bindgen::JsValue> {
425 use wasm_bindgen::closure::Closure;
426 use wasm_bindgen::JsCast;
427 use web_sys::{EventSource, MessageEvent};
428
429 let mut url = format!("/api/logs/stream?levels={}", filter.levels.join(","));
430 if let Some(t) = &filter.target {
431 if !t.is_empty() {
432 url.push_str("&target=");
433 url.push_str(&String::from(js_sys::encode_uri_component(t)));
434 }
435 }
436 if !filter.query.is_empty() {
437 url.push_str("&q=");
438 url.push_str(&String::from(js_sys::encode_uri_component(&filter.query)));
439 }
440 let es = EventSource::new(&url)?;
441 let close_on_err = |e: wasm_bindgen::JsValue| {
443 es.close();
444 e
445 };
446
447 let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
449 connected.try_set(true);
450 });
451 es.add_event_listener_with_callback("open", on_open.as_ref().unchecked_ref())
452 .map_err(close_on_err)?;
453
454 let on_log = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
456 if let Some(s) = e.data().as_string() {
457 if let Ok(entry) = serde_json::from_slice::<LogEntry>(s.as_bytes()) {
458 rows.try_update(|list| {
459 list.push(LogRow {
460 key: next_row_key(row_key),
461 kind: LogRowKind::Entry(entry),
462 });
463 if list.len() > MAX_BUFFER_ROWS {
465 list.drain(..BUFFER_TRIM);
466 }
467 });
468 }
469 }
470 });
471 es.add_event_listener_with_callback("log", on_log.as_ref().unchecked_ref())
472 .map_err(close_on_err)?;
473
474 let on_gap = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
476 let missed = e
478 .data()
479 .as_string()
480 .and_then(|s| s.split_whitespace().nth(1)?.parse::<u64>().ok());
481 rows.try_update(|list| {
482 list.push(LogRow {
483 key: next_row_key(row_key),
484 kind: LogRowKind::Gap(missed),
485 });
486 });
487 });
488 es.add_event_listener_with_callback("gap", on_gap.as_ref().unchecked_ref())
489 .map_err(close_on_err)?;
490
491 let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
493 connected.try_set(false);
494 });
495 es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())
496 .map_err(close_on_err)?;
497
498 es_slot.try_set_value(Some(LogStreamHandle {
500 es,
501 _on_open: on_open,
502 _on_log: on_log,
503 _on_gap: on_gap,
504 _on_error: on_error,
505 }));
506 Ok(())
507}