1use dioxus::prelude::*;
14
15#[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
26const ALL_LEVELS: [&str; 5] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"];
28#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
32const INITIAL_LIMIT: i32 = 200;
33#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
35const OLDER_PAGE_LIMIT: i32 = 200;
36#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
38const STICK_THRESHOLD_PX: i32 = 48;
39#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
41const MAX_BUFFER_ROWS: usize = 2000;
42#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
44const BUFFER_TRIM: usize = 500;
45
46static LOG_STREAM_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
48
49#[component]
54#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
55pub fn Logs() -> Element {
56 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 let mut query_draft: Signal<String> = use_signal(String::new);
62
63 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 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 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 if toast_state().map(|(m, _)| m == key).unwrap_or(false) {
94 toast_state.set(None);
95 }
96 });
97 }
98 });
99
100 let on_pick_target: Callback<String> = Callback::new(move |t: String| target.set(Some(t)));
102
103 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 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 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 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 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 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 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 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 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 div { class: "flex-shrink-0 mb-4",
244 CleanupPolicy {}
245 }
246
247 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#[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 let mut just_saved: Signal<bool> = use_signal(|| false);
279
280 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 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 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 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 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 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 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
421static TARGET_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
423
424#[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 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 {
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#[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 Gap(Option<u64>),
611}
612
613#[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 let mut stick: Signal<bool> = use_signal(|| true);
636 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 #[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 let levels_for_load = levels.clone();
657 let target_for_load = target.clone();
658 let query_for_load = query.clone();
659 #[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 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 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 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 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 #[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 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 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 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 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 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 span {
903 class: "flex-shrink-0 tabular-nums text-[var(--color-paper-tertiary)]",
904 title: "{time_full}",
905 "{time_hms}"
906 }
907 span { class: "{BADGE_BASE} {badge} w-16 justify-center flex-shrink-0",
909 "{entry.level}"
910 }
911 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 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 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
949fn 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 _ => "text-[var(--color-paper-tertiary)]",
958 }
959}
960
961#[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#[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#[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 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 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 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 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 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 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#[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#[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}