1#[cfg(feature = "debug_pane")]
2mod debug_pane;
3pub mod sysinfo;
4mod tui_nodes;
5
6use crate::tui_nodes::{Connection, NodeGraph, NodeLayout};
7use ansi_to_tui::IntoText;
8use color_eyre::config::HookBuilder;
9use compact_str::{CompactString, ToCompactString};
10use cu29::clock::{CuDuration, RobotClock};
11use cu29::config::CuConfig;
12use cu29::config::Flavor;
13use cu29::curuntime::{CuExecutionUnit, compute_runtime_plan};
14use cu29::cutask::CuMsgMetadata;
15use cu29::monitoring::{
16 ComponentKind, CopperListInfo, CopperListIoStats, CuDurationStatistics, CuMonitor, CuTaskState,
17 Decision, MonitorTopology,
18};
19use cu29::prelude::{CuCompactString, CuTime, pool};
20use cu29::{CuError, CuResult};
21use cu29_log::CuLogLevel;
22#[cfg(debug_assertions)]
23use cu29_log_runtime::{
24 format_message_only, register_live_log_listener, unregister_live_log_listener,
25};
26#[cfg(feature = "debug_pane")]
27use debug_pane::{StyledLine, StyledRun, UIExt};
28use ratatui::backend::CrosstermBackend;
29use ratatui::buffer::Buffer;
30use ratatui::crossterm::event::{
31 DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEventKind,
32};
33use ratatui::crossterm::terminal::{
34 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
35};
36use ratatui::crossterm::tty::IsTty;
37use ratatui::crossterm::{event, execute};
38use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Size};
39use ratatui::prelude::Stylize;
40use ratatui::prelude::{Backend, Rect};
41use ratatui::style::{Color, Modifier, Style};
42use ratatui::text::{Line, Span, Text};
43use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, StatefulWidget, Table};
44use ratatui::{Frame, Terminal};
45use std::backtrace::Backtrace;
46use std::fmt::{Display, Formatter};
47use std::io::{Write, stdin, stdout};
48use std::marker::PhantomData;
49use std::process;
50use std::sync::atomic::{AtomicBool, Ordering};
51use std::sync::{Arc, Mutex, OnceLock};
52use std::thread::JoinHandle;
53use std::time::{Duration, Instant};
54use std::{collections::HashMap, io, thread};
55use tui_widgets::scrollview::{ScrollView, ScrollViewState};
56
57#[cfg(feature = "debug_pane")]
58use arboard::Clipboard;
59
60#[derive(Clone, Copy)]
61struct TabDef {
62 screen: Screen,
63 label: &'static str,
64 key: &'static str,
65}
66
67#[derive(Clone, Copy)]
68struct TabHitbox {
69 screen: Screen,
70 x: u16,
71 y: u16,
72 width: u16,
73 height: u16,
74}
75
76#[derive(Clone, Copy)]
77enum HelpAction {
78 ResetLatency,
79 Quit,
80}
81
82#[derive(Clone, Copy)]
83struct HelpHitbox {
84 action: HelpAction,
85 x: u16,
86 y: u16,
87 width: u16,
88 height: u16,
89}
90
91const TAB_DEFS: &[TabDef] = &[
92 TabDef {
93 screen: Screen::Neofetch,
94 label: "SYS",
95 key: "1",
96 },
97 TabDef {
98 screen: Screen::Dag,
99 label: "DAG",
100 key: "2",
101 },
102 TabDef {
103 screen: Screen::Latency,
104 label: "LAT",
105 key: "3",
106 },
107 TabDef {
108 screen: Screen::CopperList,
109 label: "BW",
110 key: "4",
111 },
112 TabDef {
113 screen: Screen::MemoryPools,
114 label: "MEM",
115 key: "5",
116 },
117 #[cfg(feature = "debug_pane")]
118 TabDef {
119 screen: Screen::DebugOutput,
120 label: "LOG",
121 key: "6",
122 },
123];
124
125const COPPERLIST_RATE_WINDOW: Duration = Duration::from_secs(1);
126
127#[derive(Clone, Copy, PartialEq)]
128enum Screen {
129 Neofetch,
130 Dag,
131 Latency,
132 MemoryPools,
133 CopperList,
134 #[cfg(feature = "debug_pane")]
135 DebugOutput,
136}
137
138#[cfg(feature = "debug_pane")]
139#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
140struct SelectionPoint {
141 row: usize,
142 col: usize,
143}
144
145#[cfg(feature = "debug_pane")]
146#[derive(Clone, Copy, Debug, Default)]
147struct DebugSelection {
148 anchor: Option<SelectionPoint>,
149 cursor: Option<SelectionPoint>,
150}
151
152#[cfg(feature = "debug_pane")]
153impl DebugSelection {
154 fn clear(&mut self) {
155 self.anchor = None;
156 self.cursor = None;
157 }
158
159 fn start(&mut self, point: SelectionPoint) {
160 self.anchor = Some(point);
161 self.cursor = Some(point);
162 }
163
164 fn update(&mut self, point: SelectionPoint) {
165 if self.anchor.is_some() {
166 self.cursor = Some(point);
167 }
168 }
169
170 fn range(&self) -> Option<(SelectionPoint, SelectionPoint)> {
171 let anchor = self.anchor?;
172 let cursor = self.cursor?;
173 if (anchor.row, anchor.col) <= (cursor.row, cursor.col) {
174 Some((anchor, cursor))
175 } else {
176 Some((cursor, anchor))
177 }
178 }
179}
180
181fn segment_width(key: &str, label: &str) -> u16 {
182 (6 + key.chars().count() + label.chars().count()) as u16
183}
184
185fn help_action(key: &str) -> Option<HelpAction> {
186 match key {
187 "r" => Some(HelpAction::ResetLatency),
188 "q" => Some(HelpAction::Quit),
189 _ => None,
190 }
191}
192
193fn mouse_inside(mouse: &event::MouseEvent, x: u16, y: u16, width: u16, height: u16) -> bool {
194 mouse.column >= x && mouse.column < x + width && mouse.row >= y && mouse.row < y + height
195}
196
197#[cfg(feature = "debug_pane")]
198fn char_to_byte_index(text: &str, char_idx: usize) -> usize {
199 text.char_indices()
200 .nth(char_idx)
201 .map(|(idx, _)| idx)
202 .unwrap_or(text.len())
203}
204
205#[cfg(feature = "debug_pane")]
206fn slice_char_range(text: &str, start: usize, end: usize) -> (&str, &str, &str) {
207 let start_idx = char_to_byte_index(text, start);
208 let end_idx = char_to_byte_index(text, end);
209 let start_idx = start_idx.min(text.len());
210 let end_idx = end_idx.min(text.len());
211 let (start_idx, end_idx) = if start_idx <= end_idx {
212 (start_idx, end_idx)
213 } else {
214 (end_idx, start_idx)
215 };
216 (
217 &text[..start_idx],
218 &text[start_idx..end_idx],
219 &text[end_idx..],
220 )
221}
222
223#[cfg(feature = "debug_pane")]
224fn line_selection_bounds(
225 line_index: usize,
226 line_len: usize,
227 start: SelectionPoint,
228 end: SelectionPoint,
229) -> Option<(usize, usize)> {
230 if line_index < start.row || line_index > end.row {
231 return None;
232 }
233
234 let start_col = if line_index == start.row {
235 start.col
236 } else {
237 0
238 };
239 let mut end_col = if line_index == end.row {
240 end.col
241 } else {
242 line_len
243 };
244 if line_index == end.row {
245 end_col = end_col.saturating_add(1).min(line_len);
246 }
247 let start_col = start_col.min(line_len);
248 let end_col = end_col.min(line_len);
249 if start_col >= end_col {
250 return None;
251 }
252
253 Some((start_col, end_col))
254}
255
256#[cfg(feature = "debug_pane")]
257fn slice_chars_owned(text: &str, start: usize, end: usize) -> String {
258 let start_idx = char_to_byte_index(text, start);
259 let end_idx = char_to_byte_index(text, end);
260 text[start_idx.min(text.len())..end_idx.min(text.len())].to_string()
261}
262
263#[cfg(feature = "debug_pane")]
264fn spans_from_runs(line: &StyledLine) -> Vec<Span<'static>> {
265 if line.runs.is_empty() {
266 return vec![Span::raw(line.text.clone())];
267 }
268
269 let mut spans = Vec::new();
270 let mut cursor = 0usize;
271 let total_chars = line.text.chars().count();
272 let mut runs = line.runs.clone();
273 runs.sort_by_key(|r| r.start);
274
275 for run in runs {
276 let start = run.start.min(total_chars);
277 let end = run.end.min(total_chars);
278 if start > cursor {
279 let before = slice_chars_owned(&line.text, cursor, start);
280 if !before.is_empty() {
281 spans.push(Span::raw(before));
282 }
283 }
284 if end > start {
285 let segment = slice_chars_owned(&line.text, start, end);
286 spans.push(Span::styled(segment, run.style));
287 }
288 cursor = cursor.max(end);
289 }
290
291 if cursor < total_chars {
292 let tail = slice_chars_owned(&line.text, cursor, total_chars);
293 if !tail.is_empty() {
294 spans.push(Span::raw(tail));
295 }
296 }
297
298 spans
299}
300
301#[cfg(feature = "debug_pane")]
302fn color_for_level(level: CuLogLevel) -> Color {
303 match level {
304 CuLogLevel::Debug => Color::Green,
305 CuLogLevel::Info => Color::Gray,
306 CuLogLevel::Warning => Color::Yellow,
307 CuLogLevel::Error => Color::Red,
308 CuLogLevel::Critical => Color::Red,
309 }
310}
311
312#[cfg(feature = "debug_pane")]
313fn format_ts(time: CuTime) -> String {
314 let nanos = time.as_nanos();
315 let total_ms = nanos / 1_000_000;
316 let millis = total_ms % 1000;
317 let total_s = total_ms / 1000;
318 let secs = total_s % 60;
319 let mins = (total_s / 60) % 60;
320 let hours = (total_s / 3600) % 24;
321 format!("{hours:02}:{mins:02}:{secs:02}.{millis:03}")
322}
323
324#[cfg(feature = "debug_pane")]
325fn build_message_with_runs(
326 format_str: &str,
327 params: &[String],
328 named_params: &HashMap<String, String>,
329) -> (String, Vec<(usize, usize)>) {
330 let mut out = String::new();
331 let mut param_spans = Vec::new();
332 let mut anon_iter = params.iter();
333 let mut iter = format_str.char_indices().peekable();
334 while let Some((idx, ch)) = iter.next() {
335 if ch == '{' {
336 let start_idx = idx + ch.len_utf8();
337 if let Some(end) = format_str[start_idx..].find('}') {
338 let end_idx = start_idx + end;
339 let placeholder = &format_str[start_idx..end_idx];
340 let replacement_opt = if placeholder.is_empty() {
341 anon_iter.next()
342 } else {
343 named_params.get(placeholder)
344 };
345 if let Some(repl) = replacement_opt {
346 let span_start = out.chars().count();
347 out.push_str(repl);
348 let span_end = out.chars().count();
349 param_spans.push((span_start, span_end));
350 let skip_to = end_idx + '}'.len_utf8();
351 while let Some((next_idx, _)) = iter.peek().copied() {
352 if next_idx < skip_to {
353 iter.next();
354 } else {
355 break;
356 }
357 }
358 continue;
359 }
360 }
361 }
362 out.push(ch);
363 }
364 (out, param_spans)
365}
366
367#[cfg(feature = "debug_pane")]
368fn styled_line_from_structured(
369 time: CuTime,
370 level: CuLogLevel,
371 format_str: &str,
372 params: &[String],
373 named_params: &HashMap<String, String>,
374) -> StyledLine {
375 let ts = format_ts(time);
376 let level_txt = format!("[{:?}]", level);
377
378 let (msg_text, param_spans) = build_message_with_runs(format_str, params, named_params);
379 let mut msg_runs = Vec::new();
380 let mut cursor = 0usize;
381 let param_spans_sorted = {
382 let mut v = param_spans;
383 v.sort_by_key(|p| p.0);
384 v
385 };
386 for (start, end) in param_spans_sorted {
387 if start > cursor {
388 msg_runs.push(StyledRun {
389 start: cursor,
390 end: start,
391 style: Style::default().fg(Color::Gray),
392 });
393 }
394 msg_runs.push(StyledRun {
395 start,
396 end,
397 style: Style::default().fg(Color::Magenta),
398 });
399 cursor = end;
400 }
401 if cursor < msg_text.chars().count() {
402 msg_runs.push(StyledRun {
403 start: cursor,
404 end: msg_text.chars().count(),
405 style: Style::default().fg(Color::Gray),
406 });
407 }
408
409 let prefix = format!("{ts} {level_txt} ");
410 let prefix_len = prefix.chars().count();
411 let line_text = format!("{prefix}{msg_text}");
412
413 let mut runs = Vec::new();
414 let ts_len = ts.chars().count();
415 let level_start = ts_len + 1;
416 let level_end = level_start + level_txt.chars().count();
417
418 runs.push(StyledRun {
419 start: 0,
420 end: ts_len,
421 style: Style::default().fg(Color::Blue),
422 });
423 runs.push(StyledRun {
424 start: level_start,
425 end: level_end,
426 style: Style::default().fg(color_for_level(level)).bold(),
427 });
428 for run in msg_runs {
429 runs.push(StyledRun {
430 start: prefix_len + run.start,
431 end: prefix_len + run.end,
432 style: run.style,
433 });
434 }
435
436 StyledLine {
437 text: line_text,
438 runs,
439 }
440}
441
442struct TaskStats {
443 stats: Vec<CuDurationStatistics>,
444 end2end: CuDurationStatistics,
445}
446
447impl TaskStats {
448 fn new(num_tasks: usize, max_duration: CuDuration) -> Self {
449 let stats = vec![CuDurationStatistics::new(max_duration); num_tasks];
450 TaskStats {
451 stats,
452 end2end: CuDurationStatistics::new(max_duration),
453 }
454 }
455
456 fn update(&mut self, msgs: &[&CuMsgMetadata]) {
457 for (i, &msg) in msgs.iter().enumerate() {
458 let (before, after) = (
459 msg.process_time.start.unwrap(),
460 msg.process_time.end.unwrap(),
461 );
462 self.stats[i].record(after - before);
463 }
464 self.end2end.record(compute_end_to_end_latency(msgs));
465 }
466
467 fn reset(&mut self) {
468 for s in &mut self.stats {
469 s.reset();
470 }
471 self.end2end.reset();
472 }
473}
474struct PoolStats {
475 id: CompactString,
476 space_left: usize,
477 total_size: usize,
478 buffer_size: usize,
479 handles_in_use: usize,
480 handles_per_second: usize,
481 last_update: Instant,
482}
483
484impl PoolStats {
485 fn new(
486 id: impl ToCompactString,
487 space_left: usize,
488 total_size: usize,
489 buffer_size: usize,
490 ) -> Self {
491 Self {
492 id: id.to_compact_string(),
493 space_left,
494 total_size,
495 buffer_size,
496 handles_in_use: total_size - space_left,
497 handles_per_second: 0,
498 last_update: Instant::now(),
499 }
500 }
501
502 fn update(&mut self, space_left: usize, total_size: usize) {
503 let now = Instant::now();
504 let handles_in_use = total_size - space_left;
505 let elapsed = now.duration_since(self.last_update).as_secs_f32();
506
507 if elapsed >= 1.0 {
508 self.handles_per_second =
509 ((handles_in_use.abs_diff(self.handles_in_use)) as f32 / elapsed) as usize;
510 self.last_update = now;
511 }
512
513 self.handles_in_use = handles_in_use;
514 self.space_left = space_left;
515 self.total_size = total_size;
516 }
517}
518
519struct CopperListStats {
520 size_bytes: usize,
521 raw_culist_bytes: u64,
522 handle_bytes: u64,
523 encoded_bytes: u64,
524 keyframe_bytes: u64,
525 structured_total_bytes: u64,
526 structured_bytes_per_cl: u64,
527 total_copperlists: u64,
528 window_copperlists: u64,
529 last_rate_at: Instant,
530 rate_hz: f64,
531}
532
533impl CopperListStats {
534 fn new() -> Self {
535 Self {
536 size_bytes: 0,
537 raw_culist_bytes: 0,
538 handle_bytes: 0,
539 encoded_bytes: 0,
540 keyframe_bytes: 0,
541 structured_total_bytes: 0,
542 structured_bytes_per_cl: 0,
543 total_copperlists: 0,
544 window_copperlists: 0,
545 last_rate_at: Instant::now(),
546 rate_hz: 0.0,
547 }
548 }
549
550 fn set_info(&mut self, info: CopperListInfo) {
551 self.size_bytes = info.size_bytes;
552 }
553
554 fn update_io(&mut self, stats: cu29::monitoring::CopperListIoStats) {
555 self.raw_culist_bytes = stats.raw_culist_bytes;
556 self.handle_bytes = stats.handle_bytes;
557 self.encoded_bytes = stats.encoded_culist_bytes;
558 self.keyframe_bytes = stats.keyframe_bytes;
559 let total = stats.structured_log_bytes_total;
560 self.structured_bytes_per_cl = total.saturating_sub(self.structured_total_bytes);
561 self.structured_total_bytes = total;
562 }
563
564 fn update_rate(&mut self) {
565 self.total_copperlists = self.total_copperlists.saturating_add(1);
566 self.window_copperlists = self.window_copperlists.saturating_add(1);
567
568 let now = Instant::now();
569 let elapsed = now.duration_since(self.last_rate_at);
570 if elapsed >= COPPERLIST_RATE_WINDOW {
571 let elapsed_secs = elapsed.as_secs_f64();
572 self.rate_hz = if elapsed_secs > 0.0 {
573 self.window_copperlists as f64 / elapsed_secs
574 } else {
575 0.0
576 };
577 self.window_copperlists = 0;
578 self.last_rate_at = now;
579 }
580 }
581}
582
583fn compute_end_to_end_latency(msgs: &[&CuMsgMetadata]) -> CuDuration {
584 let start = msgs.first().map(|m| m.process_time.start);
585 let end = msgs.last().map(|m| m.process_time.end);
586
587 if let (Some(s), Some(e)) = (start, end)
588 && let (Some(s), Some(e)) = (Option::<CuTime>::from(s), Option::<CuTime>::from(e))
589 && e >= s
590 {
591 e - s
592 } else {
593 CuDuration::MIN
594 }
595}
596
597fn format_bytes(bytes: f64) -> String {
598 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
599 let mut value = bytes;
600 let mut unit_idx = 0;
601 while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
602 value /= 1024.0;
603 unit_idx += 1;
604 }
605 if unit_idx == 0 {
606 format!("{:.0} {}", value, UNITS[unit_idx])
607 } else {
608 format!("{:.2} {}", value, UNITS[unit_idx])
609 }
610}
611
612fn format_bytes_or(bytes: u64, fallback: &str) -> String {
613 if bytes > 0 {
614 format_bytes(bytes as f64)
615 } else {
616 fallback.to_string()
617 }
618}
619
620fn format_rate_bytes_or_na(bytes: u64, rate_hz: f64) -> String {
621 if bytes > 0 {
622 format!("{}/s", format_bytes((bytes as f64) * rate_hz))
623 } else {
624 "n/a".to_string()
625 }
626}
627
628#[derive(Copy, Clone)]
630enum NodeType {
631 Unknown,
632 Source,
633 Sink,
634 Task,
635 Bridge,
636}
637
638impl Display for NodeType {
639 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
640 match self {
641 Self::Unknown => write!(f, "?"),
642 Self::Source => write!(f, "◈"),
643 Self::Task => write!(f, "⚙"),
644 Self::Sink => write!(f, "⭳"),
645 Self::Bridge => write!(f, "⇆"),
646 }
647 }
648}
649
650impl NodeType {
651 fn add_incoming(self) -> NodeType {
652 match self {
653 Self::Unknown => Self::Sink,
654 Self::Source => Self::Task,
655 Self::Sink => Self::Sink,
656 Self::Task => Self::Task,
657 Self::Bridge => Self::Bridge,
658 }
659 }
660
661 fn add_outgoing(self) -> NodeType {
662 match self {
663 Self::Unknown => Self::Source,
664 Self::Source => Self::Source,
665 Self::Sink => Self::Task,
666 Self::Task => Self::Task,
667 Self::Bridge => Self::Bridge,
668 }
669 }
670
671 fn color(self) -> Color {
672 match self {
673 Self::Unknown => Color::Gray,
674 Self::Source => Color::Rgb(255, 191, 0),
675 Self::Sink => Color::Rgb(255, 102, 204),
676 Self::Task => Color::White,
677 Self::Bridge => Color::Rgb(204, 153, 255),
678 }
679 }
680}
681
682#[derive(Default, Clone)]
683struct TaskStatus {
684 is_error: bool,
685 status_txt: CompactString,
686 error: CompactString,
687}
688
689#[derive(Clone)]
690struct DisplayNode {
691 id: String,
692 type_label: String,
693 node_type: NodeType,
694 inputs: Vec<String>,
695 outputs: Vec<String>,
696}
697
698#[derive(Clone, Copy, PartialEq, Eq)]
699struct GraphCacheKey {
700 area: Size,
701 node_count: usize,
702 connection_count: usize,
703}
704
705struct GraphCache {
706 graph: Option<NodeGraph<'static>>,
707 content_size: Size,
708 key: Option<GraphCacheKey>,
709 dirty: bool,
710}
711
712impl GraphCache {
713 fn new() -> Self {
714 Self {
715 graph: None,
716 content_size: Size::ZERO,
717 key: None,
718 dirty: true,
719 }
720 }
721}
722
723impl GraphCache {
724 fn needs_rebuild(&self, key: GraphCacheKey) -> bool {
725 self.dirty || self.graph.is_none() || self.key != Some(key)
726 }
727}
728
729struct NodesScrollableWidgetState {
730 display_nodes: Vec<DisplayNode>,
731 connections: Vec<Connection>,
732 statuses: Arc<Mutex<Vec<TaskStatus>>>,
733 status_index_map: Vec<Option<usize>>,
734 task_count: usize,
735 nodes_scrollable_state: ScrollViewState,
736 graph_cache: GraphCache,
737}
738
739impl NodesScrollableWidgetState {
740 fn new(
741 config: &CuConfig,
742 errors: Arc<Mutex<Vec<TaskStatus>>>,
743 mission: Option<&str>,
744 task_ids: &'static [&'static str],
745 topology: Option<MonitorTopology>,
746 ) -> Self {
747 let topology = topology
748 .or_else(|| cu29::monitoring::build_monitor_topology(config, mission).ok())
749 .unwrap_or_default();
750
751 let mut display_nodes: Vec<DisplayNode> = Vec::new();
752 let mut status_index_map = Vec::new();
753 let mut node_lookup = HashMap::new();
754
755 for node in topology.nodes.iter() {
756 let node_type = match node.kind {
757 ComponentKind::Bridge => NodeType::Bridge,
758 ComponentKind::Task => {
759 let mut role = NodeType::Unknown;
760 if !node.inputs.is_empty() {
761 role = role.add_incoming();
762 }
763 if !node.outputs.is_empty() {
764 role = role.add_outgoing();
765 }
766 role
767 }
768 };
769
770 display_nodes.push(DisplayNode {
771 id: node.id.clone(),
772 type_label: node
773 .type_name
774 .clone()
775 .unwrap_or_else(|| "unknown".to_string()),
776 node_type,
777 inputs: node.inputs.clone(),
778 outputs: node.outputs.clone(),
779 });
780 let idx = display_nodes.len() - 1;
781 node_lookup.insert(node.id.clone(), idx);
782
783 let status_idx = match node.kind {
784 ComponentKind::Task => task_ids.iter().position(|id| *id == node.id.as_str()),
785 ComponentKind::Bridge => None,
786 };
787 status_index_map.push(status_idx);
788 }
789
790 let mut connections: Vec<Connection> = Vec::with_capacity(topology.connections.len());
791 for cnx in topology.connections.iter() {
792 let Some(&src_idx) = node_lookup.get(&cnx.src) else {
793 continue;
794 };
795 let Some(&dst_idx) = node_lookup.get(&cnx.dst) else {
796 continue;
797 };
798 let src_node = &display_nodes[src_idx];
799 let dst_node = &display_nodes[dst_idx];
800 let src_port = cnx
801 .src_port
802 .as_ref()
803 .and_then(|p| src_node.outputs.iter().position(|name| name == p))
804 .unwrap_or(0);
805 let dst_port = cnx
806 .dst_port
807 .as_ref()
808 .and_then(|p| dst_node.inputs.iter().position(|name| name == p))
809 .unwrap_or(0);
810
811 connections.push(Connection::new(
812 src_idx,
813 src_port + NODE_PORT_ROW_OFFSET,
814 dst_idx,
815 dst_port + NODE_PORT_ROW_OFFSET,
816 ));
817 }
818
819 if !display_nodes.is_empty() {
822 let mut from_set = std::collections::HashSet::new();
823 for conn in &connections {
824 from_set.insert(conn.from_node);
825 }
826 if from_set.len() == display_nodes.len() {
827 let root_idx = 0;
828 connections.retain(|c| c.from_node != root_idx);
829 }
830 }
831
832 NodesScrollableWidgetState {
833 display_nodes,
834 connections,
835 statuses: errors,
836 status_index_map,
837 task_count: task_ids.len(),
838 nodes_scrollable_state: ScrollViewState::default(),
839 graph_cache: GraphCache::new(),
840 }
841 }
842
843 fn mark_graph_dirty(&mut self) {
844 self.graph_cache.dirty = true;
845 }
846
847 fn ensure_graph_cache(&mut self, area: Rect) -> Size {
848 let key = self.graph_cache_key(area);
849 if self.graph_cache.needs_rebuild(key) {
850 self.rebuild_graph_cache(area, key);
851 }
852 self.graph_cache.content_size
853 }
854
855 fn graph(&self) -> &NodeGraph<'static> {
856 self.graph_cache
857 .graph
858 .as_ref()
859 .expect("graph cache must be initialized before render")
860 }
861
862 fn graph_cache_key(&self, area: Rect) -> GraphCacheKey {
863 GraphCacheKey {
864 area: area.into(),
865 node_count: self.display_nodes.len(),
866 connection_count: self.connections.len(),
867 }
868 }
869
870 fn build_graph(&self, content_size: Size) -> NodeGraph<'static> {
871 let mut graph = NodeGraph::new(
872 self.build_node_layouts(),
873 self.connections.clone(),
874 content_size.width as usize,
875 content_size.height as usize,
876 );
877 graph.calculate();
878 graph
879 }
880
881 fn rebuild_graph_cache(&mut self, area: Rect, key: GraphCacheKey) {
882 let content_size = if self.display_nodes.is_empty() {
883 Size::new(area.width.max(NODE_WIDTH), area.height.max(NODE_HEIGHT))
884 } else {
885 let node_count = self.display_nodes.len();
886 let content_width = (node_count as u16)
887 .saturating_mul(NODE_WIDTH + 20)
888 .max(NODE_WIDTH);
889 let max_ports = self
890 .display_nodes
891 .iter()
892 .map(|node| node.inputs.len().max(node.outputs.len()))
893 .max()
894 .unwrap_or_default();
895 let content_height =
896 (((max_ports + NODE_PORT_ROW_OFFSET) as u16) * 12).max(NODE_HEIGHT * 6);
897
898 let initial_size = Size::new(content_width, content_height);
899 let graph = self.build_graph(initial_size);
900 let bounds = graph.content_bounds();
901 let desired_width = bounds
902 .width
903 .saturating_add(GRAPH_WIDTH_PADDING)
904 .max(NODE_WIDTH);
905 let desired_height = bounds
906 .height
907 .saturating_add(GRAPH_HEIGHT_PADDING)
908 .max(NODE_HEIGHT);
909 Size::new(desired_width, desired_height)
910 };
911
912 self.graph_cache.graph = Some(self.build_graph(content_size));
913 self.graph_cache.content_size = content_size;
914 self.graph_cache.key = Some(key);
915 self.graph_cache.dirty = false;
916
917 self.clamp_scroll_offset(area, content_size);
918 }
919
920 fn build_node_layouts(&self) -> Vec<NodeLayout<'static>> {
921 self.display_nodes
922 .iter()
923 .map(|node| {
924 let ports = node.inputs.len().max(node.outputs.len());
925 let content_rows = ports + NODE_PORT_ROW_OFFSET;
926 let height = (content_rows as u16).saturating_add(2).max(NODE_HEIGHT);
927 let title_line = Line::from(vec![
928 Span::styled(
929 format!(" {}", node.node_type),
930 Style::default().fg(node.node_type.color()),
931 ),
932 Span::styled(format!(" {} ", node.id), Style::default().fg(Color::White)),
933 ]);
934 NodeLayout::new((NODE_WIDTH, height)).with_title_line(title_line)
935 })
936 .collect()
937 }
938
939 fn clamp_scroll_offset(&mut self, area: Rect, content_size: Size) {
940 let max_x = content_size
941 .width
942 .saturating_sub(area.width.saturating_sub(1));
943 let max_y = content_size
944 .height
945 .saturating_sub(area.height.saturating_sub(1));
946 let offset = self.nodes_scrollable_state.offset();
947 let clamped = Position::new(offset.x.min(max_x), offset.y.min(max_y));
948 self.nodes_scrollable_state.set_offset(clamped);
949 }
950}
951
952struct NodesScrollableWidget<'a> {
953 _marker: PhantomData<&'a ()>,
954}
955
956const NODE_WIDTH: u16 = 29;
957const NODE_WIDTH_CONTENT: u16 = NODE_WIDTH - 2;
958
959const NODE_HEIGHT: u16 = 5;
960const NODE_META_LINES: usize = 2;
961const NODE_PORT_ROW_OFFSET: usize = NODE_META_LINES;
962const MAX_CULIST_MAP: usize = 512;
963
964fn clip_tail(value: &str, max_chars: usize) -> String {
965 if max_chars == 0 {
966 return String::new();
967 }
968 let char_count = value.chars().count();
969 if char_count <= max_chars {
970 return value.to_string();
971 }
972 let skip = char_count.saturating_sub(max_chars);
973 let start = value
974 .char_indices()
975 .nth(skip)
976 .map(|(idx, _)| idx)
977 .unwrap_or(value.len());
978 value[start..].to_string()
979}
980
981#[allow(dead_code)]
982const NODE_HEIGHT_CONTENT: u16 = NODE_HEIGHT - 2;
983const GRAPH_WIDTH_PADDING: u16 = NODE_WIDTH * 2;
984const GRAPH_HEIGHT_PADDING: u16 = NODE_HEIGHT * 4;
985
986impl StatefulWidget for NodesScrollableWidget<'_> {
987 type State = NodesScrollableWidgetState;
988
989 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
990 let content_size = state.ensure_graph_cache(area);
991 let mut scroll_view = ScrollView::new(content_size);
992
993 {
994 let graph = state.graph();
995 let zones = graph.split(scroll_view.area());
996
997 let mut statuses = state.statuses.lock().unwrap();
998 if statuses.len() <= state.task_count {
999 statuses.resize(state.task_count + 1, TaskStatus::default());
1000 }
1001 for (idx, ea_zone) in zones.into_iter().enumerate() {
1002 let fallback_idx = state.task_count;
1003 let status_idx = state
1004 .status_index_map
1005 .get(idx)
1006 .and_then(|opt| *opt)
1007 .unwrap_or(fallback_idx);
1008 let safe_index = status_idx.min(statuses.len().saturating_sub(1));
1009 let status = &mut statuses[safe_index];
1010 let node = &state.display_nodes[idx];
1011 let status_line = if status.is_error {
1012 format!("❌ {}", status.error)
1013 } else {
1014 format!("✓ {}", status.status_txt)
1015 };
1016
1017 let label_width = (NODE_WIDTH_CONTENT as usize).saturating_sub(2);
1018 let type_label = clip_tail(&node.type_label, label_width);
1019 let status_text = clip_tail(&status_line, label_width);
1020 let base_style = if status.is_error {
1021 Style::default().fg(Color::Red)
1022 } else {
1023 Style::default().fg(Color::Green)
1024 };
1025 let mut lines: Vec<Line> = Vec::new();
1026 lines.push(Line::styled(format!(" {}", type_label), base_style));
1027 lines.push(Line::styled(format!(" {}", status_text), base_style));
1028
1029 let max_ports = node.inputs.len().max(node.outputs.len());
1030 if max_ports > 0 {
1031 let left_width = (NODE_WIDTH_CONTENT as usize - 2) / 2;
1032 let right_width = NODE_WIDTH_CONTENT as usize - 2 - left_width;
1033 let input_style = Style::default().fg(Color::Yellow);
1034 let output_style = Style::default().fg(Color::Cyan);
1035 let dotted_style = Style::default().fg(Color::DarkGray);
1036 for port_idx in 0..max_ports {
1037 let input = node
1038 .inputs
1039 .get(port_idx)
1040 .map(|label| clip_tail(label, left_width))
1041 .unwrap_or_default();
1042 let output = node
1043 .outputs
1044 .get(port_idx)
1045 .map(|label| clip_tail(label, right_width))
1046 .unwrap_or_default();
1047 let mut port_line = Line::default();
1048 port_line.spans.push(Span::styled(
1049 format!(" {:<left_width$}", input, left_width = left_width),
1050 input_style,
1051 ));
1052 port_line.spans.push(Span::styled("┆", dotted_style));
1053 port_line.spans.push(Span::styled(
1054 format!("{:>right_width$}", output, right_width = right_width),
1055 output_style,
1056 ));
1057 lines.push(port_line);
1058 }
1059 }
1060
1061 let paragraph = Paragraph::new(Text::from(lines));
1062 status.is_error = false; scroll_view.render_widget(paragraph, ea_zone);
1064 }
1065
1066 let content_area = Rect::new(0, 0, content_size.width, content_size.height);
1067 scroll_view.render_widget(graph, content_area);
1068 }
1069
1070 scroll_view.render(area, buf, &mut state.nodes_scrollable_state);
1071 }
1072}
1073
1074pub struct CuConsoleMon {
1076 config: CuConfig,
1077 taskids: &'static [&'static str],
1078 task_stats: Arc<Mutex<TaskStats>>,
1079 task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1080 culist_to_task: [usize; MAX_CULIST_MAP],
1081 ui_handle: Option<JoinHandle<()>>,
1082 pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1083 copperlist_stats: Arc<Mutex<CopperListStats>>,
1084 quitting: Arc<AtomicBool>,
1085 topology: Option<MonitorTopology>,
1086}
1087
1088impl Drop for CuConsoleMon {
1089 fn drop(&mut self) {
1090 self.quitting.store(true, Ordering::SeqCst);
1091 let _ = restore_terminal();
1092 if let Some(handle) = self.ui_handle.take() {
1093 let _ = handle.join();
1094 }
1095 }
1096}
1097
1098struct UI {
1099 task_ids: &'static [&'static str],
1100 active_screen: Screen,
1101 sysinfo: String,
1102 task_stats: Arc<Mutex<TaskStats>>,
1103 quitting: Arc<AtomicBool>,
1104 tab_hitboxes: Vec<TabHitbox>,
1105 help_hitboxes: Vec<HelpHitbox>,
1106 nodes_scrollable_widget_state: NodesScrollableWidgetState,
1107 #[cfg(feature = "debug_pane")]
1108 error_redirect: Option<gag::BufferRedirect>,
1109 #[cfg(feature = "debug_pane")]
1110 debug_output: Option<debug_pane::DebugLog>,
1111 #[cfg(feature = "debug_pane")]
1112 debug_output_area: Option<Rect>,
1113 #[cfg(feature = "debug_pane")]
1114 debug_output_visible_offset: usize,
1115 #[cfg(feature = "debug_pane")]
1116 debug_output_lines: Vec<debug_pane::StyledLine>,
1117 #[cfg(feature = "debug_pane")]
1118 debug_selection: DebugSelection,
1119 #[cfg(feature = "debug_pane")]
1120 clipboard: Option<Clipboard>,
1121 pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1122 copperlist_stats: Arc<Mutex<CopperListStats>>,
1123}
1124
1125impl UI {
1126 #[cfg(feature = "debug_pane")]
1127 #[allow(clippy::too_many_arguments)]
1128 fn new(
1129 config: CuConfig,
1130 mission: Option<&str>,
1131 task_ids: &'static [&'static str],
1132 task_stats: Arc<Mutex<TaskStats>>,
1133 task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1134 quitting: Arc<AtomicBool>,
1135 error_redirect: Option<gag::BufferRedirect>,
1136 debug_output: Option<debug_pane::DebugLog>,
1137 pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1138 copperlist_stats: Arc<Mutex<CopperListStats>>,
1139 topology: Option<MonitorTopology>,
1140 ) -> UI {
1141 init_error_hooks();
1142 let nodes_scrollable_widget_state = NodesScrollableWidgetState::new(
1143 &config,
1144 task_statuses.clone(),
1145 mission,
1146 task_ids,
1147 topology.clone(),
1148 );
1149
1150 Self {
1151 task_ids,
1152 active_screen: Screen::Neofetch,
1153 sysinfo: sysinfo::pfetch_info(),
1154 task_stats,
1155 quitting,
1156 tab_hitboxes: Vec::new(),
1157 help_hitboxes: Vec::new(),
1158 nodes_scrollable_widget_state,
1159 error_redirect,
1160 debug_output,
1161 debug_output_area: None,
1162 debug_output_visible_offset: 0,
1163 debug_output_lines: Vec::new(),
1164 debug_selection: DebugSelection::default(),
1165 clipboard: None,
1166 pool_stats,
1167 copperlist_stats,
1168 }
1169 }
1170
1171 #[cfg(not(feature = "debug_pane"))]
1172 fn new(
1173 config: CuConfig,
1174 task_ids: &'static [&'static str],
1175 task_stats: Arc<Mutex<TaskStats>>,
1176 task_statuses: Arc<Mutex<Vec<TaskStatus>>>,
1177 quitting: Arc<AtomicBool>,
1178 pool_stats: Arc<Mutex<Vec<PoolStats>>>,
1179 copperlist_stats: Arc<Mutex<CopperListStats>>,
1180 topology: Option<MonitorTopology>,
1181 ) -> UI {
1182 init_error_hooks();
1183 let nodes_scrollable_widget_state = NodesScrollableWidgetState::new(
1184 &config,
1185 task_statuses.clone(),
1186 None,
1187 task_ids,
1188 topology.clone(),
1189 );
1190
1191 Self {
1192 task_ids,
1193 active_screen: Screen::Neofetch,
1194 sysinfo: sysinfo::pfetch_info(),
1195 task_stats,
1196 quitting,
1197 tab_hitboxes: Vec::new(),
1198 help_hitboxes: Vec::new(),
1199 nodes_scrollable_widget_state,
1200 pool_stats,
1201 copperlist_stats,
1202 }
1203 }
1204
1205 fn draw_latency_table(&self, f: &mut Frame, area: Rect) {
1206 let header_cells = [
1207 "🛠 Task",
1208 "⬇ Min",
1209 "⬆ Max",
1210 "∅ Mean",
1211 "σ Stddev",
1212 "⧖∅ Jitter",
1213 "⧗⬆ Jitter",
1214 ]
1215 .iter()
1216 .map(|h| {
1217 Cell::from(Line::from(*h).alignment(Alignment::Right)).style(
1218 Style::default()
1219 .fg(Color::Yellow)
1220 .add_modifier(Modifier::BOLD),
1221 )
1222 });
1223
1224 let header = Row::new(header_cells)
1225 .style(Style::default().fg(Color::Yellow))
1226 .bottom_margin(1)
1227 .top_margin(1);
1228
1229 let task_stats = self.task_stats.lock().unwrap(); let mut rows = task_stats
1231 .stats
1232 .iter()
1233 .enumerate()
1234 .map(|(i, stat)| {
1235 let cells = vec![
1236 Cell::from(Line::from(self.task_ids[i]).alignment(Alignment::Right))
1237 .light_blue(),
1238 Cell::from(Line::from(stat.min().to_string()).alignment(Alignment::Right))
1239 .style(Style::default()),
1240 Cell::from(Line::from(stat.max().to_string()).alignment(Alignment::Right))
1241 .style(Style::default()),
1242 Cell::from(Line::from(stat.mean().to_string()).alignment(Alignment::Right))
1243 .style(Style::default()),
1244 Cell::from(Line::from(stat.stddev().to_string()).alignment(Alignment::Right))
1245 .style(Style::default()),
1246 Cell::from(
1247 Line::from(stat.jitter_mean().to_string()).alignment(Alignment::Right),
1248 )
1249 .style(Style::default()),
1250 Cell::from(
1251 Line::from(stat.jitter_max().to_string()).alignment(Alignment::Right),
1252 )
1253 .style(Style::default()),
1254 ];
1255 Row::new(cells)
1256 })
1257 .collect::<Vec<Row>>();
1258
1259 let cells = vec![
1260 Cell::from(
1261 Line::from("End2End")
1262 .light_red()
1263 .alignment(Alignment::Right),
1264 ),
1265 Cell::from(
1266 Line::from(task_stats.end2end.min().to_string())
1267 .light_red()
1268 .alignment(Alignment::Right),
1269 )
1270 .style(Style::default()),
1271 Cell::from(
1272 Line::from(task_stats.end2end.max().to_string())
1273 .light_red()
1274 .alignment(Alignment::Right),
1275 )
1276 .style(Style::default()),
1277 Cell::from(
1278 Line::from(task_stats.end2end.mean().to_string())
1279 .light_red()
1280 .alignment(Alignment::Right),
1281 )
1282 .style(Style::default()),
1283 Cell::from(
1284 Line::from(task_stats.end2end.stddev().to_string())
1285 .light_red()
1286 .alignment(Alignment::Right),
1287 )
1288 .style(Style::default()),
1289 Cell::from(
1290 Line::from(task_stats.end2end.jitter_mean().to_string())
1291 .light_red()
1292 .alignment(Alignment::Right),
1293 )
1294 .style(Style::default()),
1295 Cell::from(
1296 Line::from(task_stats.end2end.jitter_max().to_string())
1297 .light_red()
1298 .alignment(Alignment::Right),
1299 )
1300 .style(Style::default()),
1301 ];
1302 rows.push(Row::new(cells).top_margin(1));
1303
1304 let table = Table::new(
1305 rows,
1306 &[
1307 Constraint::Length(10),
1308 Constraint::Length(10),
1309 Constraint::Length(12),
1310 Constraint::Length(12),
1311 Constraint::Length(10),
1312 Constraint::Length(12),
1313 Constraint::Length(13),
1314 ],
1315 )
1316 .header(header)
1317 .block(
1318 Block::default()
1319 .borders(Borders::ALL)
1320 .border_type(BorderType::Rounded)
1321 .title(" Latencies "),
1322 );
1323
1324 f.render_widget(table, area);
1325 }
1326
1327 fn draw_memory_pools(&self, f: &mut Frame, area: Rect) {
1328 let header_cells = [
1329 "Pool ID",
1330 "Used/Total",
1331 "Buffer Size",
1332 "Handles in Use",
1333 "Handles/sec",
1334 ]
1335 .iter()
1336 .map(|h| {
1337 Cell::from(Line::from(*h).alignment(Alignment::Right)).style(
1338 Style::default()
1339 .fg(Color::Yellow)
1340 .add_modifier(Modifier::BOLD),
1341 )
1342 });
1343
1344 let header = Row::new(header_cells)
1345 .style(Style::default().fg(Color::Yellow))
1346 .bottom_margin(1);
1347
1348 let pool_stats = self.pool_stats.lock().unwrap();
1349 let rows = pool_stats
1350 .iter()
1351 .map(|stat| {
1352 let used = stat.total_size - stat.space_left;
1353 let percent = if stat.total_size > 0 {
1354 100.0 * used as f64 / stat.total_size as f64
1355 } else {
1356 0.0
1357 };
1358 let buffer_size = stat.buffer_size;
1359 let mb_unit = 1024.0 * 1024.0;
1360
1361 let cells = vec![
1362 Cell::from(Line::from(stat.id.to_string()).alignment(Alignment::Right))
1363 .light_blue(),
1364 Cell::from(
1365 Line::from(format!(
1366 "{:.2} MB / {:.2} MB ({:.1}%)",
1367 used as f64 * buffer_size as f64 / mb_unit,
1368 stat.total_size as f64 * buffer_size as f64 / mb_unit,
1369 percent
1370 ))
1371 .alignment(Alignment::Right),
1372 ),
1373 Cell::from(
1374 Line::from(format!("{} KB", stat.buffer_size / 1024))
1375 .alignment(Alignment::Right),
1376 ),
1377 Cell::from(
1378 Line::from(format!("{}", stat.handles_in_use)).alignment(Alignment::Right),
1379 ),
1380 Cell::from(
1381 Line::from(format!("{}/s", stat.handles_per_second))
1382 .alignment(Alignment::Right),
1383 ),
1384 ];
1385 Row::new(cells)
1386 })
1387 .collect::<Vec<Row>>();
1388
1389 let table = Table::new(
1390 rows,
1391 &[
1392 Constraint::Percentage(30),
1393 Constraint::Percentage(20),
1394 Constraint::Percentage(15),
1395 Constraint::Percentage(15),
1396 Constraint::Percentage(20),
1397 ],
1398 )
1399 .header(header)
1400 .block(
1401 Block::default()
1402 .borders(Borders::ALL)
1403 .border_type(BorderType::Rounded)
1404 .title(" Memory Pools "),
1405 );
1406
1407 f.render_widget(table, area);
1408 }
1409
1410 fn draw_copperlist_stats(&self, f: &mut Frame, area: Rect) {
1411 let stats = self.copperlist_stats.lock().unwrap();
1412 let size_display = format_bytes_or(stats.size_bytes as u64, "unknown");
1413 let raw_total = stats.raw_culist_bytes.max(stats.size_bytes as u64);
1414 let handles_display = format_bytes_or(stats.handle_bytes, "0 B");
1415 let mem_total = raw_total
1416 .saturating_add(stats.keyframe_bytes)
1417 .saturating_add(stats.structured_bytes_per_cl);
1418 let mem_total_display = format_bytes_or(mem_total, "unknown");
1419 let encoded_display = format_bytes_or(stats.encoded_bytes, "n/a");
1420 let efficiency_display = if raw_total > 0 && stats.encoded_bytes > 0 {
1421 let ratio = (stats.encoded_bytes as f64) / (raw_total as f64);
1422 format!("{:.1}%", ratio * 100.0)
1423 } else {
1424 "n/a".to_string()
1425 };
1426 let rate_display = format!("{:.2} Hz", stats.rate_hz);
1427 let raw_bw = format_rate_bytes_or_na(mem_total, stats.rate_hz);
1428 let keyframe_display = format_bytes_or(stats.keyframe_bytes, "0 B");
1429 let structured_display = format_bytes_or(stats.structured_bytes_per_cl, "0 B");
1430 let structured_bw = format_rate_bytes_or_na(stats.structured_bytes_per_cl, stats.rate_hz);
1431 let disk_total_bytes = stats
1432 .encoded_bytes
1433 .saturating_add(stats.keyframe_bytes)
1434 .saturating_add(stats.structured_bytes_per_cl);
1435 let disk_total_bw = format_rate_bytes_or_na(disk_total_bytes, stats.rate_hz);
1436
1437 let header_cells = ["Metric", "Value"].iter().map(|h| {
1438 Cell::from(Line::from(*h)).style(
1439 Style::default()
1440 .fg(Color::Yellow)
1441 .add_modifier(Modifier::BOLD),
1442 )
1443 });
1444
1445 let header = Row::new(header_cells).bottom_margin(1);
1446
1447 let row = |metric: &'static str, value: String| {
1448 Row::new(vec![
1449 Cell::from(Line::from(metric)),
1450 Cell::from(Line::from(value).alignment(Alignment::Right)),
1451 ])
1452 };
1453 let spacer = row(" ", " ".to_string());
1454
1455 let rate_style = Style::default().fg(Color::Cyan);
1456 let mem_rows = vec![
1457 row("Observed rate", rate_display).style(rate_style),
1458 spacer.clone(),
1459 row("CopperList size", size_display),
1460 row("Pool memory used", handles_display),
1461 row("Keyframe size", keyframe_display),
1462 row("Mem total (CL+KF+SL)", mem_total_display),
1463 spacer.clone(),
1464 row("RAM BW (raw)", raw_bw),
1465 ];
1466
1467 let disk_rows = vec![
1468 row("CL serialized size", encoded_display),
1469 row("CL encoding efficiency", efficiency_display),
1470 row("Structured log / CL", structured_display),
1471 row("Structured BW", structured_bw),
1472 spacer.clone(),
1473 row("Total disk BW", disk_total_bw),
1474 ];
1475
1476 let mem_table = Table::new(mem_rows, &[Constraint::Length(24), Constraint::Length(12)])
1477 .header(header.clone())
1478 .block(
1479 Block::default()
1480 .borders(Borders::ALL)
1481 .border_type(BorderType::Rounded)
1482 .title(" Memory BW "),
1483 );
1484
1485 let disk_table = Table::new(disk_rows, &[Constraint::Length(24), Constraint::Length(12)])
1486 .header(header)
1487 .block(
1488 Block::default()
1489 .borders(Borders::ALL)
1490 .border_type(BorderType::Rounded)
1491 .title(" Disk / Encoding "),
1492 );
1493
1494 let layout = Layout::default()
1495 .direction(Direction::Horizontal)
1496 .constraints([Constraint::Length(42), Constraint::Length(42)].as_ref())
1497 .split(area);
1498
1499 f.render_widget(mem_table, layout[0]);
1500 f.render_widget(disk_table, layout[1]);
1501 }
1502
1503 fn draw_nodes(&mut self, f: &mut Frame, space: Rect) {
1504 NodesScrollableWidget {
1505 _marker: Default::default(),
1506 }
1507 .render(
1508 space,
1509 f.buffer_mut(),
1510 &mut self.nodes_scrollable_widget_state,
1511 )
1512 }
1513
1514 fn render_tabs(&mut self, f: &mut Frame, area: Rect) {
1515 let base_bg = Color::Rgb(16, 18, 20);
1516 let active_bg = Color::Rgb(56, 110, 120);
1517 let inactive_bg = Color::Rgb(40, 44, 52);
1518 let active_fg = Color::Rgb(245, 246, 247);
1519 let inactive_fg = Color::Rgb(198, 200, 204);
1520 let key_fg = Color::Rgb(255, 208, 128);
1521
1522 let mut spans = Vec::new();
1523 self.tab_hitboxes.clear();
1524 let mut cursor_x = area.x;
1525 spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1526 cursor_x = cursor_x.saturating_add(1);
1527
1528 for tab in TAB_DEFS {
1529 let is_active = self.active_screen == tab.screen;
1530 let bg = if is_active { active_bg } else { inactive_bg };
1531 let fg = if is_active { active_fg } else { inactive_fg };
1532 let label_style = if is_active {
1533 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
1534 } else {
1535 Style::default().fg(fg).bg(bg)
1536 };
1537 let tab_width = segment_width(tab.key, tab.label);
1538 self.tab_hitboxes.push(TabHitbox {
1539 screen: tab.screen,
1540 x: cursor_x,
1541 y: area.y,
1542 width: tab_width,
1543 height: area.height,
1544 });
1545 cursor_x = cursor_x.saturating_add(tab_width);
1546
1547 spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1548 spans.push(Span::styled(" ", Style::default().bg(bg)));
1549 spans.push(Span::styled(
1550 tab.key,
1551 Style::default()
1552 .fg(key_fg)
1553 .bg(bg)
1554 .add_modifier(Modifier::BOLD),
1555 ));
1556 spans.push(Span::styled(" ", Style::default().bg(bg)));
1557 spans.push(Span::styled(tab.label, label_style));
1558 spans.push(Span::styled(" ", Style::default().bg(bg)));
1559 spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1560 spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1561 }
1562
1563 let tabs = Paragraph::new(Line::from(spans))
1564 .style(Style::default().bg(base_bg))
1565 .block(
1566 Block::default()
1567 .borders(Borders::BOTTOM)
1568 .style(Style::default().bg(base_bg)),
1569 );
1570 f.render_widget(tabs, area);
1571 }
1572
1573 fn render_help(&mut self, f: &mut Frame, area: Rect) {
1574 let base_bg = Color::Rgb(18, 16, 22);
1575 let key_fg = Color::Rgb(248, 231, 176);
1576 let text_fg = Color::Rgb(236, 236, 236);
1577
1578 let mut spans = Vec::new();
1579 self.help_hitboxes.clear();
1580 let mut cursor_x = area.x;
1581 spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1582 cursor_x = cursor_x.saturating_add(1);
1583
1584 let tab_hint = if cfg!(feature = "debug_pane") {
1585 "1-6"
1586 } else {
1587 "1-5"
1588 };
1589
1590 let segments = [
1591 (tab_hint, "Tabs", Color::Rgb(86, 114, 98)),
1592 ("r", "Reset latency", Color::Rgb(136, 92, 78)),
1593 ("hjkl/←↑→↓", "Scroll", Color::Rgb(92, 102, 150)),
1594 ("q", "Quit", Color::Rgb(124, 118, 76)),
1595 ];
1596
1597 for (key, label, bg) in segments {
1598 let segment_len = segment_width(key, label);
1599 let action = help_action(key);
1600 if let Some(action) = action {
1601 self.help_hitboxes.push(HelpHitbox {
1602 action,
1603 x: cursor_x,
1604 y: area.y,
1605 width: segment_len,
1606 height: area.height,
1607 });
1608 }
1609 cursor_x = cursor_x.saturating_add(segment_len);
1610
1611 spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1612 spans.push(Span::styled(" ", Style::default().bg(bg)));
1613 spans.push(Span::styled(
1614 key,
1615 Style::default()
1616 .fg(key_fg)
1617 .bg(bg)
1618 .add_modifier(Modifier::BOLD),
1619 ));
1620 spans.push(Span::styled(" ", Style::default().bg(bg)));
1621 spans.push(Span::styled(label, Style::default().fg(text_fg).bg(bg)));
1622 spans.push(Span::styled(" ", Style::default().bg(bg)));
1623 spans.push(Span::styled("", Style::default().fg(bg).bg(base_bg)));
1624 spans.push(Span::styled(" ", Style::default().bg(base_bg)));
1625 }
1626
1627 let help = Paragraph::new(Line::from(spans))
1628 .style(Style::default().bg(base_bg))
1629 .block(
1630 Block::default()
1631 .borders(Borders::TOP)
1632 .style(Style::default().bg(base_bg)),
1633 );
1634 f.render_widget(help, area);
1635 }
1636
1637 fn draw(&mut self, f: &mut Frame) {
1638 let layout = Layout::default()
1639 .direction(Direction::Vertical)
1640 .constraints(
1641 [
1642 Constraint::Length(2), Constraint::Min(0), Constraint::Length(2), ]
1646 .as_ref(),
1647 )
1648 .split(f.area());
1649
1650 self.render_tabs(f, layout[0]);
1651 self.render_help(f, layout[2]);
1652
1653 match self.active_screen {
1654 Screen::Neofetch => {
1655 const VERSION: &str = env!("CARGO_PKG_VERSION");
1656 let text: Text = format!("\n -> Copper v{}\n\n{}\n\n ", VERSION, self.sysinfo)
1657 .into_text()
1658 .unwrap();
1659 let p = Paragraph::new::<Text>(text).block(
1660 Block::default()
1661 .title(" System Info ")
1662 .borders(Borders::ALL)
1663 .border_type(BorderType::Rounded),
1664 );
1665 f.render_widget(p, layout[1]);
1666 }
1667 Screen::Dag => {
1668 self.draw_nodes(f, layout[1]);
1669 }
1670 Screen::Latency => self.draw_latency_table(f, layout[1]),
1671 Screen::MemoryPools => self.draw_memory_pools(f, layout[1]),
1672 Screen::CopperList => self.draw_copperlist_stats(f, layout[1]),
1673 #[cfg(feature = "debug_pane")]
1674 Screen::DebugOutput => self.draw_debug_output(f, layout[1]),
1675 };
1676 }
1677
1678 fn handle_tab_click(&mut self, mouse: event::MouseEvent) {
1679 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1680 return;
1681 }
1682
1683 for hitbox in &self.tab_hitboxes {
1684 if mouse_inside(&mouse, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
1685 self.active_screen = hitbox.screen;
1686 break;
1687 }
1688 }
1689 }
1690
1691 fn handle_help_click(&mut self, mouse: event::MouseEvent) -> bool {
1692 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1693 return false;
1694 }
1695
1696 for hitbox in &self.help_hitboxes {
1697 if !mouse_inside(&mouse, hitbox.x, hitbox.y, hitbox.width, hitbox.height) {
1698 continue;
1699 }
1700
1701 match hitbox.action {
1702 HelpAction::ResetLatency => {
1703 if self.active_screen == Screen::Latency {
1704 self.task_stats.lock().unwrap().reset();
1705 }
1706 }
1707 HelpAction::Quit => {
1708 self.quitting.store(true, Ordering::SeqCst);
1709 }
1710 }
1711 return true;
1712 }
1713
1714 false
1715 }
1716
1717 fn handle_scroll_mouse(&mut self, mouse: event::MouseEvent) {
1718 if self.active_screen != Screen::Dag {
1719 return;
1720 }
1721
1722 match mouse.kind {
1723 MouseEventKind::ScrollDown => {
1724 self.nodes_scrollable_widget_state
1725 .nodes_scrollable_state
1726 .scroll_down();
1727 }
1728 MouseEventKind::ScrollUp => {
1729 self.nodes_scrollable_widget_state
1730 .nodes_scrollable_state
1731 .scroll_up();
1732 }
1733 MouseEventKind::ScrollRight => {
1734 for _ in 0..5 {
1735 self.nodes_scrollable_widget_state
1736 .nodes_scrollable_state
1737 .scroll_right();
1738 }
1739 }
1740 MouseEventKind::ScrollLeft => {
1741 for _ in 0..5 {
1742 self.nodes_scrollable_widget_state
1743 .nodes_scrollable_state
1744 .scroll_left();
1745 }
1746 }
1747 _ => {}
1748 }
1749 }
1750
1751 #[cfg(feature = "debug_pane")]
1752 fn handle_mouse_event(&mut self, mouse: event::MouseEvent) {
1753 self.handle_tab_click(mouse);
1754 if self.handle_help_click(mouse) {
1755 return;
1756 }
1757 self.handle_scroll_mouse(mouse);
1758
1759 if self.active_screen != Screen::DebugOutput {
1760 return;
1761 }
1762
1763 let Some(area) = self.debug_output_area else {
1764 return;
1765 };
1766
1767 if !mouse_inside(&mouse, area.x, area.y, area.width, area.height) {
1768 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1769 self.debug_selection.clear();
1770 }
1771 return;
1772 }
1773
1774 let rel_row = (mouse.row - area.y) as usize;
1775 let rel_col = (mouse.column - area.x) as usize;
1776 let line_index = self.debug_output_visible_offset.saturating_add(rel_row);
1777 let Some(line) = self.debug_output_lines.get(line_index) else {
1778 return;
1779 };
1780 let line_len = line.text.chars().count();
1781 let point = SelectionPoint {
1782 row: line_index,
1783 col: rel_col.min(line_len),
1784 };
1785
1786 match mouse.kind {
1787 MouseEventKind::Down(MouseButton::Left) => {
1788 self.debug_selection.start(point);
1789 }
1790 MouseEventKind::Drag(MouseButton::Left) => {
1791 self.debug_selection.update(point);
1792 }
1793 MouseEventKind::Up(MouseButton::Left) => {
1794 self.debug_selection.update(point);
1795 if let Some(text) = self.selected_debug_text() {
1796 self.copy_debug_text(text);
1797 }
1798 }
1799 _ => {}
1800 }
1801 }
1802
1803 #[cfg(not(feature = "debug_pane"))]
1804 fn handle_mouse_event(&mut self, mouse: event::MouseEvent) {
1805 self.handle_tab_click(mouse);
1806 let _ = self.handle_help_click(mouse);
1807 self.handle_scroll_mouse(mouse);
1808 }
1809
1810 #[cfg(feature = "debug_pane")]
1811 fn selected_debug_text(&self) -> Option<String> {
1812 let (start, end) = self.debug_selection.range()?;
1813 if start == end {
1814 return None;
1815 }
1816 if self.debug_output_lines.is_empty() {
1817 return None;
1818 }
1819 if start.row >= self.debug_output_lines.len() || end.row >= self.debug_output_lines.len() {
1820 return None;
1821 }
1822
1823 let mut selected = Vec::new();
1824 for row in start.row..=end.row {
1825 let line = &self.debug_output_lines[row];
1826 let line_len = line.text.chars().count();
1827 let Some((start_col, end_col)) = line_selection_bounds(row, line_len, start, end)
1828 else {
1829 selected.push(String::new());
1830 continue;
1831 };
1832 let (_, selected_part, _) = slice_char_range(&line.text, start_col, end_col);
1833 selected.push(selected_part.to_string());
1834 }
1835 Some(selected.join("\n"))
1836 }
1837
1838 #[cfg(feature = "debug_pane")]
1839 fn copy_debug_text(&mut self, text: String) {
1840 if text.is_empty() {
1841 return;
1842 }
1843
1844 if self.clipboard.is_none() {
1845 match Clipboard::new() {
1846 Ok(clipboard) => self.clipboard = Some(clipboard),
1847 Err(err) => {
1848 eprintln!("CuConsoleMon clipboard init failed: {err}");
1849 return;
1850 }
1851 }
1852 }
1853
1854 if let Some(clipboard) = self.clipboard.as_mut()
1855 && let Err(err) = clipboard.set_text(text)
1856 {
1857 eprintln!("CuConsoleMon clipboard copy failed: {err}");
1858 }
1859 }
1860
1861 #[cfg(feature = "debug_pane")]
1862 fn build_debug_output_text(&self, area: Rect) -> Text<'_> {
1863 let mut rendered_lines = Vec::new();
1864 let selection = self
1865 .debug_selection
1866 .range()
1867 .filter(|(start, end)| start != end);
1868 let selection_style = Style::default().bg(Color::Blue).fg(Color::Black);
1869 let visible = self
1870 .debug_output_lines
1871 .iter()
1872 .skip(self.debug_output_visible_offset)
1873 .take(area.height as usize);
1874
1875 for (idx, line) in visible.enumerate() {
1876 let line_index = self.debug_output_visible_offset + idx;
1877 let spans = if let Some((start, end)) = selection {
1878 let line_len = line.text.chars().count();
1879 if let Some((start_col, end_col)) =
1880 line_selection_bounds(line_index, line_len, start, end)
1881 {
1882 let (before, selected, after) =
1883 slice_char_range(&line.text, start_col, end_col);
1884 let mut spans = Vec::new();
1885 if !before.is_empty() {
1886 spans.push(Span::raw(before.to_string()));
1887 }
1888 spans.push(Span::styled(selected.to_string(), selection_style));
1889 if !after.is_empty() {
1890 spans.push(Span::raw(after.to_string()));
1891 }
1892 spans
1893 } else {
1894 spans_from_runs(line)
1895 }
1896 } else {
1897 spans_from_runs(line)
1898 };
1899 rendered_lines.push(Line::from(spans));
1900 }
1901
1902 Text::from(rendered_lines)
1903 }
1904
1905 fn run_app<B: Backend<Error = io::Error>>(
1906 &mut self,
1907 terminal: &mut Terminal<B>,
1908 ) -> io::Result<()> {
1909 loop {
1910 if self.quitting.load(Ordering::SeqCst) {
1911 break;
1912 }
1913 #[cfg(feature = "debug_pane")]
1914 self.update_debug_output();
1915
1916 terminal.draw(|f| {
1917 self.draw(f);
1918 })?;
1919
1920 if event::poll(Duration::from_millis(50))? {
1921 let event = event::read()?;
1922
1923 match event {
1924 Event::Key(key) => match key.code {
1925 KeyCode::Char('1') => self.active_screen = Screen::Neofetch,
1926 KeyCode::Char('2') => self.active_screen = Screen::Dag,
1927 KeyCode::Char('3') => self.active_screen = Screen::Latency,
1928 KeyCode::Char('4') => self.active_screen = Screen::CopperList,
1929 KeyCode::Char('5') => self.active_screen = Screen::MemoryPools,
1930 #[cfg(feature = "debug_pane")]
1931 KeyCode::Char('6') => self.active_screen = Screen::DebugOutput,
1932 KeyCode::Char('r') => {
1933 if self.active_screen == Screen::Latency {
1934 self.task_stats.lock().unwrap().reset()
1935 }
1936 }
1937 KeyCode::Char('j') | KeyCode::Down => {
1938 if self.active_screen == Screen::Dag {
1939 for _ in 0..1 {
1940 self.nodes_scrollable_widget_state
1941 .nodes_scrollable_state
1942 .scroll_down();
1943 }
1944 }
1945 }
1946 KeyCode::Char('k') | KeyCode::Up => {
1947 if self.active_screen == Screen::Dag {
1948 for _ in 0..1 {
1949 self.nodes_scrollable_widget_state
1950 .nodes_scrollable_state
1951 .scroll_up();
1952 }
1953 }
1954 }
1955 KeyCode::Char('h') | KeyCode::Left => {
1956 if self.active_screen == Screen::Dag {
1957 for _ in 0..5 {
1958 self.nodes_scrollable_widget_state
1959 .nodes_scrollable_state
1960 .scroll_left();
1961 }
1962 }
1963 }
1964 KeyCode::Char('l') | KeyCode::Right => {
1965 if self.active_screen == Screen::Dag {
1966 for _ in 0..5 {
1967 self.nodes_scrollable_widget_state
1968 .nodes_scrollable_state
1969 .scroll_right();
1970 }
1971 }
1972 }
1973 KeyCode::Char('q') => {
1974 self.quitting.store(true, Ordering::SeqCst);
1975 break;
1976 }
1977 _ => {}
1978 },
1979
1980 Event::Mouse(mouse) => {
1981 self.handle_mouse_event(mouse);
1982 }
1983 Event::Resize(_columns, rows) => {
1984 self.nodes_scrollable_widget_state.mark_graph_dirty();
1985 #[cfg(not(feature = "debug_pane"))]
1986 let _ = rows;
1987 #[cfg(feature = "debug_pane")]
1988 if let Some(debug_output) = self.debug_output.as_mut() {
1989 debug_output.max_rows.store(rows, Ordering::SeqCst)
1990 }
1991 }
1992 _ => {}
1993 }
1994 }
1995 }
1996 Ok(())
1997 }
1998}
1999
2000impl CuMonitor for CuConsoleMon {
2001 fn new(config: &CuConfig, taskids: &'static [&'static str]) -> CuResult<Self>
2002 where
2003 Self: Sized,
2004 {
2005 let mut culist_to_task = [usize::MAX; MAX_CULIST_MAP];
2006 if let Ok(map) = build_culist_to_task_index(config, taskids) {
2007 culist_to_task = map;
2008 }
2009 let task_stats = Arc::new(Mutex::new(TaskStats::new(
2010 taskids.len(),
2011 CuDuration::from(Duration::from_secs(5)),
2012 )));
2013
2014 Ok(Self {
2015 config: config.clone(),
2016 taskids,
2017 task_stats,
2018 task_statuses: Arc::new(Mutex::new(vec![TaskStatus::default(); taskids.len()])),
2019 culist_to_task,
2020 ui_handle: None,
2021 quitting: Arc::new(AtomicBool::new(false)),
2022 pool_stats: Arc::new(Mutex::new(Vec::new())),
2023 copperlist_stats: Arc::new(Mutex::new(CopperListStats::new())),
2024 topology: None,
2025 })
2026 }
2027 fn set_topology(&mut self, topology: MonitorTopology) {
2028 self.topology = Some(topology);
2029 }
2030
2031 fn set_copperlist_info(&mut self, info: CopperListInfo) {
2032 let mut stats = self.copperlist_stats.lock().unwrap();
2033 stats.set_info(info);
2034 }
2035
2036 fn observe_copperlist_io(&self, stats: CopperListIoStats) {
2037 let mut cl_stats = self.copperlist_stats.lock().unwrap();
2038 cl_stats.update_io(stats);
2039 }
2040
2041 fn start(&mut self, _clock: &RobotClock) -> CuResult<()> {
2042 if !should_start_ui() {
2043 #[cfg(debug_assertions)]
2044 {
2045 register_live_log_listener(|entry, format_str, param_names| {
2046 if let Some(line) = format_headless_log_line(entry, format_str, param_names) {
2047 println!("{line}");
2048 }
2049 });
2050 }
2051 return Ok(());
2052 }
2053
2054 let config_dup = self.config.clone();
2055 let taskids = self.taskids;
2056
2057 let task_stats_ui = self.task_stats.clone();
2058 let error_states = self.task_statuses.clone();
2059 let pool_stats_ui = self.pool_stats.clone();
2060 let copperlist_stats_ui = self.copperlist_stats.clone();
2061 let quitting = self.quitting.clone();
2062 let topology = self.topology.clone();
2063
2064 let handle = thread::spawn(move || {
2066 let backend = CrosstermBackend::new(stdout());
2067 let _terminal_guard = TerminalRestoreGuard;
2068
2069 if let Err(err) = setup_terminal() {
2070 eprintln!("Failed to prepare terminal UI: {err}");
2071 return;
2072 }
2073
2074 let mut terminal = match Terminal::new(backend) {
2075 Ok(terminal) => terminal,
2076 Err(err) => {
2077 eprintln!("Failed to initialize terminal backend: {err}");
2078 return;
2079 }
2080 };
2081
2082 #[cfg(feature = "debug_pane")]
2083 {
2084 let error_redirect = match gag::BufferRedirect::stderr() {
2086 Ok(redirect) => Some(redirect),
2087 Err(err) => {
2088 eprintln!(
2089 "Failed to redirect stderr for debug pane; continuing without redirect: {err}"
2090 );
2091 None
2092 }
2093 };
2094
2095 let mut ui = UI::new(
2096 config_dup,
2097 None, taskids,
2099 task_stats_ui,
2100 error_states,
2101 quitting.clone(),
2102 error_redirect,
2103 None,
2104 pool_stats_ui,
2105 copperlist_stats_ui,
2106 topology.clone(),
2107 );
2108
2109 #[cfg(debug_assertions)]
2110 {
2111 let max_lines = terminal.size().unwrap().height - 5;
2112 let (mut debug_log, tx) = debug_pane::DebugLog::new(max_lines);
2113
2114 cu29_log_runtime::register_live_log_listener(
2115 move |entry, format_str, param_names| {
2116 let params: Vec<String> =
2118 entry.params.iter().map(|v| v.to_string()).collect();
2119 let named_params: HashMap<String, String> = param_names
2120 .iter()
2121 .zip(params.iter())
2122 .map(|(name, value)| (name.to_string(), value.clone()))
2123 .collect();
2124 let line = styled_line_from_structured(
2125 entry.time,
2126 entry.level,
2127 format_str,
2128 params.as_slice(),
2129 &named_params,
2130 );
2131 let _ = tx.try_send(line);
2133 },
2134 );
2135
2136 debug_log.update_logs();
2138 ui.debug_output = Some(debug_log);
2139 }
2140 if let Err(err) = ui.run_app(&mut terminal) {
2141 let _ = restore_terminal();
2142 eprintln!("CuConsoleMon UI exited with error: {err}");
2143 cu29_log_runtime::unregister_live_log_listener();
2144 return;
2145 }
2146 cu29_log_runtime::unregister_live_log_listener();
2147 }
2148
2149 #[cfg(not(feature = "debug_pane"))]
2150 {
2151 let stderr_gag = gag::Gag::stderr().unwrap();
2152
2153 let mut ui = UI::new(
2154 config_dup,
2155 taskids,
2156 task_stats_ui,
2157 error_states,
2158 quitting.clone(),
2159 pool_stats_ui,
2160 copperlist_stats_ui,
2161 topology,
2162 );
2163 if let Err(err) = ui.run_app(&mut terminal) {
2164 let _ = restore_terminal();
2165 eprintln!("CuConsoleMon UI exited with error: {err}");
2166 return;
2167 }
2168
2169 drop(stderr_gag);
2170 }
2171
2172 quitting.store(true, Ordering::SeqCst);
2173 let _ = restore_terminal();
2175 });
2176
2177 self.ui_handle = Some(handle);
2178 Ok(())
2179 }
2180
2181 fn process_copperlist(&self, msgs: &[&CuMsgMetadata]) -> CuResult<()> {
2182 {
2183 let mut task_stats = self.task_stats.lock().unwrap();
2184 task_stats.update(msgs);
2185 }
2186 {
2187 let mut copperlist_stats = self.copperlist_stats.lock().unwrap();
2188 copperlist_stats.update_rate();
2189 }
2190 {
2191 let mut task_statuses = self.task_statuses.lock().unwrap();
2192 for (i, msg) in msgs.iter().enumerate() {
2193 let CuCompactString(status_txt) = &msg.status_txt;
2194 if let Some(&task_idx) = self.culist_to_task.get(i)
2195 && task_idx != usize::MAX
2196 && task_idx < task_statuses.len()
2197 {
2198 task_statuses[task_idx].status_txt = status_txt.clone();
2199 }
2200 }
2201 }
2202
2203 {
2205 let pool_stats_data = pool::pools_statistics();
2206 let mut pool_stats = self.pool_stats.lock().unwrap();
2207
2208 for (id, space_left, total_size, buffer_size) in pool_stats_data {
2210 let id_str = id.to_string();
2211 if let Some(existing) = pool_stats.iter_mut().find(|p| p.id == id_str) {
2212 existing.update(space_left, total_size);
2213 } else {
2214 pool_stats.push(PoolStats::new(id_str, space_left, total_size, buffer_size));
2215 }
2216 }
2217 }
2218
2219 if self.quitting.load(Ordering::SeqCst) {
2220 return Err("Exiting...".into());
2221 }
2222 Ok(())
2223 }
2224
2225 fn process_error(&self, taskid: usize, step: CuTaskState, error: &CuError) -> Decision {
2226 {
2227 let status = &mut self.task_statuses.lock().unwrap()[taskid];
2228 status.is_error = true;
2229 status.error = error.to_compact_string();
2230 }
2231 match step {
2232 CuTaskState::Start => Decision::Shutdown,
2233 CuTaskState::Preprocess => Decision::Abort,
2234 CuTaskState::Process => Decision::Ignore,
2235 CuTaskState::Postprocess => Decision::Ignore,
2236 CuTaskState::Stop => Decision::Shutdown,
2237 }
2238 }
2239
2240 fn stop(&mut self, _clock: &RobotClock) -> CuResult<()> {
2241 self.quitting.store(true, Ordering::SeqCst);
2242 let _ = restore_terminal();
2243
2244 if let Some(handle) = self.ui_handle.take() {
2245 let _ = handle.join();
2246 }
2247
2248 #[cfg(debug_assertions)]
2249 if !should_start_ui() {
2250 unregister_live_log_listener();
2251 }
2252
2253 self.task_stats
2254 .lock()
2255 .unwrap()
2256 .stats
2257 .iter_mut()
2258 .for_each(|s| s.reset());
2259 Ok(())
2260 }
2261}
2262
2263struct TerminalRestoreGuard;
2264
2265impl Drop for TerminalRestoreGuard {
2266 fn drop(&mut self) {
2267 let _ = restore_terminal();
2268 }
2269}
2270
2271fn build_culist_to_task_index(
2272 config: &CuConfig,
2273 task_ids: &'static [&'static str],
2274) -> CuResult<[usize; MAX_CULIST_MAP]> {
2275 let graph = config.get_graph(None)?;
2276 let plan = compute_runtime_plan(graph)?;
2277
2278 let mut mapping = [usize::MAX; MAX_CULIST_MAP];
2279
2280 for unit in &plan.steps {
2281 if let CuExecutionUnit::Step(step) = unit
2282 && let Some(output_pack) = &step.output_msg_pack
2283 {
2284 if step.node.get_flavor() != Flavor::Task {
2285 continue;
2286 }
2287 let node_id = step.node.get_id();
2288 let culist_idx = output_pack.culist_index as usize;
2289 if culist_idx >= MAX_CULIST_MAP {
2290 continue;
2291 }
2292 if let Some(task_idx) = task_ids.iter().position(|id| *id == node_id.as_str()) {
2293 mapping[culist_idx] = task_idx;
2294 }
2295 }
2296 }
2297
2298 Ok(mapping)
2299}
2300
2301fn init_error_hooks() {
2302 static ONCE: OnceLock<()> = OnceLock::new();
2303 if ONCE.get().is_some() {
2304 return;
2305 }
2306
2307 let (_panic_hook, error) = HookBuilder::default().into_hooks();
2308 let error = error.into_eyre_hook();
2309 color_eyre::eyre::set_hook(Box::new(move |e| {
2310 let _ = restore_terminal();
2311 error(e)
2312 }))
2313 .unwrap();
2314 std::panic::set_hook(Box::new(move |info| {
2315 let _ = restore_terminal();
2316 let bt = Backtrace::force_capture();
2317 println!("CuConsoleMon panic: {info}");
2319 println!("Backtrace:\n{bt}");
2320 let _ = stdout().flush();
2321 process::exit(1);
2323 }));
2324
2325 let _ = ONCE.set(());
2326}
2327
2328fn setup_terminal() -> io::Result<()> {
2329 enable_raw_mode()?;
2330 execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
2332 Ok(())
2333}
2334
2335fn restore_terminal() -> io::Result<()> {
2336 execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
2337 disable_raw_mode()
2338}
2339
2340#[cfg(debug_assertions)]
2341fn format_timestamp(time: CuDuration) -> String {
2342 let nanos = time.as_nanos();
2344 let total_seconds = nanos / 1_000_000_000;
2345 let hours = total_seconds / 3600;
2346 let minutes = (total_seconds / 60) % 60;
2347 let seconds = total_seconds % 60;
2348 let fractional_1e4 = (nanos % 1_000_000_000) / 100_000;
2349 format!("{hours:02}:{minutes:02}:{seconds:02}.{fractional_1e4:04}")
2350}
2351
2352#[cfg(debug_assertions)]
2353fn format_headless_log_line(
2354 entry: &cu29_log::CuLogEntry,
2355 format_str: &str,
2356 param_names: &[&str],
2357) -> Option<String> {
2358 let params: Vec<String> = entry.params.iter().map(|v| v.to_string()).collect();
2359 let named: HashMap<String, String> = param_names
2360 .iter()
2361 .zip(params.iter())
2362 .map(|(k, v)| (k.to_string(), v.clone()))
2363 .collect();
2364
2365 format_message_only(format_str, params.as_slice(), &named)
2366 .ok()
2367 .map(|msg| {
2368 let ts = format_timestamp(entry.time);
2369 format!("{} [{:?}] {}", ts, entry.level, msg)
2370 })
2371}
2372
2373fn should_start_ui() -> bool {
2374 if !stdout().is_tty() || !stdin().is_tty() {
2375 return false;
2376 }
2377
2378 #[cfg(unix)]
2379 {
2380 use std::os::unix::io::AsRawFd;
2381 let stdin_fd = stdin().as_raw_fd();
2382 let fg_pgrp = unsafe { libc::tcgetpgrp(stdin_fd) };
2384 if fg_pgrp == -1 {
2385 return false;
2386 }
2387 let pgrp = unsafe { libc::getpgrp() };
2389 if fg_pgrp != pgrp {
2390 return false;
2391 }
2392 }
2393
2394 true
2395}