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 "data-vt-scroll": "admin-logs",
777 class: "flex-1 min-h-0 overflow-y-auto overscroll-contain py-1 font-mono text-xs leading-5",
778 onscroll: move |_| {
779 #[cfg(target_arch = "wasm32")]
780 {
781 if let Some(el) = get_scroll_el(&scroll_id_for_scroll) {
782 let near_bottom = el.scroll_height()
783 - el.scroll_top()
784 - el.client_height()
785 < STICK_THRESHOLD_PX;
786 if near_bottom != *stick.peek() {
787 stick.set(near_bottom);
788 }
789 }
790 }
791 },
792
793 if next_cursor().is_some() {
795 div { class: "flex justify-center py-1.5 border-b border-[var(--color-paper-border)]/30 mb-1",
796 button {
797 r#type: "button",
798 class: "{BTN_GHOST} font-sans",
799 disabled: loading_older(),
800 onclick: move |_| {
801 #[cfg(target_arch = "wasm32")]
802 {
803 let Some(cursor) = *next_cursor.peek() else { return };
804 if *loading_older.peek() {
805 return;
806 }
807 loading_older.set(true);
808 let base = get_scroll_el(&scroll_id_for_older)
809 .map(|el| el.scroll_height())
810 .unwrap_or(0);
811 let levels = levels_for_older.clone();
812 let target = target_for_older.clone();
813 let q = if query_for_older.is_empty() {
814 None
815 } else {
816 Some(query_for_older.clone())
817 };
818 let row_key = row_key.clone();
819 spawn(async move {
820 if let Ok(page) = get_logs(
821 levels, target, q, Some(cursor), OLDER_PAGE_LIMIT,
822 )
823 .await
824 {
825 next_cursor.set(page.next_cursor);
826 let mut older = page.entries;
827 older.sort_by_key(|e| e.id);
828 older.retain(|e| {
830 e.id == 0
831 || !rows.read().iter().any(|r| {
832 matches!(&r.kind, LogRowKind::Entry(cur) if cur.id == e.id && e.id != 0)
833 })
834 });
835 if !older.is_empty() {
836 let mut merged: Vec<LogRow> = older
837 .into_iter()
838 .map(|e| LogRow {
839 key: next_row_key(&row_key),
840 kind: LogRowKind::Entry(e),
841 })
842 .collect();
843 merged.extend(rows.read().iter().cloned());
844 rows.set(merged);
845 prepend_base.set(Some(base));
846 }
847 }
848 loading_older.set(false);
849 });
850 }
851 },
852 if loading_older() { "加载中…" } else { "加载更早" }
853 }
854 }
855 }
856
857 if !initial_loaded() {
859 DelayedSkeleton {
860 div { class: "px-3 py-2 space-y-2.5",
861 SkeletonBox { class: "h-4 w-[88%] rounded" }
862 SkeletonBox { class: "h-4 w-[72%] rounded" }
863 SkeletonBox { class: "h-4 w-[81%] rounded" }
864 SkeletonBox { class: "h-4 w-[64%] rounded" }
865 SkeletonBox { class: "h-4 w-[77%] rounded" }
866 }
867 }
868 } else if load_error() && rows_snapshot.is_empty() {
869 div { class: "py-16 text-center text-sm font-sans text-red-500 dark:text-red-400",
870 "历史日志加载失败,请调整筛选后重试"
871 }
872 } else if rows_snapshot.is_empty() {
873 div { class: "py-16 text-center text-sm font-sans text-[var(--color-paper-tertiary)]",
874 "当前筛选条件下暂无日志"
875 }
876 }
877
878 for row in rows_snapshot {
879 match row {
880 LogRow { key, kind: LogRowKind::Gap(missed) } => {
881 let gap_text = match missed {
882 Some(n) => format!("…部分日志已跳过({n} 条)…"),
883 None => "…部分日志已跳过…".to_string(),
884 };
885 rsx! {
886 div { key: "row-{key}", class: "flex items-center gap-3 px-3 py-1 select-none",
887 div { class: "flex-1 border-t border-dashed border-[var(--color-paper-border)]" }
888 span { class: "text-[11px] font-sans text-[var(--color-paper-tertiary)]",
889 "{gap_text}"
890 }
891 div { class: "flex-1 border-t border-dashed border-[var(--color-paper-border)]" }
892 }
893 }
894 }
895 LogRow { key, kind: LogRowKind::Entry(entry) } => {
896 let badge = level_badge_class(&entry.level);
897 let time_hms = entry.ts.format("%H:%M:%S").to_string();
898 let time_full = entry.ts.to_rfc3339();
899 let target_name = entry.target.clone();
900 rsx! {
901 div { key: "row-{key}", class: "flex items-baseline gap-3 px-3 py-1 hover:bg-[var(--color-paper-entry)]/70 transition-colors",
902 span {
904 class: "flex-shrink-0 tabular-nums text-[var(--color-paper-tertiary)]",
905 title: "{time_full}",
906 "{time_hms}"
907 }
908 span { class: "{BADGE_BASE} {badge} w-16 justify-center flex-shrink-0",
910 "{entry.level}"
911 }
912 button {
914 r#type: "button",
915 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",
916 title: "按此 target 筛选",
917 onclick: move |_| on_pick_target.call(target_name.clone()),
918 "{entry.target}"
919 }
920 span { class: "flex-1 min-w-0 whitespace-pre-wrap break-words text-[var(--color-paper-primary)]/90",
922 "{entry.message}"
923 }
924 }
925 }
926 }
927 }
928 }
929 }
930
931 if !stick() {
933 button {
934 r#type: "button",
935 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",
936 onclick: move |_| {
937 #[cfg(target_arch = "wasm32")]
938 if let Some(el) = get_scroll_el(&scroll_id_for_back) {
939 el.set_scroll_top(el.scroll_height());
940 }
941 stick.set(true);
942 },
943 "回到底部"
944 }
945 }
946 }
947 }
948}
949
950fn level_badge_class(level: &str) -> &'static str {
952 match level {
953 "ERROR" => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
954 "WARN" => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
955 "INFO" => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
956 "DEBUG" => "bg-[var(--color-paper-theme)] text-[var(--color-paper-secondary)]",
957 _ => "text-[var(--color-paper-tertiary)]",
959 }
960}
961
962#[cfg(target_arch = "wasm32")]
964fn next_row_key(counter: &std::cell::Cell<u64>) -> u64 {
965 let v = counter.get();
966 counter.set(v + 1);
967 v
968}
969
970#[cfg(target_arch = "wasm32")]
972fn get_scroll_el(id: &str) -> Option<web_sys::Element> {
973 web_sys::window()?.document()?.get_element_by_id(id)
974}
975
976#[cfg(target_arch = "wasm32")]
984fn open_log_stream(
985 levels: &[String],
986 target: &Option<String>,
987 query: &str,
988 es_slot: std::rc::Rc<std::cell::RefCell<Option<web_sys::EventSource>>>,
989 rows: Signal<Vec<LogRow>>,
990 row_key: std::rc::Rc<std::cell::Cell<u64>>,
991 connected: Signal<bool>,
992) -> Result<(), wasm_bindgen::JsValue> {
993 use wasm_bindgen::closure::Closure;
994 use wasm_bindgen::JsCast;
995 use web_sys::{EventSource, MessageEvent};
996
997 let mut url = format!("/api/logs/stream?levels={}", levels.join(","));
998 if let Some(t) = target {
999 if !t.is_empty() {
1000 url.push_str("&target=");
1001 url.push_str(&String::from(js_sys::encode_uri_component(t)));
1002 }
1003 }
1004 if !query.is_empty() {
1005 url.push_str("&q=");
1006 url.push_str(&String::from(js_sys::encode_uri_component(query)));
1007 }
1008 let es = EventSource::new(&url)?;
1009
1010 let mut connected_for_open = connected;
1012 let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1013 connected_for_open.set(true);
1014 });
1015 es.add_event_listener_with_callback("open", on_open.as_ref().unchecked_ref())?;
1016 on_open.forget();
1017
1018 let mut rows_for_log = rows;
1020 let row_key_for_log = row_key.clone();
1021 let on_log = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
1022 if let Some(s) = e.data().as_string() {
1023 if let Ok(entry) = serde_json::from_str::<LogEntry>(&s) {
1024 rows_for_log.with_mut(|list| {
1025 list.push(LogRow {
1026 key: next_row_key(&row_key_for_log),
1027 kind: LogRowKind::Entry(entry),
1028 });
1029 if list.len() > MAX_BUFFER_ROWS {
1031 list.drain(..BUFFER_TRIM);
1032 }
1033 });
1034 }
1035 }
1036 });
1037 es.add_event_listener_with_callback("log", on_log.as_ref().unchecked_ref())?;
1038 on_log.forget();
1039
1040 let mut rows_for_gap = rows;
1042 let row_key_for_gap = row_key.clone();
1043 let on_gap = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
1044 let missed = e
1046 .data()
1047 .as_string()
1048 .and_then(|s| s.split_whitespace().nth(1)?.parse::<u64>().ok());
1049 rows_for_gap.with_mut(|list| {
1050 list.push(LogRow {
1051 key: next_row_key(&row_key_for_gap),
1052 kind: LogRowKind::Gap(missed),
1053 });
1054 });
1055 });
1056 es.add_event_listener_with_callback("gap", on_gap.as_ref().unchecked_ref())?;
1057 on_gap.forget();
1058
1059 let mut connected_for_error = connected;
1061 let on_error = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| {
1062 connected_for_error.set(false);
1063 });
1064 es.add_event_listener_with_callback("error", on_error.as_ref().unchecked_ref())?;
1065 on_error.forget();
1066
1067 *es_slot.borrow_mut() = Some(es);
1068 Ok(())
1069}
1070
1071#[cfg(target_arch = "wasm32")]
1073fn download_text_file(filename: &str, content: &str) {
1074 use wasm_bindgen::JsCast;
1075
1076 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
1077 return;
1078 };
1079 let parts = js_sys::Array::new();
1080 parts.push(&wasm_bindgen::JsValue::from_str(content));
1081 let opts = web_sys::BlobPropertyBag::new();
1082 opts.set_type("text/plain;charset=utf-8");
1083 let Ok(blob) = web_sys::Blob::new_with_str_sequence_and_options(&parts, &opts) else {
1084 return;
1085 };
1086 let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
1087 return;
1088 };
1089 if let Ok(el) = document.create_element("a") {
1090 if let Ok(anchor) = el.dyn_into::<web_sys::HtmlAnchorElement>() {
1091 anchor.set_href(&url);
1092 anchor.set_download(filename);
1093 anchor.click();
1094 }
1095 }
1096 let _ = web_sys::Url::revoke_object_url(&url);
1097}
1098
1099#[cfg(target_arch = "wasm32")]
1101fn log_export_filename() -> String {
1102 let iso: String = js_sys::Date::new_0().to_iso_string().into();
1103 let date = iso.get(..10).unwrap_or("unknown-date");
1104 format!("yggdrasil-logs-{date}.log")
1105}