Skip to main content

yggdrasil/pages/admin/
logs.rs

1//! 管理后台「运行日志」页面(`/admin/logs`)。
2//!
3//! 数据流:筛选变更(级别开关 / target 下拉 / 关键字)→ 变更 `filter_key` →
4//! `LogsStreamPane` 整体重建:卸载即 `on_cleanup` close EventSource,挂载即
5//! 重新拉历史(`get_logs` limit 200)并重连 SSE `/api/logs/stream`。SSE 实时行追加到
6//! 列表尾部并按 id 去重——实时事件 id 恒为 0(未落库),去重只对历史行生效。
7//!
8//! 页面在主内容区内部滚动:标题行与筛选栏固定,日志区占满剩余高度。导出按当前筛选
9//! 下载 .log 文件;清理策略在「⋯」展开的面板里。样式见 styles/pages/admin-logs.css。
10//!
11//! 页面没有 SSR 数据:历史与实时流都在浏览器端加载,加载前的骨架屏由后台外壳按路径
12//! 提供(`LogsSkeleton`)。
13use crate::components::admin_layout::AdminPageHead;
14use leptos::prelude::*;
15use leptos::task::spawn_local;
16use leptos_router::{lazy_route, LazyRoute};
17
18use crate::components::forms::{FormSelect, ToggleSwitch, FORM_SELECT_COMPACT_CLASS};
19use crate::components::ui::{button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconName};
20
21mod cleanup;
22#[cfg(target_arch = "wasm32")]
23mod export;
24mod stream;
25use cleanup::CleanupPolicy;
26#[cfg(target_arch = "wasm32")]
27use export::{download_text_file, log_export_filename};
28use stream::LogsStreamPane;
29
30/// 全部可选级别(顺序即展示与服务端白名单顺序)。
31const ALL_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
32
33/// `/admin/logs` 路由:无 SSR 数据,直接渲染页面。
34pub struct LogsRoute;
35
36#[lazy_route]
37impl LazyRoute for LogsRoute {
38    fn data() -> Self {
39        Self
40    }
41
42    fn view(_this: Self) -> AnyView {
43        view! { <Logs/> }.into_any()
44    }
45}
46
47/// 运行日志页面。
48#[component]
49pub fn Logs() -> impl IntoView {
50    let toast = use_toast();
51    // —— 已应用筛选(任何变化都会重建下方 LogsStreamPane)——
52    let levels = RwSignal::new(vec!["ERROR", "WARN", "INFO"]);
53    let target = RwSignal::new(None::<String>);
54    let query = RwSignal::new(String::new());
55    // 关键字草稿:Enter 提交、清空即时生效,避免每击键都重连 SSE。
56    let query_draft = RwSignal::new(String::new());
57    let follow = RwSignal::new(true);
58    let connected = RwSignal::new(false);
59    let dropped = RwSignal::new(0_u64);
60    let exporting = RwSignal::new(false);
61    let cleanup_open = RwSignal::new(false);
62    // target 候选:挂载后拉取一次(服务端缓存 60s)。
63    let targets = RwSignal::new(Vec::<String>::new());
64    Effect::new(move |_| {
65        spawn_local(async move {
66            if let Ok(list) = crate::api::logs::get_log_targets().await {
67                targets.try_set(list);
68            }
69        });
70    });
71
72    // 行内 target 点击 → 设为 target 筛选(触发重建)。
73    let on_pick_target = Callback::new(move |t: String| target.set(Some(t)));
74    // FormSelect 的类型参数靠闭包参数类型推断;闭包放在 view! 外,避免宏解析泛型尖括号。
75    let on_target_change = move |v: Option<String>| target.set(v);
76    // 只有筛选内容真的变化时 Memo 才通知,面板因此不会被无关更新重建。
77    let filter_key = Memo::new(move |_| {
78        format!(
79            "{}|{}|{}",
80            levels.with(|l| l.join(",")),
81            target.get().unwrap_or_default(),
82            query.get()
83        )
84    });
85    let target_options = Signal::derive(move || {
86        let mut options: Vec<(Option<String>, String)> = vec![(None, "全部 target".into())];
87        options.extend(targets.get().into_iter().map(|t| (Some(t.clone()), t)));
88        options
89    });
90    let export = move |_| {
91        if exporting.get_untracked() {
92            return;
93        }
94        exporting.set(true);
95        let lv: Vec<String> = levels.with_untracked(|l| l.iter().map(|s| s.to_string()).collect());
96        let (t, q) = (target.get_untracked(), query.get_untracked());
97        spawn_local(async move {
98            match crate::api::logs::export_logs(lv, t, (!q.is_empty()).then_some(q)).await {
99                Ok(content) => {
100                    let count = content.lines().count();
101                    #[cfg(target_arch = "wasm32")]
102                    download_text_file(&log_export_filename(), &content);
103                    toast.success(format!("已导出 {count} 条日志"));
104                }
105                Err(_) => toast.error("日志导出失败,请重试"),
106            }
107            exporting.try_set(false);
108        });
109    };
110
111    view! {
112        <div class="logs-page">
113            <AdminPageHead
114                title="运行日志"
115                class="logs-head"
116                actions=move || {
117                    view! {
118                        <span
119                            class="logs-live"
120                            data-on=move || connected.get().then_some("true")
121                            role="status"
122                        >
123                            <span class="logs-live-dot" aria-hidden="true"></span>
124                            {move || if connected.get() { "已连接" } else { "未连接" }}
125                        </span>
126                        <button
127                            class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
128                            type="button"
129                            aria-expanded=move || cleanup_open.get().to_string()
130                            on:click=move |_| cleanup_open.update(|open| *open = !*open)
131                        >
132                            <Icon name=IconName::SlidersHorizontal size=15/>
133                            "清理策略"
134                        </button>
135                        <button
136                            class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
137                            type="button"
138                            disabled=move || exporting.get()
139                            on:click=export
140                        >
141                            <Icon name=IconName::Download size=15/>
142                            {move || if exporting.get() { "导出中…" } else { "导出日志" }}
143                        </button>
144                    }
145                }
146            >
147                "进程内结构化日志,历史查询加实时流。"
148                {move || {
149                    (dropped.get() > 0)
150                        .then(|| format!("启动以来丢弃 {} 条。", dropped.get()))
151                }}
152            </AdminPageHead>
153            {move || {
154                cleanup_open
155                    .get()
156                    .then(|| {
157                        view! { <CleanupPolicy on_close=move |_| cleanup_open.set(false)/> }
158                    })
159            }}
160            <div class="logs-filters">
161                <div class="logs-levels" role="group" aria-label="日志级别">
162                    {ALL_LEVELS
163                        .into_iter()
164                        .map(|level| {
165                            view! {
166                                <button
167                                    type="button"
168                                    class="logs-level-toggle"
169                                    data-level=level
170                                    aria-pressed=move || {
171                                        levels.with(|sel| sel.contains(&level)).to_string()
172                                    }
173                                    on:click=move |_| {
174                                        levels
175                                            .update(|sel| {
176                                                if let Some(pos) = sel.iter().position(|l| *l == level) {
177                                                    sel.remove(pos);
178                                                } else {
179                                                    sel.push(level);
180                                                    sel.sort_by_key(|l| {
181                                                        ALL_LEVELS
182                                                            .iter()
183                                                            .position(|a| a == l)
184                                                            .unwrap_or(usize::MAX)
185                                                    });
186                                                }
187                                            });
188                                    }
189                                >
190                                    {level}
191                                </button>
192                            }
193                        })
194                        .collect_view()}
195                </div>
196                <FormSelect
197                    id="logs-target"
198                    value=target
199                    options=target_options
200                    trigger_class=FORM_SELECT_COMPACT_CLASS
201                    aria_label="按 target 筛选"
202                    onchange=on_target_change
203                />
204                <label class="field field-sm admin-filter-search logs-search">
205                    <Icon name=IconName::Search size=14/>
206                    <span class="sr-only">"搜索日志"</span>
207                    <input
208                        type="search"
209                        placeholder="搜索日志内容,Enter 生效"
210                        prop:value=move || query_draft.get()
211                        on:input=move |e| {
212                            let v = event_target_value(&e);
213                            // 清空即恢复无关键字。
214                            if v.is_empty() && !query.get_untracked().is_empty() {
215                                query.set(String::new());
216                            }
217                            query_draft.set(v);
218                        }
219                        on:keydown=move |e: leptos::ev::KeyboardEvent| {
220                            if e.key() == "Enter" && !e.is_composing() {
221                                query.set(query_draft.get_untracked());
222                            }
223                        }
224                    />
225                </label>
226                <label class="logs-follow">
227                    <ToggleSwitch
228                        checked=follow
229                        aria_label="自动跟随"
230                        ontoggle=move |_| follow.update(|on| *on = !*on)
231                    />
232                    "自动跟随"
233                </label>
234            </div>
235            {move || {
236                levels
237                    .with(Vec::is_empty)
238                    .then(|| view! { <p class="logs-hint">"没有选择级别时显示全部。"</p> })
239            }}
240            // 筛选变更 → 重建:卸载旧面板(close EventSource)→ 挂载新面板。
241            // 只订阅 filter_key,面板参数一律按挂载时刻的快照读取。
242            {move || {
243                let _ = filter_key.get();
244                let levels: Vec<String> = levels
245                    .with_untracked(|l| l.iter().map(|s| s.to_string()).collect());
246                let target = target.get_untracked();
247                let query = query.get_untracked();
248                view! {
249                    <div class="logs-pane-slot">
250                        <LogsStreamPane
251                            levels
252                            target
253                            query
254                            follow
255                            connected
256                            dropped
257                            on_pick_target
258                        />
259                    </div>
260                }
261            }}
262        </div>
263    }
264}