Skip to main content

yggdrasil/pages/admin/logs/
stream.rs

1//! 日志页的日志流:历史加载、SSE 实时追加、向上翻页与滚动跟随。
2use 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
10/// 首屏历史加载条数。
11const INITIAL_LIMIT: i32 = 200;
12/// 「加载更早」单次翻页条数。
13const OLDER_PAGE_LIMIT: i32 = 200;
14/// 判定「贴底」的阈值(px):距底小于该值时新行自动跟随滚动。
15#[cfg(target_arch = "wasm32")]
16const STICK_THRESHOLD_PX: i32 = 48;
17/// 列表缓冲上限(行):超出时丢弃最旧行,保护 DOM 规模。
18#[cfg(target_arch = "wasm32")]
19const MAX_BUFFER_ROWS: usize = 2000;
20/// 触发上限时一次性裁掉的行数(留缓冲,避免每行都触发 drain)。
21#[cfg(target_arch = "wasm32")]
22const BUFFER_TRIM: usize = 500;
23
24/// 日志行:entry(历史 id>0 / 实时 id=0)或 gap 分隔提示。
25///
26/// `key` 是客户端单调序号(SSE 实时行 id 恒 0,不能用作渲染 key)。
27#[derive(Clone)]
28struct LogRow {
29    key: u64,
30    kind: LogRowKind,
31}
32
33#[derive(Clone)]
34enum LogRowKind {
35    Entry(LogEntry),
36    /// broadcast lagged 分隔提示;Some(n) 为服务端报告的丢弃条数。
37    // 只在浏览器端的 SSE 回调里构造,服务端构建下会报 dead_code。
38    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
39    Gap(Option<u64>),
40}
41
42/// 挂载时刻的筛选快照:面板按筛选 remount,历史查询、翻页与 SSE 都用同一份。
43#[derive(Clone)]
44struct StreamFilter {
45    levels: Vec<String>,
46    target: Option<String>,
47    query: String,
48}
49
50impl StreamFilter {
51    /// 空关键字按「无关键字」传给服务端。
52    fn query_opt(&self) -> Option<String> {
53        (!self.query.is_empty()).then(|| self.query.clone())
54    }
55}
56
57/// 分配客户端单调行 key(SSE 实时行 id 恒 0,不能用作渲染 key)。
58fn 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/// 实时日志流面板:历史加载 + SSE 追加 + 向上翻页 + 滚动跟随。
69///
70/// 父组件按筛选变化重建本组件(props 均为挂载时刻快照):卸载时 `on_cleanup`
71/// 关闭 EventSource,挂载时重新拉历史并重连。`follow` 是父组件的开关,
72/// `connected` / `dropped` 是与父组件共享的信号。
73#[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    // 贴底状态:用户上滑离开底部即暂停跟随,浮出「回到底部」。
95    let stick = RwSignal::new(true);
96    // prepend 后待补偿的滚动基准(prepend 前的 scrollHeight)。
97    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    // —— 生命周期:挂载拉历史 + 连 SSE;卸载 close EventSource ——
102    // 句柄含 JS 对象与闭包(非 Send),放进 local 存储;Drop 时关闭连接并释放回调。
103    #[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    // 无追踪信号,只在挂载后运行一次。
111    Effect::new(move |_| {
112        let snapshot = filter.get_value();
113        spawn_local(async move {
114            // remount 期间状态点回落为「未连接」,待 SSE open 后再置绿。
115            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            // 等待期间筛选变更会卸载本面板(信号已释放):直接收尾,不再建连。
125            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            // 历史落地后连 SSE 实时流;失败仅提示,不影响历史查看。
150            #[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    // 滚动跟随 / prepend 位移补偿:渲染后执行的真副作用(DOM 写入)。
158    // 订阅 rows / follow / stick / prepend_base——开启「自动跟随」会立即贴底。
159    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                // 向上 prepend 后补偿位移,保持视口锚定原内容。
168                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    // 「加载更早」:游标来自服务端 LogsPage.next_cursor。
182    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                // prepend 重叠去重:仅对历史行(id != 0)。
212                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            // SSE 建连失败提示条(历史仍可用)
246            {move || {
247                stream_error
248                    .get()
249                    .then(|| {
250                        view! {
251                            <p class="logs-banner" role="status">
252                                "实时流连接失败,只显示历史快照。"
253                            </p>
254                        }
255                    })
256            }}
257            // 日志滚动区(等宽字体行列表,新行自底部进入)
258            <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                // 向上翻页:加载更早
274                {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                // 首屏加载 / 错误 / 空态
294                {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            // 「回到底部」浮动按钮(离开底部时浮出)
327            {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
351/// 渲染一行日志或 gap 分隔提示。
352fn 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/// SSE 连接与回调闭包同生共死:drop 时关闭连接,闭包字段随之释放回调表槽位。
393#[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/// 建立 SSE 连接消费实时日志流。
410///
411/// `log` 事件(JSON LogEntry,id 恒 0)追加到列表尾部;`gap` 事件
412/// (broadcast lagged)插入分隔提示行;`open`/`error` 维护连接状态点。
413/// 浏览器在 `error` 后自动重连(重连丢段由服务端 `gap` 通知),
414/// 故 error 只标记断开、不 close。连接与回调闭包存入 `es_slot` 的 `LogStreamHandle`,
415/// 面板卸载(`on_cleanup`)或重连替换时 drop 句柄即关闭连接并释放闭包;
416/// 回调里的信号写入一律 `try_*`,卸载后的迟到事件不会触碰已释放的信号。
417#[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    // 注册失败的早退路径也要关闭半接线的连接。
442    let close_on_err = |e: wasm_bindgen::JsValue| {
443        es.close();
444        e
445    };
446
447    // open → 已连接
448    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    // log 事件 → 追加(实时行 id 恒 0:到达即追加,不去重、不作游标)
455    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                    // 缓冲上限:超出后裁掉最旧一段,保护 DOM 规模。
464                    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    // gap 事件 → 分隔提示行(broadcast lagged;data 为文本 "missed N log events")
475    let on_gap = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
476        // data 形如 "missed N log events",解析出 N 用于提示;解析失败则省略计数。
477        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    // error → 标记断开(浏览器自动重连,open 时恢复)
492    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    // 面板已卸载(槽位已释放)时 try_set_value 会把句柄原样退回,随即 drop 关闭连接。
499    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}