yggdrasil/pages/admin/
logs.rs1use 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
30const ALL_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
32
33pub 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#[component]
49pub fn Logs() -> impl IntoView {
50 let toast = use_toast();
51 let levels = RwSignal::new(vec!["ERROR", "WARN", "INFO"]);
53 let target = RwSignal::new(None::<String>);
54 let query = RwSignal::new(String::new());
55 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 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 let on_pick_target = Callback::new(move |t: String| target.set(Some(t)));
74 let on_target_change = move |v: Option<String>| target.set(v);
76 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 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 {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}