Skip to main content

yggdrasil/pages/admin/
logs.rs

1//! 管理后台「运行日志」页面(`/admin/logs`,侧边栏「工具」组)。
2//!
3//! 数据流:筛选变更(级别 chips / target 选择器 / 关键字)→ 变更 `filter_key` →
4//! `LogsStreamPane` key-based remount(同 runner.rs 语言切换模式):卸载即
5//! `use_drop` close EventSource,挂载即重新拉历史(`get_logs` limit 200)并
6//! 重连 SSE `/api/logs/stream`。SSE 实时行追加到列表尾部并按 id 去重——注意
7//! 实时事件 id 恒为 0(未落库无 id,见 backend 契约),故去重只对历史行
8//! (id != 0)生效,实时行到达即追加、绝不参与游标计算。
9//!
10//! 渲染纯度:render body 无任何副作用;网络 / DOM / EventSource 全部在
11//! `use_effect` / 事件闭包 / `#[cfg(target_arch = "wasm32")]` 门控块内。
12
13use dioxus::prelude::*;
14
15// server function 仅在 WASM 事件闭包内调用(SSR 下 unused),按项目惯例放行。
16#[allow(unused_imports)]
17use crate::api::logs::{
18    export_logs, get_log_settings, get_log_targets, get_logs, update_log_settings,
19};
20use crate::components::forms::{FormInput, ToggleSwitch, INPUT_INLINE_CLASS};
21use crate::components::skeletons::atoms::SkeletonBox;
22use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
23use crate::components::ui::{CollapsibleSettingsCard, LoadingButton, BADGE_BASE, BTN_GHOST};
24use crate::models::log::{LogEntry, LogSettings};
25
26/// 全部可选级别(多选 chips,顺序即展示与服务端白名单顺序)。
27const ALL_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
28/// 首屏历史加载条数。
29/// 以下常量与 LogRowKind 仅在 wasm32 门控块内构造(SSR 不加载数据),
30/// server target 会报 dead_code,按本文件组件同款 cfg_attr 放行。
31#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
32const INITIAL_LIMIT: i32 = 200;
33/// 「加载更早」单次翻页条数。
34#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
35const OLDER_PAGE_LIMIT: i32 = 200;
36/// 判定「贴底」的阈值(px):距底小于该值时新行自动跟随滚动。
37#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
38const STICK_THRESHOLD_PX: i32 = 48;
39/// 列表缓冲上限(行):超出时丢弃最旧行,保护 DOM 规模。
40#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
41const MAX_BUFFER_ROWS: usize = 2000;
42/// 触发上限时一次性裁掉的行数(留缓冲,避免每行都触发 drain)。
43#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
44const BUFFER_TRIM: usize = 500;
45
46/// LogsStreamPane 实例计数器(滚动容器 id 跨实例全局唯一,同 FormSelect 模式)。
47static LOG_STREAM_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
48
49/// 运行日志页面:页头(状态/跟随/导出)+ 筛选栏 + 清理策略 + 实时日志流。
50///
51/// internal-scroll 路由(AdminLayout 已切到卡片不滚动变体):本页自组织
52/// 「固定页头/筛选 + flex-1 内部滚动日志区」的分区布局,同 settings 页模式。
53#[component]
54#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
55pub fn Logs() -> Element {
56    // —— 已应用筛选(任何变化都会 remount 下方 LogsStreamPane)——
57    let mut levels: Signal<Vec<&'static str>> = use_signal(|| vec!["ERROR", "WARN", "INFO"]);
58    let mut target: Signal<Option<String>> = use_signal(|| None);
59    let mut query: Signal<String> = use_signal(String::new);
60    // 关键字草稿:Enter 提交、清空即时生效,避免每击键都重连 SSE。
61    let mut query_draft: Signal<String> = use_signal(String::new);
62
63    // —— 页头状态 ——
64    let mut follow: Signal<bool> = use_signal(|| true);
65    let connected: Signal<bool> = use_signal(|| false);
66    let dropped: Signal<u64> = use_signal(|| 0);
67    let mut exporting: Signal<bool> = use_signal(|| false);
68
69    // target 候选列表:挂载后拉取一次(服务端 moka 缓存 60s;筛选 remount
70    // 只重建子面板,本页不重拉)。
71    let mut targets: Signal<Vec<String>> = use_signal(Vec::new);
72    use_effect(move || {
73        #[cfg(target_arch = "wasm32")]
74        spawn(async move {
75            if let Ok(list) = get_log_targets().await {
76                targets.set(list);
77            }
78        });
79    });
80
81    // 导出反馈提示条(ygg-toast 高度过渡 + 3s 自动消失,同 settings/profile 模式)。
82    let mut toast_state: Signal<Option<(String, bool)>> = use_signal(|| None);
83    let mut display_msg: Signal<String> = use_signal(String::new);
84    let mut display_err: Signal<bool> = use_signal(|| false);
85    use_effect(move || {
86        if let Some((msg, is_err)) = toast_state() {
87            display_msg.set(msg.clone());
88            display_err.set(is_err);
89            let key = msg.clone();
90            spawn(async move {
91                crate::utils::time::sleep_ms(3000).await;
92                // 仅当未被新提示覆盖时清除。
93                if toast_state().map(|(m, _)| m == key).unwrap_or(false) {
94                    toast_state.set(None);
95                }
96            });
97        }
98    });
99
100    // 行内 target 点击 → 设为 target 筛选(触发 remount)。
101    let on_pick_target: Callback<String> = Callback::new(move |t: String| target.set(Some(t)));
102
103    // 筛选签名:作为 remount key(级别顺序固定、target/query 原样拼接)。
104    let filter_key = format!(
105        "{}|{}|{}",
106        levels().join(","),
107        target().unwrap_or_default(),
108        query()
109    );
110
111    let subtitle = if dropped() > 0 {
112        format!(
113            "进程内结构化日志 · 历史查询 + 实时流 · 启动以来丢弃 {} 条",
114            dropped()
115        )
116    } else {
117        "进程内结构化日志 · 历史查询 + 实时流".to_string()
118    };
119
120    rsx! {
121        div { class: "animate-page-enter w-full flex-1 min-h-0 flex flex-col px-6 py-8",
122            // 页头(固定,不随日志区滚动)
123            div { class: "flex-shrink-0 flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70 mb-4",
124                div {
125                    h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
126                        "运行日志"
127                    }
128                    p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5", "{subtitle}" }
129                }
130                div { class: "flex items-center gap-3 flex-wrap",
131                    // 实时连接状态指示(绿 = 已连接 / 灰 = 断开)
132                    div { class: "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs bg-[var(--color-paper-entry)] text-[var(--color-paper-secondary)] border border-[var(--color-paper-border)]/70 shadow-2xs",
133                        span {
134                            class: if connected() { "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" } else { "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-tertiary)]" },
135                        }
136                        span { if connected() { "已连接" } else { "未连接" } }
137                    }
138                    // 自动跟随开关
139                    label { class: "flex items-center gap-2 cursor-pointer select-none",
140                        span { class: "text-xs text-[var(--color-paper-secondary)]", "自动跟随" }
141                        ToggleSwitch {
142                            checked: follow(),
143                            ontoggle: move |_| follow.set(!follow()),
144                        }
145                    }
146                    // 导出按钮:按当前筛选导出为 .log 文件
147                    LoadingButton {
148                        label: "导出日志".to_string(),
149                        loading: exporting(),
150                        variant: "sm",
151                        onclick: move |_| {
152                            #[cfg(target_arch = "wasm32")]
153                            {
154                                if *exporting.peek() {
155                                    return;
156                                }
157                                exporting.set(true);
158                                let lv: Vec<String> = levels().iter().map(|s| s.to_string()).collect();
159                                let t = target();
160                                let q = query();
161                                spawn(async move {
162                                    match export_logs(lv, t, if q.is_empty() { None } else { Some(q) }).await {
163                                        Ok(content) => {
164                                            let count = content.lines().count();
165                                            download_text_file(&log_export_filename(), &content);
166                                            toast_state.set(Some((format!("已导出 {count} 条日志"), false)));
167                                        }
168                                        Err(_) => {
169                                            toast_state.set(Some(("日志导出失败,请重试".to_string(), true)));
170                                        }
171                                    }
172                                    exporting.set(false);
173                                });
174                            }
175                        },
176                    }
177                }
178            }
179
180            // 操作提示条(导出反馈)
181            div { class: if toast_state().is_some() { "ygg-toast is-open" } else { "ygg-toast" },
182                div { class: if display_err() { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300" } else { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" },
183                    "{display_msg()}"
184                }
185            }
186
187            // 筛选栏(固定):级别多选 chips + target 选择器 + 关键字
188            div { class: "flex-shrink-0 bg-[var(--color-paper-entry)]/40 rounded-2xl border border-[var(--color-paper-border)]/70 shadow-xs p-4 sm:p-5 mb-4 space-y-4",
189                div { class: "flex flex-wrap items-center gap-2",
190                    for level in ALL_LEVELS {
191                        button {
192                            key: "{level}",
193                            r#type: "button",
194                            class: if levels().contains(&level) { "inline-flex items-center px-3 py-1 text-xs font-mono font-medium rounded-full transition-all cursor-pointer bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] shadow-2xs" } else { "inline-flex items-center px-3 py-1 text-xs font-mono font-medium rounded-full transition-all cursor-pointer text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-theme)] hover:text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/70" },
195                            onclick: move |_| {
196                                levels.with_mut(|sel| {
197                                    if let Some(pos) = sel.iter().position(|l| *l == level) {
198                                        sel.remove(pos);
199                                    } else {
200                                        sel.push(level);
201                                        // 保持 ALL_LEVELS  canonical 顺序。
202                                        sel.sort_by_key(|l| {
203                                            ALL_LEVELS.iter().position(|a| a == l).unwrap_or(usize::MAX)
204                                        });
205                                    }
206                                });
207                            },
208                            "{level}"
209                        }
210                    }
211                    if levels().is_empty() {
212                        span { class: "text-xs text-[var(--color-paper-tertiary)]", "未选择级别时显示全部" }
213                    }
214                }
215                div { class: "flex flex-col sm:flex-row sm:items-center gap-3",
216                    TargetSelect {
217                        value: target(),
218                        options: targets(),
219                        onchange: move |v: Option<String>| target.set(v),
220                    }
221                    FormInput {
222                        r#type: "search",
223                        placeholder: "搜索日志内容,Enter 生效",
224                        value: query_draft(),
225                        class: Some(INPUT_INLINE_CLASS),
226                        oninput: move |v: String| {
227                            query_draft.set(v.clone());
228                            // 原生 search 清空按钮:清空即恢复无关键字。
229                            if v.is_empty() && !query().is_empty() {
230                                query.set(String::new());
231                            }
232                        },
233                        onkeydown: move |e: KeyboardEvent| {
234                            if e.key() == Key::Enter {
235                                query.set(query_draft());
236                            }
237                        },
238                    }
239                }
240            }
241
242            // 清理策略(折叠卡,固定区)
243            div { class: "flex-shrink-0 mb-4",
244                CleanupPolicy {}
245            }
246
247            // 筛选变更 → key-based remount:卸载旧面板(use_drop close
248            // EventSource)→ 挂载新面板(重拉历史 + 重连 SSE)。
249            for key in std::iter::once(filter_key) {
250                div { key: "{key}", class: "flex-1 min-h-0 flex flex-col",
251                    LogsStreamPane {
252                        levels: levels().iter().map(|s| s.to_string()).collect(),
253                        target: target(),
254                        query: query(),
255                        follow,
256                        connected,
257                        dropped,
258                        on_pick_target,
259                    }
260                }
261            }
262        }
263    }
264}
265
266/// 清理策略子组件:保留天数 + 最大行数,复用共享可折叠设置卡片。
267///
268/// 模式镜像 posts_trash.rs `AutoPurgeSettings`:草稿信号 + `use_memo` dirty +
269/// LoadingButton 保存 + just_saved 反馈,加载/保存均在组件内部完成。
270#[component]
271#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
272fn CleanupPolicy() -> Element {
273    let mut saved: Signal<LogSettings> = use_signal(LogSettings::default);
274    let mut draft_days: Signal<String> = use_signal(|| "7".to_string());
275    let mut draft_rows: Signal<String> = use_signal(|| "100000".to_string());
276    let mut saving: Signal<bool> = use_signal(|| false);
277    // 保存成功后的短暂反馈标记(用户再次编辑时清除)。
278    let mut just_saved: Signal<bool> = use_signal(|| false);
279
280    // 挂载即拉取一次服务端配置(缺键时服务端回退默认值)。
281    use_effect(move || {
282        #[cfg(target_arch = "wasm32")]
283        spawn(async move {
284            if let Ok(s) = get_log_settings().await {
285                draft_days.set(s.retention_days.to_string());
286                draft_rows.set(s.max_rows.to_string());
287                saved.set(s);
288            }
289        });
290    });
291
292    // 草稿相对已保存配置是否存在差异(use_memo:依赖不变不重算 parse)。
293    let dirty = use_memo(move || {
294        let days_diff = draft_days()
295            .trim()
296            .parse::<i32>()
297            .ok()
298            .map(|d| d != saved().retention_days)
299            .unwrap_or(true);
300        let rows_diff = draft_rows()
301            .trim()
302            .parse::<i32>()
303            .ok()
304            .map(|r| r != saved().max_rows)
305            .unwrap_or(true);
306        days_diff || rows_diff
307    });
308
309    rsx! {
310        CollapsibleSettingsCard {
311            title: "清理策略".to_string(),
312            summary: format!("保留 {} 天 · 上限 {} 行", saved().retention_days, saved().max_rows),
313            enabled: true,
314            on_toggle: move |_| just_saved.set(false),
315            div { class: "border-t border-paper-border p-5 space-y-6",
316                // 保留天数行
317                div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-3",
318                    div { class: "min-w-0",
319                        div { class: "text-sm font-medium text-paper-primary", "保留天数" }
320                        div { class: "text-xs text-paper-secondary mt-1",
321                            "超过保留期的日志将被后台任务自动清理(1–90 天)"
322                        }
323                    }
324                    div { class: "flex items-center gap-2 flex-shrink-0",
325                        div { class: "w-28",
326                            FormInput {
327                                r#type: "number",
328                                placeholder: "7",
329                                value: draft_days(),
330                                oninput: move |v: String| {
331                                    draft_days.set(v);
332                                    just_saved.set(false);
333                                },
334                            }
335                        }
336                        span { class: "text-xs text-paper-secondary", "天" }
337                    }
338                }
339
340                // 最大行数行
341                div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-3",
342                    div { class: "min-w-0",
343                        div { class: "text-sm font-medium text-paper-primary", "最大行数" }
344                        div { class: "text-xs text-paper-secondary mt-1",
345                            "日志表行数上限,超出后按最旧优先裁剪(1000–1000000)"
346                        }
347                    }
348                    div { class: "flex items-center gap-2 flex-shrink-0",
349                        div { class: "w-28",
350                            FormInput {
351                                r#type: "number",
352                                placeholder: "100000",
353                                value: draft_rows(),
354                                oninput: move |v: String| {
355                                    draft_rows.set(v);
356                                    just_saved.set(false);
357                                },
358                            }
359                        }
360                        span { class: "text-xs text-paper-secondary", "行" }
361                    }
362                }
363
364                // 底部操作行:草稿状态提示 + 保存按钮
365                div { class: "flex items-center justify-between gap-4 pt-1",
366                    if just_saved() {
367                        span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
368                            svg {
369                                class: "w-3.5 h-3.5",
370                                view_box: "0 0 24 24",
371                                fill: "none",
372                                stroke: "currentColor",
373                                stroke_width: "2.5",
374                                path {
375                                    stroke_linecap: "round",
376                                    stroke_linejoin: "round",
377                                    d: "M5 13l4 4L19 7",
378                                }
379                            }
380                            "已保存"
381                        }
382                    } else if dirty() {
383                        span { class: "text-xs text-paper-secondary", "有未保存的更改" }
384                    } else {
385                        span { class: "text-xs text-transparent select-none", "·" }
386                    }
387                    LoadingButton {
388                        label: "保存设置".to_string(),
389                        loading: saving(),
390                        disabled: just_saved() || !dirty(),
391                        variant: "sm",
392                        onclick: move |_| {
393                            // 与服务端 clamp 一致的客户端预收敛(服务端仍会兜底)。
394                            let days = draft_days().trim().parse::<i32>().unwrap_or(7).clamp(1, 90);
395                            let max_rows = draft_rows()
396                                .trim()
397                                .parse::<i32>()
398                                .unwrap_or(100_000)
399                                .clamp(1000, 1_000_000);
400                            saving.set(true);
401                            spawn(async move {
402                                if let Ok(s) = update_log_settings(days, max_rows).await {
403                                    // 先拷出字段再 set(LogSettings 非 Copy,set 会 move)。
404                                    let saved_days = s.retention_days;
405                                    let saved_rows = s.max_rows;
406                                    saved.set(s);
407                                    draft_days.set(saved_days.to_string());
408                                    draft_rows.set(saved_rows.to_string());
409                                    just_saved.set(true);
410                                }
411                                saving.set(false);
412                            });
413                        },
414                    }
415                }
416            }
417        }
418    }
419}
420
421/// TargetSelect 实例计数器(跨实例全局唯一,同 FormSelect 模式)。
422static TARGET_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
423
424/// target 筛选选择器:触发器 + 下拉列表面板。
425///
426/// 选项为动态 target 列表(含「全部 target」清空项与暂无 target 提示),
427/// 列表项采用等宽字体展示 Rust module 路径。
428/// 触发器与面板尺寸、圆角、层级和动画完全对齐 [`FormSelect`] 与 [`INPUT_INLINE_CLASS`]。
429#[component]
430#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
431fn TargetSelect(
432    value: Option<String>,
433    options: Vec<String>,
434    onchange: EventHandler<Option<String>>,
435) -> Element {
436    let id_prefix = use_hook(|| TARGET_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
437    let trigger_id = format!("target-select-{id_prefix}");
438
439    #[cfg(target_arch = "wasm32")]
440    let trigger_id_click = trigger_id.clone();
441    #[cfg(target_arch = "wasm32")]
442    let trigger_id_keys = trigger_id.clone();
443
444    let mut open = use_signal(|| false);
445    #[allow(unused_mut)]
446    let mut flip_up = use_signal(|| false);
447
448    let current_label = value.clone().unwrap_or_else(|| "全部 target".to_string());
449    let chevron_rotate = if open() { "rotate-180" } else { "" };
450    let total_options = options.len() + 1;
451
452    let placement_cls = if flip_up() {
453        "bottom-full mb-1.5 origin-bottom"
454    } else {
455        "top-full mt-1.5 origin-top"
456    };
457
458    rsx! {
459        div { class: "relative flex-shrink-0 w-full sm:w-60",
460            button {
461                id: "{trigger_id}",
462                r#type: "button",
463                class: "relative inline-flex w-full items-center justify-between cursor-pointer select-none text-left pl-4 pr-10 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary hover:bg-paper-theme focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200",
464                aria_haspopup: "listbox",
465                aria_expanded: "{open()}",
466                aria_label: "按 target 筛选",
467                onclick: move |_| {
468                    if !open() {
469                        #[cfg(target_arch = "wasm32")]
470                        flip_up.set(crate::components::forms::measure_flip(&trigger_id_click, total_options));
471                        open.set(true);
472                    } else {
473                        open.set(false);
474                    }
475                },
476                onkeydown: move |e: KeyboardEvent| {
477                    let key = e.key();
478                    let is_space = matches!(&key, Key::Character(s) if s == " ");
479                    if !open() {
480                        if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter || is_space {
481                            e.prevent_default();
482                            #[cfg(target_arch = "wasm32")]
483                            flip_up.set(crate::components::forms::measure_flip(&trigger_id_keys, total_options));
484                            open.set(true);
485                        }
486                    } else if key == Key::Escape || key == Key::Tab {
487                        open.set(false);
488                    }
489                },
490                span { class: "truncate", "{current_label}" }
491                svg {
492                    class: "pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary transition-transform duration-200 {chevron_rotate}",
493                    view_box: "0 0 24 24",
494                    fill: "none",
495                    stroke: "currentColor",
496                    stroke_width: "2",
497                    path {
498                        stroke_linecap: "round",
499                        stroke_linejoin: "round",
500                        d: "M6 9l6 6 6-6",
501                    }
502                }
503            }
504
505            if open() {
506                // 透明遮罩:拦截外部点击关闭
507                div {
508                    class: "fixed inset-0 z-40",
509                    onclick: move |_| open.set(false),
510                }
511                ul {
512                    class: "absolute left-0 z-50 w-72 max-w-[calc(100vw_-_2rem)] max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-popover-enter-edge {placement_cls} flex flex-col gap-0.5",
513                    role: "listbox",
514                    aria_labelledby: "{trigger_id}",
515                    // 「全部」选项(清除 target 筛选)
516                    {
517                        let selected = value.is_none();
518                        rsx! {
519                            li {
520                                button {
521                                    r#type: "button",
522                                    class: if selected { "w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] text-paper-accent text-sm" } else { "w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-primary)] text-sm" },
523                                    role: "option",
524                                    aria_selected: "{selected}",
525                                    onmousedown: move |e| e.prevent_default(),
526                                    onclick: move |_| {
527                                        onchange.call(None);
528                                        open.set(false);
529                                    },
530                                    span { class: "truncate", "全部 target" }
531                                    if selected {
532                                        svg {
533                                            class: "w-4 h-4 flex-shrink-0 text-paper-accent",
534                                            view_box: "0 0 24 24",
535                                            fill: "none",
536                                            stroke: "currentColor",
537                                            stroke_width: "2.5",
538                                            path {
539                                                stroke_linecap: "round",
540                                                stroke_linejoin: "round",
541                                                d: "M5 13l4 4L19 7",
542                                            }
543                                        }
544                                    }
545                                }
546                            }
547                        }
548                    }
549                    if options.is_empty() {
550                        li { class: "px-3 py-2 text-xs text-[var(--color-paper-tertiary)] select-none",
551                            "暂无 target(日志落库后出现)"
552                        }
553                    }
554                    for t in options {
555                        {
556                            let selected = value.as_deref() == Some(t.as_str());
557                            let pick = t.clone();
558                            rsx! {
559                                li { key: "{t}",
560                                    button {
561                                        r#type: "button",
562                                        class: if selected { "w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] font-mono text-xs text-paper-accent" } else { "w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] font-mono text-xs text-[var(--color-paper-primary)]" },
563                                        role: "option",
564                                        aria_selected: "{selected}",
565                                        onmousedown: move |e| e.prevent_default(),
566                                        onclick: move |_| {
567                                            onchange.call(Some(pick.clone()));
568                                            open.set(false);
569                                        },
570                                        span { class: "truncate", "{t}" }
571                                        if selected {
572                                            svg {
573                                                class: "w-4 h-4 flex-shrink-0 text-paper-accent",
574                                                view_box: "0 0 24 24",
575                                                fill: "none",
576                                                stroke: "currentColor",
577                                                stroke_width: "2.5",
578                                                path {
579                                                    stroke_linecap: "round",
580                                                    stroke_linejoin: "round",
581                                                    d: "M5 13l4 4L19 7",
582                                                }
583                                            }
584                                        }
585                                    }
586                                }
587                            }
588                        }
589                    }
590                }
591            }
592        }
593    }
594}
595
596/// 日志行:entry(历史 id>0 / 实时 id=0)或 gap 分隔提示。
597///
598/// `key` 是客户端单调序号(SSE 实时行 id 恒 0,不能用作渲染 key)。
599#[derive(Clone)]
600struct LogRow {
601    key: u64,
602    kind: LogRowKind,
603}
604
605#[derive(Clone)]
606#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
607enum LogRowKind {
608    Entry(LogEntry),
609    /// broadcast lagged 分隔提示;Some(n) 为服务端报告的丢弃条数。
610    Gap(Option<u64>),
611}
612
613/// 实时日志流面板:历史加载 + SSE 追加 + 向上翻页 + 滚动跟随。
614///
615/// 以 `filter_key` remount 重建(props 均为挂载时刻快照);`follow` /
616/// `connected` / `dropped` 是与父组件共享的响应式信号。
617#[component]
618#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
619fn LogsStreamPane(
620    levels: Vec<String>,
621    target: Option<String>,
622    query: String,
623    follow: Signal<bool>,
624    connected: Signal<bool>,
625    dropped: Signal<u64>,
626    on_pick_target: Callback<String>,
627) -> Element {
628    let mut rows: Signal<Vec<LogRow>> = use_signal(Vec::new);
629    let mut next_cursor: Signal<Option<i64>> = use_signal(|| None);
630    let mut initial_loaded: Signal<bool> = use_signal(|| false);
631    let mut load_error: Signal<bool> = use_signal(|| false);
632    let mut stream_error: Signal<bool> = use_signal(|| false);
633    let mut loading_older: Signal<bool> = use_signal(|| false);
634    // 贴底状态:用户上滑离开底部即暂停跟随,浮出「回到底部」。
635    let mut stick: Signal<bool> = use_signal(|| true);
636    // prepend 后待补偿的滚动基准(prepend 前的 scrollHeight)。
637    let mut prepend_base: Signal<Option<i32>> = use_signal(|| None);
638
639    let scroll_id = use_hook(|| {
640        format!(
641            "logs-stream-{}",
642            LOG_STREAM_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
643        )
644    });
645
646    // —— WASM-only 生命周期:挂载拉历史 + 连 SSE;卸载 close EventSource ——
647    #[cfg(target_arch = "wasm32")]
648    let es_slot: std::rc::Rc<std::cell::RefCell<Option<web_sys::EventSource>>> =
649        use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(None)));
650    #[cfg(target_arch = "wasm32")]
651    let row_key: std::rc::Rc<std::cell::Cell<u64>> =
652        use_hook(|| std::rc::Rc::new(std::cell::Cell::new(1)));
653
654    // 挂载 effect 是 FnMut:spawn 内用的非 Copy 值必须在闭包体内 clone,
655    // 否则把捕获变量 move 进 async 块会让 effect 闭包退化为 FnOnce。
656    let levels_for_load = levels.clone();
657    let target_for_load = target.clone();
658    let query_for_load = query.clone();
659    // move 闭包按值捕获,es_slot/row_key 先克隆出 effect 专用副本,
660    // 保留原件供下方 use_drop 使用。
661    #[cfg(target_arch = "wasm32")]
662    let es_slot_for_effect = es_slot.clone();
663    #[cfg(target_arch = "wasm32")]
664    let row_key_for_effect = row_key.clone();
665    use_effect(move || {
666        #[cfg(target_arch = "wasm32")]
667        {
668            let levels = levels_for_load.clone();
669            let target = target_for_load.clone();
670            let query = query_for_load.clone();
671            let es_slot = es_slot_for_effect.clone();
672            let row_key = row_key_for_effect.clone();
673            spawn(async move {
674                // remount 期间状态点回落为「未连接」,待 SSE open 后再置绿。
675                connected.set(false);
676                let q = if query.is_empty() {
677                    None
678                } else {
679                    Some(query.clone())
680                };
681                match get_logs(levels.clone(), target.clone(), q, None, INITIAL_LIMIT).await {
682                    Ok(page) => {
683                        dropped.set(page.dropped);
684                        next_cursor.set(page.next_cursor);
685                        let mut entries = page.entries;
686                        entries.sort_by_key(|e| e.id);
687                        rows.set(
688                            entries
689                                .into_iter()
690                                .map(|e| LogRow {
691                                    key: next_row_key(&row_key),
692                                    kind: LogRowKind::Entry(e),
693                                })
694                                .collect(),
695                        );
696                    }
697                    Err(_) => load_error.set(true),
698                }
699                initial_loaded.set(true);
700                // 历史落地后连 SSE 实时流;失败仅提示,不影响历史查看。
701                if open_log_stream(&levels, &target, &query, es_slot, rows, row_key, connected)
702                    .is_err()
703                {
704                    stream_error.set(true);
705                }
706            });
707        }
708    });
709
710    #[cfg(target_arch = "wasm32")]
711    {
712        let es_slot_for_drop = es_slot.clone();
713        use_drop(move || {
714            if let Some(es) = es_slot_for_drop.borrow_mut().take() {
715                es.close();
716            }
717        });
718    }
719
720    // 滚动跟随 / prepend 位移补偿:渲染后执行的真副作用(DOM 写入)。
721    // 订阅 rows / follow / stick / prepend_base——开启「自动跟随」会立即贴底。
722    let scroll_id_for_effect = scroll_id.clone();
723    use_effect(move || {
724        let _row_count = rows.read().len();
725        let follow_now = follow();
726        let stick_now = stick();
727        let pending = prepend_base();
728        #[cfg(target_arch = "wasm32")]
729        {
730            if let Some(el) = get_scroll_el(&scroll_id_for_effect) {
731                if let Some(base) = pending {
732                    // 向上 prepend 后补偿位移,保持视口锚定原内容。
733                    let delta = el.scroll_height() - base;
734                    if delta > 0 {
735                        el.set_scroll_top(el.scroll_top() + delta);
736                    }
737                    prepend_base.set(None);
738                } else if follow_now && stick_now {
739                    el.set_scroll_top(el.scroll_height());
740                }
741            }
742        }
743        #[cfg(not(target_arch = "wasm32"))]
744        let _ = (follow_now, stick_now, pending);
745    });
746
747    #[cfg(target_arch = "wasm32")]
748    let scroll_id_for_scroll = scroll_id.clone();
749    #[cfg(target_arch = "wasm32")]
750    let scroll_id_for_older = scroll_id.clone();
751    #[cfg(target_arch = "wasm32")]
752    let scroll_id_for_back = scroll_id.clone();
753
754    // 「加载更早」onclick 是 FnMut:spawn 内用的非 Copy 值在闭包体内 clone。
755    #[cfg(target_arch = "wasm32")]
756    let levels_for_older = levels.clone();
757    #[cfg(target_arch = "wasm32")]
758    let target_for_older = target.clone();
759    #[cfg(target_arch = "wasm32")]
760    let query_for_older = query.clone();
761
762    let rows_snapshot = rows();
763
764    rsx! {
765        div { class: "relative flex-1 min-h-0 flex flex-col bg-[var(--color-paper-entry)]/40 rounded-2xl border border-[var(--color-paper-border)]/70 shadow-xs overflow-hidden",
766            // SSE 建连失败提示条(历史仍可用)
767            if stream_error() {
768                div { class: "flex-shrink-0 px-4 py-2 text-xs text-amber-600 dark:text-amber-400 border-b border-[var(--color-paper-border)]/40 bg-amber-500/5",
769                    "实时流连接失败,仅显示历史快照"
770                }
771            }
772
773            // 日志滚动区(等宽字体行列表,新行自底部进入)
774            div {
775                id: "{scroll_id}",
776                class: "flex-1 min-h-0 overflow-y-auto overscroll-contain py-1 font-mono text-xs leading-5",
777                onscroll: move |_| {
778                    #[cfg(target_arch = "wasm32")]
779                    {
780                        if let Some(el) = get_scroll_el(&scroll_id_for_scroll) {
781                            let near_bottom = el.scroll_height()
782                                - el.scroll_top()
783                                - el.client_height()
784                                < STICK_THRESHOLD_PX;
785                            if near_bottom != *stick.peek() {
786                                stick.set(near_bottom);
787                            }
788                        }
789                    }
790                },
791
792                // 向上翻页:加载更早(游标来自服务端 LogsPage.next_cursor)
793                if next_cursor().is_some() {
794                    div { class: "flex justify-center py-1.5 border-b border-[var(--color-paper-border)]/30 mb-1",
795                        button {
796                            r#type: "button",
797                            class: "{BTN_GHOST} font-sans",
798                            disabled: loading_older(),
799                            onclick: move |_| {
800                                #[cfg(target_arch = "wasm32")]
801                                {
802                                    let Some(cursor) = *next_cursor.peek() else { return };
803                                    if *loading_older.peek() {
804                                        return;
805                                    }
806                                    loading_older.set(true);
807                                    let base = get_scroll_el(&scroll_id_for_older)
808                                        .map(|el| el.scroll_height())
809                                        .unwrap_or(0);
810                                    let levels = levels_for_older.clone();
811                                    let target = target_for_older.clone();
812                                    let q = if query_for_older.is_empty() {
813                                        None
814                                    } else {
815                                        Some(query_for_older.clone())
816                                    };
817                                    let row_key = row_key.clone();
818                                    spawn(async move {
819                                        if let Ok(page) = get_logs(
820                                            levels, target, q, Some(cursor), OLDER_PAGE_LIMIT,
821                                        )
822                                        .await
823                                        {
824                                            next_cursor.set(page.next_cursor);
825                                            let mut older = page.entries;
826                                            older.sort_by_key(|e| e.id);
827                                            // prepend 重叠去重:仅对历史行(id != 0)。
828                                            older.retain(|e| {
829                                                e.id == 0
830                                                    || !rows.read().iter().any(|r| {
831                                                        matches!(&r.kind, LogRowKind::Entry(cur) if cur.id == e.id && e.id != 0)
832                                                    })
833                                            });
834                                            if !older.is_empty() {
835                                                let mut merged: Vec<LogRow> = older
836                                                    .into_iter()
837                                                    .map(|e| LogRow {
838                                                        key: next_row_key(&row_key),
839                                                        kind: LogRowKind::Entry(e),
840                                                    })
841                                                    .collect();
842                                                merged.extend(rows.read().iter().cloned());
843                                                rows.set(merged);
844                                                prepend_base.set(Some(base));
845                                            }
846                                        }
847                                        loading_older.set(false);
848                                    });
849                                }
850                            },
851                            if loading_older() { "加载中…" } else { "加载更早" }
852                        }
853                    }
854                }
855
856                // 首屏加载 / 错误 / 空态
857                if !initial_loaded() {
858                    DelayedSkeleton {
859                        div { class: "px-3 py-2 space-y-2.5",
860                            SkeletonBox { class: "h-4 w-[88%] rounded" }
861                            SkeletonBox { class: "h-4 w-[72%] rounded" }
862                            SkeletonBox { class: "h-4 w-[81%] rounded" }
863                            SkeletonBox { class: "h-4 w-[64%] rounded" }
864                            SkeletonBox { class: "h-4 w-[77%] rounded" }
865                        }
866                    }
867                } else if load_error() && rows_snapshot.is_empty() {
868                    div { class: "py-16 text-center text-sm font-sans text-red-500 dark:text-red-400",
869                        "历史日志加载失败,请调整筛选后重试"
870                    }
871                } else if rows_snapshot.is_empty() {
872                    div { class: "py-16 text-center text-sm font-sans text-[var(--color-paper-tertiary)]",
873                        "当前筛选条件下暂无日志"
874                    }
875                }
876
877                for row in rows_snapshot {
878                    match row {
879                        LogRow { key, kind: LogRowKind::Gap(missed) } => {
880                            let gap_text = match missed {
881                                Some(n) => format!("…部分日志已跳过({n} 条)…"),
882                                None => "…部分日志已跳过…".to_string(),
883                            };
884                            rsx! {
885                                div { key: "row-{key}", class: "flex items-center gap-3 px-3 py-1 select-none",
886                                    div { class: "flex-1 border-t border-dashed border-[var(--color-paper-border)]" }
887                                    span { class: "text-[11px] font-sans text-[var(--color-paper-tertiary)]",
888                                        "{gap_text}"
889                                    }
890                                    div { class: "flex-1 border-t border-dashed border-[var(--color-paper-border)]" }
891                                }
892                            }
893                        }
894                        LogRow { key, kind: LogRowKind::Entry(entry) } => {
895                            let badge = level_badge_class(&entry.level);
896                            let time_hms = entry.ts.format("%H:%M:%S").to_string();
897                            let time_full = entry.ts.to_rfc3339();
898                            let target_name = entry.target.clone();
899                            rsx! {
900                                div { key: "row-{key}", class: "flex items-baseline gap-3 px-3 py-1 hover:bg-[var(--color-paper-entry)]/70 transition-colors",
901                                    // 时间:HH:MM:SS,hover title 显示完整时间戳
902                                    span {
903                                        class: "flex-shrink-0 tabular-nums text-[var(--color-paper-tertiary)]",
904                                        title: "{time_full}",
905                                        "{time_hms}"
906                                    }
907                                    // 级别 badge(Catppuccin 语义色)
908                                    span { class: "{BADGE_BASE} {badge} w-16 justify-center flex-shrink-0",
909                                        "{entry.level}"
910                                    }
911                                    // target:弱化色,点击设为 target 筛选
912                                    button {
913                                        r#type: "button",
914                                        class: "flex-shrink-0 max-w-40 truncate text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-accent)] transition-colors cursor-pointer text-left",
915                                        title: "按此 target 筛选",
916                                        onclick: move |_| on_pick_target.call(target_name.clone()),
917                                        "{entry.target}"
918                                    }
919                                    // message:pre-wrap 全量展示(boring 方案,无展开交互)
920                                    span { class: "flex-1 min-w-0 whitespace-pre-wrap break-words text-[var(--color-paper-primary)]/90",
921                                        "{entry.message}"
922                                    }
923                                }
924                            }
925                        }
926                    }
927                }
928            }
929
930            // 「回到底部」浮动按钮(离开底部时浮出)
931            if !stick() {
932                button {
933                    r#type: "button",
934                    class: "absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-[var(--color-paper-entry)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] border border-[var(--color-paper-border)] shadow-md transition-all cursor-pointer",
935                    onclick: move |_| {
936                        #[cfg(target_arch = "wasm32")]
937                        if let Some(el) = get_scroll_el(&scroll_id_for_back) {
938                            el.set_scroll_top(el.scroll_height());
939                        }
940                        stick.set(true);
941                    },
942                    "回到底部"
943                }
944            }
945        }
946    }
947}
948
949/// 级别 badge 配色(Catppuccin 语义色,沿用 StatusBadge 色板惯例)。
950fn level_badge_class(level: &str) -> &'static str {
951    match level {
952        "ERROR" => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
953        "WARN" => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
954        "INFO" => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
955        "DEBUG" => "bg-[var(--color-paper-theme)] text-[var(--color-paper-secondary)]",
956        // TRACE 最弱化:无底色,仅三级文字色。
957        _ => "text-[var(--color-paper-tertiary)]",
958    }
959}
960
961/// 分配客户端单调行 key(SSE 实时行 id 恒 0,不能用作渲染 key)。
962#[cfg(target_arch = "wasm32")]
963fn next_row_key(counter: &std::cell::Cell<u64>) -> u64 {
964    let v = counter.get();
965    counter.set(v + 1);
966    v
967}
968
969/// 按 id 取日志滚动容器元素。
970#[cfg(target_arch = "wasm32")]
971fn get_scroll_el(id: &str) -> Option<web_sys::Element> {
972    web_sys::window()?.document()?.get_element_by_id(id)
973}
974
975/// 建立 SSE 连接消费实时日志流。
976///
977/// `log` 事件(JSON LogEntry,id 恒 0)追加到列表尾部;`gap` 事件
978/// (broadcast lagged)插入分隔提示行;`open`/`error` 维护连接状态点。
979/// 浏览器在 `error` 后自动重连(重连丢段由服务端 `gap` 通知),
980/// 故 error 只标记断开、不 close。EventSource 存入 `es_slot`,
981/// 由组件 `use_drop` 统一 close。
982#[cfg(target_arch = "wasm32")]
983fn open_log_stream(
984    levels: &[String],
985    target: &Option<String>,
986    query: &str,
987    es_slot: std::rc::Rc<std::cell::RefCell<Option<web_sys::EventSource>>>,
988    rows: Signal<Vec<LogRow>>,
989    row_key: std::rc::Rc<std::cell::Cell<u64>>,
990    connected: Signal<bool>,
991) -> Result<(), wasm_bindgen::JsValue> {
992    use wasm_bindgen::closure::Closure;
993    use wasm_bindgen::JsCast;
994    use web_sys::{EventSource, MessageEvent};
995
996    let mut url = format!("/api/logs/stream?levels={}", levels.join(","));
997    if let Some(t) = target {
998        if !t.is_empty() {
999            url.push_str("&target=");
1000            url.push_str(&String::from(js_sys::encode_uri_component(t)));
1001        }
1002    }
1003    if !query.is_empty() {
1004        url.push_str("&q=");
1005        url.push_str(&String::from(js_sys::encode_uri_component(query)));
1006    }
1007    let es = EventSource::new(&url)?;
1008
1009    // open → 已连接
1010    let mut connected_for_open = connected;
1011    let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1012        connected_for_open.set(true);
1013    });
1014    es.add_event_listener_with_callback("open", on_open.as_ref().unchecked_ref())?;
1015    on_open.forget();
1016
1017    // log 事件 → 追加(实时行 id 恒 0:到达即追加,不去重、不作游标)
1018    let mut rows_for_log = rows;
1019    let row_key_for_log = row_key.clone();
1020    let on_log = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
1021        if let Some(s) = e.data().as_string() {
1022            if let Ok(entry) = serde_json::from_str::<LogEntry>(&s) {
1023                rows_for_log.with_mut(|list| {
1024                    list.push(LogRow {
1025                        key: next_row_key(&row_key_for_log),
1026                        kind: LogRowKind::Entry(entry),
1027                    });
1028                    // 缓冲上限:超出后裁掉最旧一段,保护 DOM 规模。
1029                    if list.len() > MAX_BUFFER_ROWS {
1030                        list.drain(..BUFFER_TRIM);
1031                    }
1032                });
1033            }
1034        }
1035    });
1036    es.add_event_listener_with_callback("log", on_log.as_ref().unchecked_ref())?;
1037    on_log.forget();
1038
1039    // gap 事件 → 分隔提示行(broadcast lagged;data 为文本 "missed N log events")
1040    let mut rows_for_gap = rows;
1041    let row_key_for_gap = row_key.clone();
1042    let on_gap = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
1043        // data 形如 "missed N log events",解析出 N 用于提示;解析失败则省略计数。
1044        let missed = e
1045            .data()
1046            .as_string()
1047            .and_then(|s| s.split_whitespace().nth(1)?.parse::<u64>().ok());
1048        rows_for_gap.with_mut(|list| {
1049            list.push(LogRow {
1050                key: next_row_key(&row_key_for_gap),
1051                kind: LogRowKind::Gap(missed),
1052            });
1053        });
1054    });
1055    es.add_event_listener_with_callback("gap", on_gap.as_ref().unchecked_ref())?;
1056    on_gap.forget();
1057
1058    // error → 标记断开(浏览器自动重连,open 时恢复)
1059    let mut connected_for_error = connected;
1060    let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1061        connected_for_error.set(false);
1062    });
1063    es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())?;
1064    on_error.forget();
1065
1066    *es_slot.borrow_mut() = Some(es);
1067    Ok(())
1068}
1069
1070/// 将文本内容触发为浏览器下载(Blob + ObjectURL + 临时 `<a download>`)。
1071#[cfg(target_arch = "wasm32")]
1072fn download_text_file(filename: &str, content: &str) {
1073    use wasm_bindgen::JsCast;
1074
1075    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
1076        return;
1077    };
1078    let parts = js_sys::Array::new();
1079    parts.push(&wasm_bindgen::JsValue::from_str(content));
1080    let opts = web_sys::BlobPropertyBag::new();
1081    opts.set_type("text/plain;charset=utf-8");
1082    let Ok(blob) = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts) else {
1083        return;
1084    };
1085    let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
1086        return;
1087    };
1088    if let Ok(el) = document.create_element("a") {
1089        if let Ok(anchor) = el.dyn_into::<web_sys::HtmlAnchorElement>() {
1090            anchor.set_href(&url);
1091            anchor.set_download(filename);
1092            anchor.click();
1093        }
1094    }
1095    let _ = web_sys::Url::revoke_object_url(&url);
1096}
1097
1098/// 导出文件名:`yggdrasil-logs-<YYYY-MM-DD>.log`(浏览器本地日期)。
1099#[cfg(target_arch = "wasm32")]
1100fn log_export_filename() -> String {
1101    let iso: String = js_sys::Date::new_0().to_iso_string().into();
1102    let date = iso.get(..10).unwrap_or("unknown-date");
1103    format!("yggdrasil-logs-{date}.log")
1104}