1use crate::events::{BacktraceDisplay, BacktraceDisplayFrame, TraceDisplayItem};
2use crate::model::panel_state::{DisplayMode, EbpfPanelState, EbpfViewMode};
3use crate::ui::themes::UIThemes;
4use ghostscope_protocol::trace_event::BacktraceStatus;
5use ratatui::{
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, BorderType, Borders, Paragraph},
10 Frame,
11};
12
13#[derive(Debug)]
15pub struct EbpfPanelRenderer;
16
17impl EbpfPanelRenderer {
18 pub fn new() -> Self {
19 Self
20 }
21
22 pub fn render(
24 &mut self,
25 state: &mut EbpfPanelState,
26 frame: &mut Frame,
27 area: Rect,
28 is_focused: bool,
29 ) {
30 let border_style = if is_focused {
32 UIThemes::panel_focused()
33 } else {
34 UIThemes::panel_unfocused()
35 };
36 let panel_block = Block::default()
37 .borders(Borders::ALL)
38 .border_type(if is_focused {
39 BorderType::Thick
40 } else {
41 BorderType::Plain
42 })
43 .title(format!(
44 "eBPF Trace Output ({} events)",
45 state.trace_events.len()
46 ))
47 .border_style(border_style);
48 frame.render_widget(panel_block, area);
49
50 if area.width <= 2 || area.height <= 2 {
51 return;
52 }
53 let content_area = Rect {
54 x: area.x + 1,
55 y: area.y + 1,
56 width: area.width - 2,
57 height: area.height - 2,
58 };
59 let content_width = content_area.width as usize;
60
61 struct Card {
63 header_no_bold: String,
64 header_number: String,
65 header_rest: String,
66 body_lines: Vec<Line<'static>>,
67 total_height: u16,
68 is_error: bool,
69 is_latest: bool,
70 }
71 let mut cards: Vec<Card> = Vec::new();
72
73 let total_traces = state.trace_events.len();
74 for (trace_index, cached_trace) in state.trace_events.iter().enumerate() {
75 let trace = &cached_trace.event;
76 let is_latest = trace_index == total_traces - 1;
77 let is_error = trace.is_error();
78
79 let header_no_bold = String::from("[No:");
80 let message_id = (trace_index + 1) as u64;
81 let formatted_timestamp = &cached_trace.formatted_timestamp;
82 let header_number = message_id.to_string();
83 let header_rest = format!(
84 "] {} TraceID:{} PID:{} TID:{}",
85 formatted_timestamp, trace.trace_id, trace.pid, trace.tid
86 );
87
88 let mut body_lines: Vec<Line> = Vec::new();
89 for item in &trace.items {
90 body_lines.extend(Self::render_trace_item(
91 item,
92 content_width,
93 state.view_mode,
94 ));
95 }
96
97 let (body_for_display, inner_height): (Vec<Line>, u16) = match state.view_mode {
99 EbpfViewMode::List => {
100 let mut b = Vec::new();
101 if body_lines.is_empty() {
102 b.push(Line::from(""));
103 } else {
104 let max_body = 3usize;
105 let truncated = body_lines.len() > max_body;
106 let take_n = body_lines.len().min(max_body);
107 b.extend(body_lines.iter().take(take_n).cloned());
108 if truncated {
109 if let Some(last) = b.last_mut() {
110 let ellipsis = Span::styled(
112 " …",
113 Style::default()
114 .fg(Color::Yellow)
115 .add_modifier(Modifier::BOLD),
116 );
117 if last.spans.len() >= 2 {
118 let indent = last.spans[0].content.clone();
119 let style = last.spans[1].style;
120 let original = last.spans[1].content.to_string();
121 let trimmed = Self::trim_chars_from_end(&original, 2);
123 last.spans.clear();
124 last.spans.push(Span::raw(indent));
125 last.spans.push(Span::styled(trimmed, style));
126 last.spans.push(ellipsis);
127 } else {
128 last.spans.push(ellipsis);
129 }
130 }
131 }
132 }
133 (b.clone(), u16::max(1, b.len() as u16))
134 }
135 EbpfViewMode::Expanded { .. } => {
136 (body_lines.clone(), u16::max(1, body_lines.len() as u16))
137 }
138 };
139 let total_height = inner_height + 2;
140 cards.push(Card {
141 header_no_bold,
142 header_number,
143 header_rest,
144 body_lines: body_for_display,
145 total_height,
146 is_error,
147 is_latest,
148 });
149 }
150
151 if let EbpfViewMode::Expanded { index, scroll } = state.view_mode {
153 if let Some(card) = cards.get(index) {
154 let border_style_l = Style::default().fg(Color::Green);
155 let title_color = Color::Green;
156 let card_block = Block::default()
157 .borders(Borders::ALL)
158 .border_type(BorderType::Thick)
159 .border_style(border_style_l)
160 .title(Line::from(vec![
161 Span::styled(
162 card.header_no_bold.clone(),
163 Style::default().fg(title_color),
164 ),
165 Span::styled(
166 card.header_number.clone(),
167 Style::default()
168 .fg(Color::LightMagenta)
169 .add_modifier(Modifier::BOLD),
170 ),
171 Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
172 ]));
173 frame.render_widget(card_block, content_area);
174
175 if content_area.width > 2 && content_area.height > 2 {
176 let hint_h: u16 = 1;
178 let inner_h = content_area.height.saturating_sub(2 + hint_h);
179 let inner = Rect {
180 x: content_area.x + 1,
181 y: content_area.y + 1,
182 width: content_area.width - 2,
183 height: inner_h,
184 };
185 state.last_inner_height = inner.height as usize;
187 let max_body_lines = inner.height as usize;
188 let total = card.body_lines.len();
189 let max_scroll = total.saturating_sub(max_body_lines);
190 let start = scroll.min(max_scroll);
191 let end = (start + max_body_lines).min(total);
192 if start != scroll {
194 state.set_expanded_scroll(start);
195 }
196 let lines = card.body_lines[start..end].to_vec();
197 let para = Paragraph::new(lines);
198 frame.render_widget(para, inner);
199 let hint_rect = Rect {
201 x: content_area.x + 1,
202 y: content_area.y + content_area.height.saturating_sub(1),
203 width: content_area.width.saturating_sub(2),
204 height: 1,
205 };
206 let hint = "Esc/Ctrl+C to exit • j/k/↑/↓ scroll • Ctrl+U/D half-page • PgUp/PgDn page";
207 let hint_line =
208 Line::from(Span::styled(hint, Style::default().fg(Color::Gray)));
209 let hint_para = Paragraph::new(vec![hint_line]);
210 frame.render_widget(hint_para, hint_rect);
211 }
212 }
213 return;
214 }
215
216 let viewport_height = content_area.height;
218 let start_index = match state.display_mode {
219 DisplayMode::AutoRefresh => {
220 let mut accumulated: u16 = 0;
221 let mut idx = cards.len();
222 while idx > 0 {
223 let next_height = accumulated.saturating_add(cards[idx - 1].total_height);
224 if next_height > viewport_height {
225 break;
226 }
227 accumulated = next_height;
228 idx -= 1;
229 }
230 idx
231 }
232 DisplayMode::Scroll => {
233 let cursor = state.cursor_trace_index.min(cards.len().saturating_sub(1));
234 let mut height_below: u16 = 0;
235 let mut end = cursor;
236 while end < cards.len() {
237 let card_height = cards[end].total_height;
238 if height_below + card_height > viewport_height {
239 break;
240 }
241 height_below += card_height;
242 end += 1;
243 }
244
245 let mut height_above: u16 = 0;
246 let mut idx = cursor;
247 while idx > 0 {
248 let card_height = cards[idx - 1].total_height;
249 if height_above + height_below + card_height > viewport_height {
250 break;
251 }
252 height_above += card_height;
253 idx -= 1;
254 }
255 idx
256 }
257 };
258
259 let mut y = content_area.y;
261 for (idx, card) in cards.iter().enumerate().skip(start_index) {
262 if y >= content_area.y + content_area.height {
263 break;
264 }
265 let remaining = (content_area.y + content_area.height).saturating_sub(y);
267 let height = card.total_height.min(remaining);
268 if height < 2 {
269 break;
270 }
271
272 let is_cursor = state.show_cursor && idx == state.cursor_trace_index;
273 let mut border_style_l = Style::default();
274 let mut border_type = BorderType::Plain;
275 if is_cursor {
276 border_style_l = Style::default().fg(Color::Yellow);
277 border_type = BorderType::Thick;
278 } else if card.is_latest {
279 border_style_l = Style::default().fg(Color::Green);
280 border_type = BorderType::Thick;
281 } else if card.is_error {
282 border_style_l = Style::default().fg(Color::Red);
283 }
284 let title_color = if is_cursor {
285 Color::Yellow
286 } else if card.is_latest {
287 Color::Green
288 } else {
289 Color::Gray
290 };
291
292 let card_block = Block::default()
293 .borders(Borders::ALL)
294 .border_type(border_type)
295 .border_style(border_style_l)
296 .title(Line::from(vec![
297 Span::styled(
298 card.header_no_bold.clone(),
299 Style::default().fg(title_color),
300 ),
301 Span::styled(
302 card.header_number.clone(),
303 Style::default()
304 .fg(Color::LightMagenta)
305 .add_modifier(Modifier::BOLD),
306 ),
307 Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
308 ]));
309
310 let card_area = Rect {
311 x: content_area.x,
312 y,
313 width: content_area.width,
314 height,
315 };
316 frame.render_widget(card_block, card_area);
317
318 if card_area.width > 2 && card_area.height > 2 {
319 let inner = Rect {
320 x: card_area.x + 1,
321 y: card_area.y + 1,
322 width: card_area.width - 2,
323 height: card_area.height - 2,
324 };
325 let body = if card.body_lines.is_empty() {
326 vec![Line::from("")]
327 } else {
328 card.body_lines.clone()
329 };
330 let para = Paragraph::new(body);
331 frame.render_widget(para, inner);
332 }
333
334 y = y.saturating_add(height);
335 }
336
337 if state.g_pressed || state.numeric_prefix.is_some() {
339 let input_text = if let Some(ref s) = state.numeric_prefix {
340 s.clone()
341 } else {
342 "g".to_string()
343 };
344 let hint_text = if state.g_pressed && state.numeric_prefix.is_none() {
345 " Press 'g' again for top"
346 } else if state.numeric_prefix.is_some() {
347 " Press 'G' to jump to message"
348 } else {
349 ""
350 };
351 let full_text = if hint_text.is_empty() {
352 input_text.clone()
353 } else {
354 let hint_body = &hint_text[1..];
355 format!("{input_text} ({hint_body})")
356 };
357
358 let text_width = full_text.len() as u16;
359 let display_x = content_area.x + content_area.width.saturating_sub(text_width + 2);
360 let display_y = content_area.y + content_area.height.saturating_sub(1);
361
362 let mut spans = vec![Span::styled(
363 input_text,
364 Style::default().fg(Color::Green).bg(Color::Rgb(30, 30, 30)),
365 )];
366 if !hint_text.is_empty() {
367 let hint_body = &hint_text[1..];
368 spans.push(Span::styled(
369 format!(" ({hint_body})"),
370 Style::default()
371 .fg(border_style.fg.unwrap_or(Color::White))
372 .bg(Color::Rgb(30, 30, 30)),
373 ));
374 }
375 let text = ratatui::text::Text::from(ratatui::text::Line::from(spans));
376 frame.render_widget(
377 ratatui::widgets::Paragraph::new(text).alignment(ratatui::layout::Alignment::Right),
378 Rect::new(display_x, display_y, text_width + 2, 1),
379 );
380 }
381 }
382
383 fn wrap_text_with_widths(text: &str, first_width: usize, cont_width: usize) -> Vec<String> {
385 if text.is_empty() {
386 return vec![String::new()];
387 }
388
389 let fw = first_width.max(1);
390 let cw = cont_width.max(1);
391 let mut width = fw;
392 let mut lines = Vec::new();
393 let mut current_line = String::new();
394
395 for ch in text.chars() {
396 if ch == '\n' {
397 lines.push(current_line);
398 current_line = String::new();
399 width = cw; continue;
401 }
402 if current_line.len() >= width {
403 lines.push(std::mem::take(&mut current_line));
404 width = cw; }
406 current_line.push(ch);
407 }
408
409 lines.push(current_line);
410 lines
411 }
412
413 fn trim_chars_from_end(s: &str, n: usize) -> String {
415 if n == 0 || s.is_empty() {
416 return s.to_string();
417 }
418 let mut end = s.len();
419 let mut iter = s.char_indices().rev();
420 for _ in 0..n {
421 if let Some((idx, _)) = iter.next() {
422 end = idx;
423 } else {
424 end = 0;
425 break;
426 }
427 }
428 s[..end].to_string()
429 }
430
431 fn render_trace_item(
432 item: &TraceDisplayItem,
433 content_width: usize,
434 view_mode: EbpfViewMode,
435 ) -> Vec<Line<'static>> {
436 match item {
437 TraceDisplayItem::Text { content } => Self::render_text_item(content, content_width),
438 TraceDisplayItem::FormattedText { content } => {
439 Self::render_text_item(content, content_width)
440 }
441 TraceDisplayItem::Variable(variable) => {
442 Self::render_text_item(&variable.to_formatted_output(), content_width)
443 }
444 TraceDisplayItem::ComplexVariable(variable) => {
445 Self::render_text_item(&variable.to_formatted_output(), content_width)
446 }
447 TraceDisplayItem::ExprError(error) => {
448 Self::render_text_item(&error.to_formatted_output(), content_width)
449 }
450 TraceDisplayItem::Backtrace(backtrace) => Self::render_backtrace_item(
451 backtrace,
452 matches!(view_mode, EbpfViewMode::Expanded { .. }),
453 content_width,
454 ),
455 }
456 }
457
458 fn render_text_item(content: &str, content_width: usize) -> Vec<Line<'static>> {
459 let color = if content.contains("ERROR") || content.contains("Error") {
460 Color::Red
461 } else if content.contains("WARN") || content.contains("Warning") {
462 Color::Yellow
463 } else {
464 Color::Cyan
465 };
466 let inner_width = content_width.saturating_sub(2);
467 let first_width = inner_width.saturating_sub(2);
468 let cont_width = inner_width.saturating_sub(4);
469 Self::wrap_text_with_widths(content, first_width, cont_width)
470 .into_iter()
471 .enumerate()
472 .map(|(i, seg)| {
473 let line_indent = if i == 0 { " " } else { " " };
474 Line::from(vec![
475 Span::raw(line_indent),
476 Span::styled(seg, Style::default().fg(color)),
477 ])
478 })
479 .collect()
480 }
481
482 fn render_backtrace_item(
483 backtrace: &BacktraceDisplay,
484 expanded: bool,
485 content_width: usize,
486 ) -> Vec<Line<'static>> {
487 let line_width = content_width.saturating_sub(2).max(1);
488 let mut lines = Vec::new();
489 lines.push(Self::render_backtrace_header(backtrace));
490
491 if expanded {
492 lines.extend(backtrace.frames.iter().map(Self::render_backtrace_frame));
493 if let Some(stopped) = backtrace.stopped_text() {
494 lines.push(Line::from(vec![
495 Span::raw(" "),
496 Span::styled(stopped, Self::status_style(backtrace.status)),
497 ]));
498 }
499 return lines
500 .into_iter()
501 .flat_map(|line| Self::wrap_styled_line(line, line_width, " "))
502 .collect();
503 } else if backtrace.frames.len() > 2 {
504 if let Some(first) = backtrace.frames.first() {
505 let first_frame =
506 Self::wrap_styled_line(Self::render_backtrace_frame(first), line_width, " ");
507 if let Some(first_line) = first_frame.into_iter().next() {
508 lines.push(first_line);
509 }
510 }
511 lines.push(Self::render_backtrace_more_line(
512 backtrace.frames.len().saturating_sub(1),
513 ));
514 return lines;
515 } else {
516 lines.extend(backtrace.frames.iter().map(Self::render_backtrace_frame));
517 if lines.len() < 3 {
518 if let Some(stopped) = backtrace.stopped_text() {
519 lines.push(Line::from(vec![
520 Span::raw(" "),
521 Span::styled(stopped, Self::status_style(backtrace.status)),
522 ]));
523 }
524 }
525 }
526
527 lines
528 .into_iter()
529 .flat_map(|line| Self::wrap_styled_line(line, line_width, " "))
530 .collect()
531 }
532
533 fn wrap_styled_line(
534 line: Line<'static>,
535 width: usize,
536 continuation_indent: &'static str,
537 ) -> Vec<Line<'static>> {
538 let width = width.max(1);
539 let indent_width = continuation_indent.chars().count();
540 if width <= indent_width {
541 return vec![line];
542 }
543
544 let mut wrapped = Vec::new();
545 let mut current = Vec::new();
546 let mut current_len = 0usize;
547 let mut continuation = false;
548
549 for span in line.spans {
550 let style = span.style;
551 let mut remaining = span.content.into_owned();
552 while !remaining.is_empty() {
553 if current_len >= width {
554 wrapped.push(Line::from(current));
555 current = vec![Span::raw(continuation_indent)];
556 current_len = indent_width;
557 continuation = true;
558 }
559
560 let available = width.saturating_sub(current_len);
561 if available == 0 {
562 wrapped.push(Line::from(current));
563 current = vec![Span::raw(continuation_indent)];
564 current_len = indent_width;
565 continuation = true;
566 continue;
567 }
568
569 let (segment, rest) = Self::split_prefix_chars(&remaining, available);
570 current_len += segment.chars().count();
571 current.push(Span::styled(segment, style));
572 remaining = rest;
573 }
574 }
575
576 if current.is_empty() {
577 if continuation {
578 wrapped.push(Line::from(vec![Span::raw(continuation_indent)]));
579 }
580 } else {
581 wrapped.push(Line::from(current));
582 }
583 wrapped
584 }
585
586 fn split_prefix_chars(text: &str, max_chars: usize) -> (String, String) {
587 if max_chars == 0 {
588 return (String::new(), text.to_string());
589 }
590
591 let mut split = text.len();
592 for (count, (idx, _)) in text.char_indices().enumerate() {
593 if count == max_chars {
594 split = idx;
595 break;
596 }
597 }
598 (text[..split].to_string(), text[split..].to_string())
599 }
600
601 fn render_backtrace_header(backtrace: &BacktraceDisplay) -> Line<'static> {
602 let frame_word = if backtrace.physical_frame_count == 1 {
603 "frame"
604 } else {
605 "frames"
606 };
607 let mut spans = vec![
608 Span::raw(" "),
609 Span::styled(
610 "backtrace",
611 Style::default()
612 .fg(Color::LightBlue)
613 .add_modifier(Modifier::BOLD),
614 ),
615 Span::styled(": ", Style::default().fg(Color::Gray)),
616 Span::styled(
617 backtrace.status.label().to_string(),
618 Self::status_style(backtrace.status),
619 ),
620 Span::styled(", ", Style::default().fg(Color::Gray)),
621 Span::styled(
622 backtrace.physical_frame_count.to_string(),
623 Style::default()
624 .fg(Color::White)
625 .add_modifier(Modifier::BOLD),
626 ),
627 Span::styled(format!(" {frame_word}"), Style::default().fg(Color::Gray)),
628 Span::styled(
629 format!(" (max {})", backtrace.requested_depth),
630 Style::default().fg(Color::DarkGray),
631 ),
632 ];
633 if backtrace.raw {
634 spans.push(Span::styled(" raw", Style::default().fg(Color::Yellow)));
635 }
636 Line::from(spans)
637 }
638
639 fn render_backtrace_frame(frame: &BacktraceDisplayFrame) -> Line<'static> {
640 let mut spans = vec![
641 Span::raw(" "),
642 Span::styled(
643 format!("#{}", frame.index),
644 Style::default()
645 .fg(Color::LightBlue)
646 .add_modifier(Modifier::BOLD),
647 ),
648 ];
649 if frame.inline {
650 spans.push(Span::styled(
651 ".inline",
652 Style::default().fg(Color::LightBlue),
653 ));
654 }
655 spans.push(Span::raw(" "));
656
657 if let Some(function) = &frame.function {
658 spans.push(Span::styled(
659 function.clone(),
660 Style::default()
661 .fg(Color::White)
662 .add_modifier(Modifier::BOLD),
663 ));
664 if !frame.parameters.is_empty() {
665 spans.push(Span::styled("(", Style::default().fg(Color::Gray)));
666 for (idx, parameter) in frame.parameters.iter().enumerate() {
667 if idx > 0 {
668 spans.push(Span::styled(", ", Style::default().fg(Color::Gray)));
669 }
670 spans.extend(Self::render_parameter_spans(parameter));
671 }
672 spans.push(Span::styled(")", Style::default().fg(Color::Gray)));
673 }
674 } else {
675 spans.push(Span::styled(
676 frame
677 .address
678 .clone()
679 .unwrap_or_else(|| "<unknown function>".to_string()),
680 Style::default().fg(Color::Yellow),
681 ));
682 }
683
684 if let Some(location) = &frame.location {
685 spans.push(Span::styled(" at ", Style::default().fg(Color::Gray)));
686 spans.push(Span::styled(
687 location.clone(),
688 Style::default().fg(Color::Cyan),
689 ));
690 } else if frame.function.is_some() {
691 spans.push(Span::styled(" at ??", Style::default().fg(Color::DarkGray)));
692 }
693 spans.push(Span::styled(" [", Style::default().fg(Color::Gray)));
694 spans.push(Span::styled(
695 frame.module.clone(),
696 Style::default().fg(Color::LightYellow),
697 ));
698 spans.push(Span::styled("]", Style::default().fg(Color::Gray)));
699
700 if let Some(raw_ip) = frame.raw_ip {
701 spans.push(Span::styled(
702 format!(" raw=0x{raw_ip:x}"),
703 Style::default().fg(Color::DarkGray),
704 ));
705 }
706 if let Some(cookie) = frame.cookie {
707 spans.push(Span::styled(
708 format!(" cookie=0x{cookie:016x}"),
709 Style::default().fg(Color::DarkGray),
710 ));
711 }
712 if let Some(flags) = frame.flags {
713 spans.push(Span::styled(
714 format!(" flags=0x{flags:x}"),
715 Style::default().fg(Color::DarkGray),
716 ));
717 }
718
719 Line::from(spans)
720 }
721
722 fn render_backtrace_more_line(hidden_frames: usize) -> Line<'static> {
723 let frame_word = if hidden_frames == 1 {
724 "frame"
725 } else {
726 "frames"
727 };
728 Line::from(vec![
729 Span::raw(" "),
730 Span::styled(
731 format!("... {hidden_frames} more {frame_word}"),
732 Style::default().fg(Color::DarkGray),
733 ),
734 ])
735 }
736
737 fn render_parameter_spans(parameter: &str) -> Vec<Span<'static>> {
738 let parameter = parameter.trim();
739 if parameter.is_empty() {
740 return Vec::new();
741 }
742
743 if let Some((type_name, name)) = parameter.rsplit_once(' ') {
744 if !type_name.trim().is_empty() && !name.trim().is_empty() {
745 return vec![
746 Span::styled(
747 type_name.trim().to_string(),
748 Style::default().fg(Color::LightMagenta),
749 ),
750 Span::raw(" "),
751 Span::styled(
752 name.trim().to_string(),
753 Style::default()
754 .fg(Color::Green)
755 .add_modifier(Modifier::BOLD),
756 ),
757 ];
758 }
759 }
760
761 vec![Span::styled(
762 parameter.to_string(),
763 Style::default().fg(Color::LightMagenta),
764 )]
765 }
766
767 fn status_style(status: BacktraceStatus) -> Style {
768 match status {
769 BacktraceStatus::Complete => Style::default()
770 .fg(Color::Green)
771 .add_modifier(Modifier::BOLD),
772 BacktraceStatus::Truncated
773 | BacktraceStatus::DwarfUnavailable
774 | BacktraceStatus::UnsupportedCfi
775 | BacktraceStatus::NoUnwindRowsForPc
776 | BacktraceStatus::OffsetsUnavailable => Style::default()
777 .fg(Color::Yellow)
778 .add_modifier(Modifier::BOLD),
779 BacktraceStatus::ReadError
780 | BacktraceStatus::InternalError
781 | BacktraceStatus::InvalidFrame => {
782 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
783 }
784 }
785 }
786}
787
788impl Default for EbpfPanelRenderer {
789 fn default() -> Self {
790 Self::new()
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 fn line_text(line: &Line<'_>) -> String {
799 line.spans
800 .iter()
801 .map(|span| span.content.as_ref())
802 .collect()
803 }
804
805 fn sample_backtrace(frame_count: usize) -> BacktraceDisplay {
806 BacktraceDisplay {
807 requested_depth: 128,
808 physical_frame_count: frame_count,
809 status: BacktraceStatus::Complete,
810 error_code: 0,
811 raw: false,
812 frames: (0..frame_count)
813 .map(|index| BacktraceDisplayFrame {
814 index,
815 inline: false,
816 function: Some(format!("function_{index}")),
817 parameters: vec!["ngx_http_request_s* r".to_string()],
818 address: None,
819 location: Some(format!("request.c:{}", 100 + index)),
820 module: format!("nginx+0x{:x}", 0x1000 + index),
821 raw_ip: None,
822 cookie: None,
823 flags: None,
824 })
825 .collect(),
826 }
827 }
828
829 #[test]
830 fn list_mode_renders_backtrace_as_compact_structured_item() {
831 let item = TraceDisplayItem::Backtrace(sample_backtrace(4));
832 let lines = EbpfPanelRenderer::render_trace_item(&item, 120, EbpfViewMode::List);
833
834 assert_eq!(lines.len(), 3);
835 assert!(line_text(&lines[0]).contains("backtrace: complete, 4 frames"));
836 assert!(line_text(&lines[1]).contains("#0 function_0"));
837 assert!(line_text(&lines[2]).contains("... 3 more frames"));
838 }
839
840 #[test]
841 fn list_mode_keeps_backtrace_summary_when_first_frame_wraps() {
842 let mut backtrace = sample_backtrace(4);
843 backtrace.frames[0].function =
844 Some("ngx_http_process_request_headers_with_a_long_suffix".to_string());
845 backtrace.frames[0].parameters = vec!["ngx_http_request_s* request".to_string()];
846 backtrace.frames[0].location = Some(
847 "/mnt/500g/code/openresty/openresty-1.27.1.1/build/nginx/src/http/ngx_http_request.c:1529:13"
848 .to_string(),
849 );
850
851 let item = TraceDisplayItem::Backtrace(backtrace);
852 let lines = EbpfPanelRenderer::render_trace_item(&item, 48, EbpfViewMode::List);
853
854 assert_eq!(lines.len(), 3);
855 assert!(line_text(&lines[0]).contains("backtrace: complete, 4 frames"));
856 assert!(line_text(&lines[1]).contains("#0 ngx_http_process"));
857 assert!(line_text(&lines[2]).contains("... 3 more frames"));
858 }
859
860 #[test]
861 fn expanded_mode_keeps_backtrace_status_and_parameters_structured() {
862 let item = TraceDisplayItem::Backtrace(sample_backtrace(1));
863 let lines = EbpfPanelRenderer::render_trace_item(
864 &item,
865 120,
866 EbpfViewMode::Expanded {
867 index: 0,
868 scroll: 0,
869 },
870 );
871
872 let frame = line_text(&lines[1]);
873 assert!(frame.contains("function_0("));
874 assert!(frame.contains("ngx_http_request_s* r"));
875 assert!(frame.contains("request.c:100"));
876 assert!(frame.contains("[nginx+0x1000]"));
877 }
878
879 #[test]
880 fn expanded_backtrace_lines_wrap_to_panel_width() {
881 let mut backtrace = sample_backtrace(1);
882 backtrace.frames[0].function =
883 Some("ngx_http_process_request_headers_with_a_long_suffix".to_string());
884 backtrace.frames[0].parameters = vec![
885 "ngx_http_request_s* request".to_string(),
886 "long unsigned int flags".to_string(),
887 ];
888 backtrace.frames[0].location = Some(
889 "/mnt/500g/code/openresty/openresty-1.27.1.1/build/nginx/src/http/ngx_http_request.c:1529:13"
890 .to_string(),
891 );
892
893 let item = TraceDisplayItem::Backtrace(backtrace);
894 let lines = EbpfPanelRenderer::render_trace_item(
895 &item,
896 48,
897 EbpfViewMode::Expanded {
898 index: 0,
899 scroll: 0,
900 },
901 );
902
903 assert!(
904 lines.len() > 2,
905 "narrow backtrace output should wrap long frame lines"
906 );
907 assert!(line_text(&lines[1]).contains("#0 ngx_http_process"));
908 assert!(
909 lines
910 .iter()
911 .skip(2)
912 .map(line_text)
913 .any(|line| line.starts_with(" ") && line.contains("request")),
914 "wrapped continuation should keep parameter text with indentation"
915 );
916 assert!(
917 lines
918 .iter()
919 .map(line_text)
920 .any(|line| line.contains("ngx_http_request.c:1529:13")),
921 "wrapped continuation should retain the source location"
922 );
923 }
924
925 #[test]
926 fn header_uses_physical_frame_count_for_inline_backtraces() {
927 let mut backtrace = sample_backtrace(1);
928 let mut inline_frame = backtrace.frames[0].clone();
929 inline_frame.inline = true;
930 inline_frame.function = Some("inlined_add".to_string());
931 backtrace.frames.insert(0, inline_frame);
932
933 let item = TraceDisplayItem::Backtrace(backtrace);
934 let lines = EbpfPanelRenderer::render_trace_item(
935 &item,
936 120,
937 EbpfViewMode::Expanded {
938 index: 0,
939 scroll: 0,
940 },
941 );
942
943 let header = line_text(&lines[0]);
944 assert!(header.contains("backtrace: complete, 1 frame (max 128)"));
945 assert!(!header.contains("2 frames"));
946 assert!(line_text(&lines[1]).contains("#0.inline inlined_add"));
947 assert!(line_text(&lines[2]).contains("#0 function_0"));
948 }
949}