1use std::io::{self, Stdout, Write};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result};
10use arboard::Clipboard;
11use crossterm::{
12 cursor::{Hide, MoveTo, Show},
13 event::{self, Event, KeyCode, KeyEventKind},
14 execute,
15 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16 ExecutableCommand,
17};
18use ratatui::{
19 buffer::Buffer,
20 prelude::*,
21 widgets::{Block, BorderType, Borders, Cell, Padding, Paragraph, Row, Table, Widget},
22};
23use tachyonfx::{fx, EffectManager};
24
25use crate::bpf_skel::types::cake_stats;
26use crate::bpf_skel::BpfSkel;
27use crate::stats::TIER_NAMES;
28use crate::topology::TopologyInfo;
29
30fn aggregate_stats(skel: &BpfSkel) -> cake_stats {
31 let mut total: cake_stats = Default::default();
32
33 if let Some(bss) = &skel.maps.bss_data {
34 for s in &bss.global_stats {
35 total.nr_new_flow_dispatches += s.nr_new_flow_dispatches;
37 total.nr_old_flow_dispatches += s.nr_old_flow_dispatches;
38
39 for i in 0..crate::stats::TIER_NAMES.len() {
40 total.nr_tier_dispatches[i] += s.nr_tier_dispatches[i];
41 total.nr_starvation_preempts_tier[i] += s.nr_starvation_preempts_tier[i];
42 }
43 }
44 }
45
46 total
47}
48
49pub struct TuiApp {
51 start_time: Instant,
52 status_message: Option<(String, Instant)>,
53 topology: TopologyInfo,
54}
55
56impl TuiApp {
57 pub fn new(topology: TopologyInfo) -> Self {
58 Self {
59 start_time: Instant::now(),
60 status_message: None,
61 topology,
62 }
63 }
64
65 fn format_uptime(&self) -> String {
67 let elapsed = self.start_time.elapsed();
68 let secs = elapsed.as_secs();
69 if secs < 3600 {
70 format!("{}m {}s", secs / 60, secs % 60)
71 } else {
72 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
73 }
74 }
75
76 fn set_status(&mut self, msg: &str) {
78 self.status_message = Some((msg.to_string(), Instant::now()));
79 }
80
81 fn get_status(&self) -> Option<&str> {
83 match &self.status_message {
84 Some((msg, timestamp)) if timestamp.elapsed() < Duration::from_secs(2) => Some(msg),
85 _ => None,
86 }
87 }
88}
89
90fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
92 enable_raw_mode().context("Failed to enable raw mode")?;
93 io::stdout()
94 .execute(EnterAlternateScreen)
95 .context("Failed to enter alternate screen")?;
96 let backend = CrosstermBackend::new(io::stdout());
97 Terminal::new(backend).context("Failed to create terminal")
98}
99
100fn restore_terminal() -> Result<()> {
102 disable_raw_mode().context("Failed to disable raw mode")?;
103 io::stdout()
104 .execute(LeaveAlternateScreen)
105 .context("Failed to leave alternate screen")?;
106 Ok(())
107}
108
109pub fn render_calibration_progress(current: usize, total: usize, is_complete: bool) {
112 use std::io::Write;
113
114 if total == 0 {
115 return;
116 }
117
118 let percent = ((current as f64 / total as f64) * 100.0) as u16;
119
120 let cyan = "\x1b[36m";
122 let green = "\x1b[32m";
123 let bold = "\x1b[1m";
124 let reset = "\x1b[0m";
125
126 let bar_width = 40;
128 let filled = ((current as f64 / total as f64) * bar_width as f64) as usize;
129 let empty = bar_width - filled;
130
131 let bar = format!(
132 "{}{}{}{}{}",
133 cyan,
134 "โ".repeat(filled),
135 reset,
136 "โ".repeat(empty),
137 reset
138 );
139
140 if is_complete {
141 print!(
143 "\r{green}โ{reset} {bold}ETD Calibration Complete{reset} [{bar}] {current}/{total} pairs ({percent}%)\n",
144 green = green,
145 reset = reset,
146 bold = bold,
147 bar = bar,
148 current = current,
149 total = total,
150 percent = percent
151 );
152 } else {
153 print!(
155 "\r{cyan}โณ{reset} {bold}ETD Calibration{reset} [{bar}] {current}/{total} pairs ({percent}%) ",
156 cyan = cyan,
157 reset = reset,
158 bold = bold,
159 bar = bar,
160 current = current,
161 total = total,
162 percent = percent
163 );
164 }
165
166 let _ = io::stdout().flush();
167}
168
169pub struct StartupParams<'a> {
171 pub topology: &'a TopologyInfo,
172 pub latency_matrix: &'a [Vec<f64>],
173 pub profile: &'a str,
174 pub quantum: u64,
175 pub starvation: u64,
176}
177
178pub fn render_startup_screen(params: StartupParams) -> Result<()> {
181 let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
183 let nr_cpus = params.latency_matrix.len();
184
185 let left_height = 6 + 6 + (nr_cpus / 8 + 6);
187 let matrix_height = nr_cpus + 6;
188 let body_height = left_height.max(matrix_height);
189 let total_height = (4 + body_height + 3) as u16;
190
191 let area = Rect::new(0, 0, width, total_height);
192 let mut buffer = Buffer::empty(area);
193
194 let mut fx_manager: EffectManager<()> = EffectManager::default();
196 let duration_ms = 4200u32;
197
198 fx_manager.add_effect(fx::dissolve(2000u32));
200
201 fx_manager.add_effect(fx::sequence(&[fx::delay(1200u32, fx::coalesce(1800u32))]));
203
204 execute!(io::stdout(), EnterAlternateScreen, Hide)?;
206
207 let start_time = Instant::now();
208 let frame_rate = Duration::from_millis(16); while start_time.elapsed().as_millis() < duration_ms as u128 {
211 let frame_start = Instant::now();
212 let elapsed_ms = start_time.elapsed().as_millis() as u32;
213 buffer.reset();
214
215 render_startup_widgets(&mut buffer, area, ¶ms, elapsed_ms);
216
217 let t_duration = tachyonfx::Duration::from_millis(elapsed_ms);
218 fx_manager.process_effects(t_duration, &mut buffer, area);
219
220 execute!(io::stdout(), MoveTo(0, 0))?;
222
223 let render_height = total_height.min(height);
225 for y in 0..render_height {
226 let mut last_style = Style::default();
227 for x in 0..width {
228 let cell = &buffer[(x, y)];
229 let cell_style = cell.style();
230 if cell_style != last_style {
231 print!("{}", cell_style.to_ansi_sequence());
232 last_style = cell_style;
233 }
234 print!("{}", cell.symbol());
235 }
236 if y < render_height - 1 {
237 print!("\x1b[0m\r\n");
238 } else {
239 print!("\x1b[0m");
240 }
241 }
242 io::stdout().flush()?;
243
244 let sleep_time = frame_rate.saturating_sub(frame_start.elapsed());
245 if !sleep_time.is_zero() {
246 std::thread::sleep(sleep_time);
247 }
248 }
249
250 execute!(io::stdout(), LeaveAlternateScreen, Show)?;
252
253 buffer.reset();
255 render_startup_widgets(
256 &mut buffer,
257 area,
258 ¶ms,
259 duration_ms, );
261
262 let final_duration = tachyonfx::Duration::from_millis(duration_ms);
264 fx_manager.process_effects(final_duration, &mut buffer, area);
265
266 for y in 0..total_height {
267 let mut last_style = Style::default();
268 for x in 0..width {
269 let cell = &buffer[(x, y)];
270 let cell_style = cell.style();
271 if cell_style != last_style {
272 print!("{}", cell_style.to_ansi_sequence());
273 last_style = cell_style;
274 }
275 print!("{}", cell.symbol());
276 }
277 println!("\x1b[0m");
278 }
279 io::stdout().flush()?;
280
281 Ok(())
282}
283
284trait ToAnsi {
286 fn to_ansi_sequence(&self) -> String;
287}
288
289impl ToAnsi for Style {
290 fn to_ansi_sequence(&self) -> String {
291 let mut seq = String::from("\x1b[0");
292
293 if let Some(fg) = self.fg {
294 match fg {
295 Color::Rgb(r, g, b) => seq.push_str(&format!(";38;2;{};{};{}", r, g, b)),
296 Color::Black => seq.push_str(";30"),
297 Color::Red => seq.push_str(";31"),
298 Color::Green => seq.push_str(";32"),
299 Color::Yellow => seq.push_str(";33"),
300 Color::Blue => seq.push_str(";34"),
301 Color::Magenta => seq.push_str(";35"),
302 Color::Cyan => seq.push_str(";36"),
303 Color::Gray => seq.push_str(";37"),
304 Color::DarkGray => seq.push_str(";90"),
305 Color::LightRed => seq.push_str(";91"),
306 Color::LightGreen => seq.push_str(";92"),
307 Color::LightYellow => seq.push_str(";93"),
308 Color::LightBlue => seq.push_str(";94"),
309 Color::LightMagenta => seq.push_str(";95"),
310 Color::LightCyan => seq.push_str(";96"),
311 Color::White => seq.push_str(";97"),
312 _ => {}
313 }
314 }
315
316 if let Some(bg) = self.bg {
317 match bg {
318 Color::Rgb(r, g, b) => seq.push_str(&format!(";48;2;{};{};{}", r, g, b)),
319 Color::Black => seq.push_str(";40"),
320 _ => {} }
322 }
323
324 if self.add_modifier.contains(Modifier::BOLD) {
325 seq.push_str(";1");
326 }
327 if self.add_modifier.contains(Modifier::ITALIC) {
328 seq.push_str(";3");
329 }
330 if self.add_modifier.contains(Modifier::DIM) {
331 seq.push_str(";2");
332 }
333
334 seq.push('m');
335 seq
336 }
337}
338
339fn render_startup_widgets(
341 buffer: &mut Buffer,
342 area: Rect,
343 params: &StartupParams,
344 elapsed_ms: u32,
345) {
346 let outer_layout = Layout::default()
349 .direction(Direction::Vertical)
350 .constraints([
351 Constraint::Length(4), Constraint::Min(20), Constraint::Length(3), ])
355 .split(area);
356
357 let dashboard_layout = Layout::default()
359 .direction(Direction::Horizontal)
360 .constraints([
361 Constraint::Percentage(22), Constraint::Percentage(39), Constraint::Fill(1), ])
365 .split(outer_layout[1]);
366
367 let left_layout = Layout::default()
369 .direction(Direction::Vertical)
370 .constraints([
371 Constraint::Length(6), Constraint::Length(6), Constraint::Min(8), ])
375 .split(dashboard_layout[0]);
376
377 let author_full = "by RitzDaCat";
379 let typing_start = 1000u32;
381 let ms_per_char = 100u32;
382 let chars_to_show = if elapsed_ms < typing_start {
383 0
384 } else {
385 ((elapsed_ms - typing_start) / ms_per_char).min(author_full.len() as u32) as usize
386 };
387 let author_typed = &author_full[..chars_to_show];
388
389 let title = Paragraph::new(vec![
390 Line::from(vec![
391 Span::styled(" ๐ฐ ", Style::default().fg(Color::Yellow)),
392 Span::styled(
393 "scx_cake ",
394 Style::default()
395 .fg(Color::Cyan)
396 .add_modifier(Modifier::BOLD),
397 ),
398 Span::styled("v1.02", Style::default().fg(Color::White)),
399 Span::styled(
400 " โ Gaming Oriented Scheduler",
401 Style::default()
402 .fg(Color::White)
403 .add_modifier(Modifier::BOLD),
404 ),
405 ]),
406 Line::from(vec![Span::styled(
407 author_typed,
408 Style::default()
409 .fg(Color::DarkGray)
410 .add_modifier(Modifier::ITALIC),
411 )]),
412 ])
413 .block(
414 Block::default()
415 .borders(Borders::ALL)
416 .border_type(BorderType::Rounded)
417 .border_style(Style::default().fg(Color::Cyan).dim()),
418 )
419 .alignment(Alignment::Center);
420 title.render(outer_layout[0], buffer);
421
422 let smt_str = if params.topology.smt_enabled {
424 "On"
425 } else {
426 "Off"
427 };
428 let hardware_rows = vec![
429 Row::new(vec![
430 Cell::from("CPUs").style(Style::default().fg(Color::Cyan)),
431 Cell::from(params.topology.nr_cpus.to_string()),
432 ]),
433 Row::new(vec![
434 Cell::from("SMT").style(Style::default().fg(Color::Cyan)),
435 Cell::from(smt_str),
436 ]),
437 Row::new(vec![
438 Cell::from("Layout").style(Style::default().fg(Color::Cyan)),
439 Cell::from(if params.topology.has_dual_ccd {
440 "Multi-CCD"
441 } else {
442 "Single"
443 }),
444 ]),
445 ];
446
447 let hardware_block = Table::new(hardware_rows, [Constraint::Length(10), Constraint::Min(10)])
448 .block(
449 Block::default()
450 .title(" System ")
451 .borders(Borders::ALL)
452 .border_type(BorderType::Rounded)
453 .border_style(Style::default().fg(Color::Cyan).dim()),
454 );
455 Widget::render(hardware_block, left_layout[0], buffer);
456
457 let profile_text = Paragraph::new(vec![
459 Line::from(vec![
460 Span::styled("Mode: ", Style::default().fg(Color::Cyan)),
461 Span::styled(
462 params.profile,
463 Style::default()
464 .fg(Color::White)
465 .add_modifier(Modifier::BOLD),
466 ),
467 ]),
468 Line::from(vec![
469 Span::styled("Quantum: ", Style::default().fg(Color::Cyan)),
470 Span::styled(
471 format!("{}ยตs", params.quantum),
472 Style::default().fg(Color::White),
473 ),
474 ]),
475 Line::from(vec![
476 Span::styled("Preempt: ", Style::default().fg(Color::Cyan)),
477 Span::styled(
478 format!("{}ms", params.starvation / 1000),
479 Style::default().fg(Color::White),
480 ),
481 ]),
482 ])
483 .block(
484 Block::default()
485 .title(" Profile ")
486 .borders(Borders::ALL)
487 .border_type(BorderType::Rounded)
488 .border_style(Style::default().fg(Color::Cyan).dim()),
489 );
490 profile_text.render(left_layout[1], buffer);
491
492 let topology_grid = build_cpu_topology_grid_compact(params.topology);
494 topology_grid.render(left_layout[2], buffer);
495
496 let heatmap = LatencyHeatmap::new(params.latency_matrix, params.topology);
498 heatmap.render(dashboard_layout[1], buffer);
499
500 let data_table = LatencyTable::new(params.latency_matrix, params.topology);
502 data_table.render(dashboard_layout[2], buffer);
503
504 let footer = Paragraph::new(vec![Line::from(vec![
506 Span::styled(
507 "โ ",
508 Style::default()
509 .fg(Color::Green)
510 .add_modifier(Modifier::BOLD),
511 ),
512 Span::styled(
513 "Cake is online!",
514 Style::default()
515 .fg(Color::Green)
516 .add_modifier(Modifier::BOLD),
517 ),
518 ])])
519 .block(
520 Block::default()
521 .borders(Borders::ALL)
522 .border_type(BorderType::Rounded)
523 .border_style(Style::default().fg(Color::Cyan).dim()),
524 )
525 .alignment(Alignment::Center);
526
527 footer.render(outer_layout[2], buffer);
528}
529
530fn build_cpu_topology_grid_compact(topology: &TopologyInfo) -> Paragraph<'static> {
532 let nr_cpus = topology.nr_cpus.min(64);
533 let mut lines = Vec::new();
534
535 lines.push(Line::from(""));
536
537 let mut current_line = Vec::new();
538 for cpu in 0..nr_cpus {
539 let symbol = if topology.cpu_is_big.get(cpu).copied().unwrap_or(0) != 0 {
541 "โ" } else {
543 "โ" };
545
546 let color = if topology.cpu_is_big.get(cpu).copied().unwrap_or(0) != 0 {
547 Color::Magenta
548 } else {
549 Color::Cyan
550 };
551
552 current_line.push(Span::styled(
553 format!("{} ", symbol),
554 Style::default().fg(color),
555 ));
556
557 if (cpu + 1) % 8 == 0 {
558 lines.push(Line::from(current_line));
559 current_line = Vec::new();
560 }
561 }
562 if !current_line.is_empty() {
563 lines.push(Line::from(current_line));
564 }
565
566 lines.push(Line::from(""));
567 lines.push(Line::from(vec![
568 Span::styled(" โ ", Style::default().fg(Color::Magenta)),
569 Span::styled("Performance ", Style::default().fg(Color::Gray).dim()),
570 Span::styled(" โ ", Style::default().fg(Color::Cyan)),
571 Span::styled("Efficiency", Style::default().fg(Color::Gray).dim()),
572 ]));
573
574 Paragraph::new(lines).block(
575 Block::default()
576 .title(" Topology ")
577 .borders(Borders::ALL)
578 .border_type(BorderType::Rounded)
579 .border_style(Style::default().fg(Color::Cyan).dim())
580 .padding(Padding::horizontal(1)),
581 )
582}
583
584struct LatencyHeatmap<'a> {
586 matrix: &'a [Vec<f64>],
587 topology: &'a TopologyInfo,
588}
589
590impl<'a> LatencyHeatmap<'a> {
591 fn new(matrix: &'a [Vec<f64>], topology: &'a TopologyInfo) -> Self {
592 Self { matrix, topology }
593 }
594}
595
596impl<'a> Widget for LatencyHeatmap<'a> {
597 fn render(self, area: Rect, buf: &mut Buffer) {
598 let nr_cpus = self.matrix.len();
599
600 let block = Block::default()
601 .title(" Latency Heatmap ")
602 .borders(Borders::ALL)
603 .border_type(BorderType::Rounded)
604 .border_style(Style::default().fg(Color::Cyan).dim());
605
606 let inner_area = block.inner(area);
607 block.render(area, buf);
608
609 if inner_area.width < 10 || inner_area.height < 5 {
610 return;
611 }
612
613 for j in 0..nr_cpus {
615 let x = inner_area.x + 6 + (j as u16 * 2);
616 if x < inner_area.right() {
617 buf.set_string(
618 x,
619 inner_area.y,
620 format!("{:1}", j % 10),
621 Style::default().fg(Color::Cyan).dim(),
622 );
623 }
624 }
625
626 for i in 0..nr_cpus {
627 let y = inner_area.y + 1 + i as u16;
628 if y >= inner_area.bottom() {
629 break;
630 }
631
632 buf.set_string(
634 inner_area.x + 1,
635 y,
636 format!("C{:02}", i),
637 Style::default().fg(Color::Cyan).dim(),
638 );
639
640 for j in 0..nr_cpus {
641 let x = inner_area.x + 6 + (j as u16 * 2);
642 if x >= inner_area.right() - 1 {
643 continue;
644 }
645
646 let is_self = i == j;
647 let is_smt = self.topology.cpu_sibling_map[i] as usize == j;
648 let same_ccd = self.topology.cpu_llc_id[i] == self.topology.cpu_llc_id[j];
649
650 let style = if is_self {
651 Style::default().fg(Color::Rgb(40, 40, 40))
652 } else if is_smt {
653 Style::default().fg(Color::Rgb(0, 255, 150)) } else if same_ccd {
655 Style::default().fg(Color::Rgb(0, 200, 255)) } else {
657 Style::default().fg(Color::Rgb(255, 180, 0)) };
659
660 buf.set_string(x, y, "โ", style);
661 buf.set_string(x + 1, y, " ", Style::default());
662 }
663 }
664
665 let legend_y = inner_area.bottom().saturating_sub(1);
667 let legend_x = inner_area.x + 1;
668 if legend_y > inner_area.y + nr_cpus as u16 {
669 buf.set_string(
670 legend_x,
671 legend_y,
672 "โ SMT",
673 Style::default().fg(Color::Rgb(0, 255, 150)),
674 );
675 buf.set_string(
676 legend_x + 9,
677 legend_y,
678 "โ Same CCD",
679 Style::default().fg(Color::Rgb(0, 200, 255)),
680 );
681 buf.set_string(
682 legend_x + 22,
683 legend_y,
684 "โ Cross-CCD",
685 Style::default().fg(Color::Rgb(255, 180, 0)),
686 );
687 }
688 }
689}
690
691struct LatencyTable<'a> {
693 matrix: &'a [Vec<f64>],
694 topology: &'a TopologyInfo,
695}
696
697impl<'a> LatencyTable<'a> {
698 fn new(matrix: &'a [Vec<f64>], topology: &'a TopologyInfo) -> Self {
699 Self { matrix, topology }
700 }
701}
702
703impl<'a> Widget for LatencyTable<'a> {
704 fn render(self, area: Rect, buf: &mut Buffer) {
705 let nr_cpus = self.matrix.len();
706
707 let block = Block::default()
708 .title(" Latency Data ")
709 .borders(Borders::ALL)
710 .border_type(BorderType::Rounded)
711 .border_style(Style::default().fg(Color::Cyan).dim());
712
713 let inner_area = block.inner(area);
714 block.render(area, buf);
715
716 if inner_area.width < 10 || inner_area.height < 5 {
717 return;
718 }
719
720 for j in 0..nr_cpus {
722 let x = inner_area.x + 5 + (j as u16 * 3);
723 if x < inner_area.right() {
724 buf.set_string(
725 x,
726 inner_area.y,
727 format!("{:>2}", j),
728 Style::default().fg(Color::Cyan).dim(),
729 );
730 }
731 }
732
733 for i in 0..nr_cpus {
734 let y = inner_area.y + 1 + i as u16;
735 if y >= inner_area.bottom() {
736 break;
737 }
738
739 buf.set_string(
740 inner_area.x + 1,
741 y,
742 format!("C{:02}", i),
743 Style::default().fg(Color::Cyan).dim(),
744 );
745
746 for j in 0..nr_cpus {
747 let x = inner_area.x + 5 + (j as u16 * 3);
748 if x >= inner_area.right() - 2 {
749 continue;
750 }
751
752 let val = self.matrix[i][j].min(999.0);
753 let is_self = i == j;
754 let is_smt = self.topology.cpu_sibling_map[i] as usize == j;
755 let same_ccd = self.topology.cpu_llc_id[i] == self.topology.cpu_llc_id[j];
756
757 let style = if is_self {
758 Style::default().fg(Color::Rgb(40, 40, 40))
759 } else if is_smt {
760 Style::default().fg(Color::Rgb(0, 255, 150))
761 } else if same_ccd {
762 Style::default().fg(Color::Rgb(0, 200, 255))
763 } else {
764 Style::default().fg(Color::Rgb(255, 180, 0))
765 };
766
767 buf.set_string(x, y, format!("{:>2.0}", val), style);
768 }
769 }
770 }
771}
772
773fn format_stats_for_clipboard(stats: &cake_stats, uptime: &str) -> String {
775 let total_dispatches = stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches;
776 let new_pct = if total_dispatches > 0 {
777 (stats.nr_new_flow_dispatches as f64 / total_dispatches as f64) * 100.0
778 } else {
779 0.0
780 };
781
782 let mut output = String::new();
783 output.push_str(&format!(
784 "=== scx_cake Statistics (Uptime: {}) ===\n\n",
785 uptime
786 ));
787 output.push_str(&format!(
788 "Dispatches: {} total ({:.1}% new-flow)\n\n",
789 total_dispatches, new_pct
790 ));
791
792 output.push_str("Tier Dispatches StarvPreempt\n");
793 output.push_str("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n");
794 for (i, name) in TIER_NAMES.iter().enumerate() {
795 output.push_str(&format!(
796 "{:12} {:>10} {:>12}\n",
797 name, stats.nr_tier_dispatches[i], stats.nr_starvation_preempts_tier[i]
798 ));
799 }
800
801 output
802}
803
804fn draw_ui(frame: &mut Frame, app: &TuiApp, stats: &cake_stats) {
806 let area = frame.area();
807
808 let layout = Layout::default()
810 .direction(Direction::Vertical)
811 .constraints([
812 Constraint::Length(3), Constraint::Min(10), Constraint::Length(5), Constraint::Length(3), ])
817 .split(area);
818
819 let total_dispatches = stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches;
821 let new_pct = if total_dispatches > 0 {
822 (stats.nr_new_flow_dispatches as f64 / total_dispatches as f64) * 100.0
823 } else {
824 0.0
825 };
826
827 let topo_info = format!(
829 "CPUs: {} {}{}{}",
830 app.topology.nr_cpus,
831 if app.topology.has_dual_ccd {
832 "[Dual-CCD]"
833 } else {
834 ""
835 },
836 if app.topology.has_hybrid_cores {
837 "[Hybrid]"
838 } else {
839 ""
840 },
841 if app.topology.smt_enabled {
842 "[SMT]"
843 } else {
844 ""
845 },
846 );
847
848 let header_text = format!(
849 " {} โ Dispatches: {} ({:.1}% new) โ Uptime: {}",
850 topo_info,
851 total_dispatches,
852 new_pct,
853 app.format_uptime()
854 );
855 let header = Paragraph::new(header_text).block(
856 Block::default()
857 .title(" scx_cake Statistics ")
858 .title_style(
859 Style::default()
860 .fg(Color::Cyan)
861 .add_modifier(Modifier::BOLD),
862 )
863 .borders(Borders::ALL)
864 .border_style(Style::default().fg(Color::Blue)),
865 );
866 frame.render_widget(header, layout[0]);
867
868 let header_cells = ["Tier", "Dispatches", "StarvPreempt"].iter().map(|h| {
870 Cell::from(*h).style(
871 Style::default()
872 .fg(Color::Yellow)
873 .add_modifier(Modifier::BOLD),
874 )
875 });
876 let header_row = Row::new(header_cells).height(1);
877
878 let rows: Vec<Row> = TIER_NAMES
879 .iter()
880 .enumerate()
881 .map(|(i, name)| {
882 let cells = vec![
883 Cell::from(*name).style(tier_style(i)),
884 Cell::from(format!("{}", stats.nr_tier_dispatches[i])),
885 Cell::from(format!("{}", stats.nr_starvation_preempts_tier[i])),
886 ];
887 Row::new(cells).height(1)
888 })
889 .collect();
890
891 let table = Table::new(
892 rows,
893 [
894 Constraint::Length(12),
895 Constraint::Length(12),
896 Constraint::Length(14),
897 ],
898 )
899 .header(header_row)
900 .block(
901 Block::default()
902 .title(" Per-Tier Statistics ")
903 .borders(Borders::ALL)
904 .border_style(Style::default().fg(Color::Blue)),
905 );
906 frame.render_widget(table, layout[1]);
907
908 let total_starvation: u64 = stats.nr_starvation_preempts_tier.iter().sum();
910 let summary_text = format!(
911 " Dispatches: {} | Starvation preempts: {}",
912 stats.nr_new_flow_dispatches + stats.nr_old_flow_dispatches,
913 total_starvation
914 );
915
916 let summary = Paragraph::new(summary_text).block(
917 Block::default()
918 .title(" Summary ")
919 .borders(Borders::ALL)
920 .border_style(Style::default().fg(Color::Blue)),
921 );
922 frame.render_widget(summary, layout[2]);
923
924 let footer_text = match app.get_status() {
926 Some(status) => format!(" [q] Quit [c] Copy [r] Reset โ {}", status),
927 None => " [q] Quit [c] Copy to clipboard [r] Reset stats".to_string(),
928 };
929 let (fg_color, border_color) = if app.get_status().is_some() {
930 (Color::Green, Color::Green)
931 } else {
932 (Color::DarkGray, Color::DarkGray)
933 };
934 let footer = Paragraph::new(footer_text)
935 .style(Style::default().fg(fg_color))
936 .block(
937 Block::default()
938 .borders(Borders::ALL)
939 .border_style(Style::default().fg(border_color)),
940 );
941 frame.render_widget(footer, layout[3]);
942}
943
944fn tier_style(tier: usize) -> Style {
946 match tier {
947 0 => Style::default()
948 .fg(Color::Cyan)
949 .add_modifier(Modifier::BOLD), 1 => Style::default().fg(Color::Green), 2 => Style::default().fg(Color::Yellow), 3 => Style::default().fg(Color::DarkGray), _ => Style::default(),
954 }
955}
956
957pub fn run_tui(
959 skel: &mut BpfSkel,
960 shutdown: Arc<AtomicBool>,
961 interval_secs: u64,
962 topology: TopologyInfo,
963) -> Result<()> {
964 let mut terminal = setup_terminal()?;
965 let mut app = TuiApp::new(topology);
966 let tick_rate = Duration::from_secs(interval_secs);
967 let mut last_tick = Instant::now();
968
969 let mut clipboard = Clipboard::new().ok();
971
972 loop {
973 if shutdown.load(Ordering::Relaxed) {
975 break;
976 }
977
978 if scx_utils::uei_exited!(skel, uei) {
980 break;
981 }
982
983 let stats = aggregate_stats(skel);
985
986 terminal.draw(|frame| draw_ui(frame, &app, &stats))?;
988
989 let timeout = tick_rate.saturating_sub(last_tick.elapsed());
991 if event::poll(timeout)? {
992 if let Event::Key(key) = event::read()? {
993 if key.kind == KeyEventKind::Press {
994 match key.code {
995 KeyCode::Char('q') | KeyCode::Esc => {
996 shutdown.store(true, Ordering::Relaxed);
997 break;
998 }
999 KeyCode::Char('c') => {
1000 let text = format_stats_for_clipboard(&stats, &app.format_uptime());
1002 match &mut clipboard {
1003 Some(cb) => match cb.set_text(text) {
1004 Ok(_) => app.set_status("โ Copied to clipboard!"),
1005 Err(_) => app.set_status("โ Failed to copy"),
1006 },
1007 None => app.set_status("โ Clipboard not available"),
1008 }
1009 }
1010 KeyCode::Char('r') => {
1011 if let Some(bss) = &mut skel.maps.bss_data {
1013 for s in &mut bss.global_stats {
1014 *s = Default::default();
1015 }
1016 app.set_status("โ Stats reset");
1017 }
1018 }
1019 _ => {}
1020 }
1021 }
1022 }
1023 }
1024
1025 if last_tick.elapsed() >= tick_rate {
1026 last_tick = Instant::now();
1027 }
1028 }
1029
1030 restore_terminal()?;
1031 Ok(())
1032}