Skip to main content

ghostlink_core/
dashboard.rs

1//! Terminal Dashboard with ratatui Integration for Ghost-Link Cluster
2//!
3//! This module provides:
4//! - Live cluster metrics display using ratatui
5//! - Node status indicators and streaming layer visualization
6//! - Operator controls (restart, reassign)
7//! - Health summary and statistics
8
9use crossterm::{
10    event::{self, Event, KeyCode, KeyEventKind},
11    terminal::{disable_raw_mode, enable_raw_mode},
12};
13use ratatui::{
14    prelude::*,
15    widgets::{Block, Borders, Paragraph},
16};
17
18use crate::cluster::{ClusterState, NodeMetrics};
19use crate::health::NetworkHealthMonitor;
20use crate::planning::QuantizationMode;
21
22/// Dashboard state
23#[derive(Clone, Debug)]
24pub struct DashboardState {
25    /// Cluster state
26    cluster: ClusterState,
27    /// Network health monitor
28    health_monitor: Option<NetworkHealthMonitor>,
29    /// Ring buffer statistics
30    ring_stats: (usize, usize),
31    /// Status message
32    status_message: String,
33    /// Quantization mode
34    quantization_mode: QuantizationMode,
35}
36
37impl DashboardState {
38    /// Create new dashboard state
39    pub fn new(cluster: ClusterState) -> Self {
40        Self {
41            cluster,
42            health_monitor: None, // Would be initialized with actual monitor
43            ring_stats: (0, 1024),
44            status_message: "Initializing...".to_string(),
45            quantization_mode: QuantizationMode::None,
46        }
47    }
48
49    /// Update cluster state
50    pub fn update_cluster(&mut self) {
51        let active_count = self.cluster.active_nodes_count();
52        let total_nodes = self.cluster.nodes_snapshot().len();
53        let quantization_label = match self.quantization_mode {
54            QuantizationMode::None => "full precision",
55            QuantizationMode::Int8 => "int8",
56            QuantizationMode::Int4 => "int4",
57        };
58        let health_label = if self.health_monitor.is_some() {
59            "health monitor: enabled"
60        } else {
61            "health monitor: disabled"
62        };
63
64        if active_count == 0 && total_nodes > 0 {
65            self.status_message =
66                format!("No active nodes | {quantization_label} | {health_label}");
67        } else if active_count < total_nodes {
68            self.status_message = format!(
69                "{active_count} of {total_nodes} nodes active | {quantization_label} | {health_label}"
70            );
71        } else {
72            self.status_message = format!(
73                "Cluster healthy: {:.1} GB total VRAM | {quantization_label} | {health_label}",
74                self.cluster.total_vram_gb(),
75            );
76        }
77    }
78
79    /// Get ring fill percentage
80    pub fn ring_fill_percent(&self) -> u8 {
81        let (used, capacity) = self.ring_stats;
82        if capacity == 0 {
83            0
84        } else {
85            ((used as f32 / capacity as f32) * 100.0).round() as u8
86        }
87    }
88
89    /// Get total VRAM
90    pub fn total_vram_gb(&self) -> f32 {
91        self.cluster.total_vram_gb()
92    }
93}
94
95/// ASCII dashboard renderer (fallback for non-ratatui environments)
96#[derive(Clone, Debug)]
97pub struct AsciiDashboard {
98    /// Cluster state
99    cluster: ClusterState,
100    /// Ring buffer fill percentage
101    ring_fill_percent: u8,
102    /// Gradient steps
103    gradient_steps: u64,
104    /// Nodes metrics
105    nodes: Vec<NodeMetrics>,
106}
107
108impl AsciiDashboard {
109    /// Create new ASCII dashboard
110    pub fn new(
111        cluster: ClusterState,
112        ring_fill_percent: u8,
113        gradient_steps: u64,
114        nodes: Vec<NodeMetrics>,
115    ) -> Self {
116        Self {
117            cluster,
118            ring_fill_percent,
119            gradient_steps,
120            nodes,
121        }
122    }
123
124    /// Render ASCII dashboard
125    pub fn render_ascii(&self) -> String {
126        let mut output =
127            String::from("+───────────────────────────────────────────────────────────────+\n");
128        output.push_str(&format!(
129            "| GHOST-LINK CLUSTER DASHBOARD               [STATUS: {:<8}] |\n",
130            if self.cluster.nodes_snapshot().is_empty() {
131                "EMPTY"
132            } else {
133                "ACTIVE"
134            }
135        ));
136        output.push_str("+───────────────────────────────────────────────────────────────+\n");
137        output.push_str(&format!(
138            "| Ring Buffer Fill: {:>3}%                    Gradient Steps: {:>6} |\n",
139            self.ring_fill_percent, self.gradient_steps
140        ));
141
142        for node in &self.nodes {
143            let blocks = ((node.used_vram_gb / node.total_vram_gb) * 20.0).round() as usize;
144            let blocks = blocks.min(20);
145            let filled = "█".repeat(blocks);
146            let empty = "░".repeat(20 - blocks);
147            let gauge = format!("{filled}{empty}");
148            output.push_str(&format!(
149                "| {:<7} ({:<8}) [{}] {:>4.1} / {:>4.1} GB VRAM |\n",
150                node.name,
151                node.gpu_name.as_deref().unwrap_or("Unknown"),
152                gauge,
153                node.used_vram_gb,
154                node.total_vram_gb
155            ));
156
157            if let Some((start, end)) = node.streaming_layers {
158                output.push_str(&format!(
159                    "| >>> Streaming Layers {:>2}-{:>2} >>> [AF_XDP: {:>4.1} Gbps / {:>3.1}us] |\n",
160                    start, end, 9.8, 1.2
161                ));
162            }
163        }
164
165        output.push_str("+───────────────────────────────────────────────────────────────+\n");
166        output
167    }
168}
169
170/// Ratatui terminal application
171pub struct TerminalApp {
172    /// Dashboard state
173    state: DashboardState,
174}
175
176impl TerminalApp {
177    /// Create new terminal app
178    pub fn new(state: DashboardState) -> Self {
179        Self { state }
180    }
181
182    /// Run terminal application
183    pub fn run(&mut self) {
184        // Initialize terminal
185        let stdout = std::io::stdout();
186
187        if let Err(err) = enable_raw_mode() {
188            println!("Failed to enable raw mode: {err:?}");
189            return;
190        }
191
192        let backend = ratatui::backend::CrosstermBackend::new(stdout);
193        let mut terminal = Terminal::new(backend).unwrap();
194
195        // Run application loop
196        let result = self.run_app_loop(&mut terminal);
197
198        // Restore terminal
199        disable_raw_mode().unwrap();
200
201        match result {
202            Ok(msg) if !msg.is_empty() => println!("{msg}"),
203            Err(err) => println!("Application error: {err}"),
204            _ => {}
205        }
206    }
207
208    /// Run application loop
209    fn run_app_loop(
210        &mut self,
211        terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
212    ) -> Result<String, String> {
213        let mut result = String::new();
214
215        loop {
216            // Render UI
217            terminal
218                .draw(|frame| self.render(frame))
219                .map_err(|e| e.to_string())?;
220
221            // Check for events
222            if event::poll(std::time::Duration::from_millis(100)).map_err(|e| e.to_string())? {
223                if let Event::Key(key) = event::read().map_err(|e| e.to_string())? {
224                    if key.kind == KeyEventKind::Press {
225                        match key.code {
226                            KeyCode::Char('q') => return Ok(result), // Quit on 'q'
227                            KeyCode::Char('r') => self.state.update_cluster(), // Refresh on 'r'
228                            _ => {}
229                        }
230                    }
231                }
232            }
233
234            result.push_str(&self.state.status_message);
235        }
236    }
237
238    /// Render UI frame
239    fn render(&mut self, frame: &mut ratatui::Frame) {
240        let chunks = Layout::default()
241            .direction(Direction::Vertical)
242            .margin(1)
243            .constraints([
244                Constraint::Length(3), // Title
245                Constraint::Min(0),    // Content
246                Constraint::Length(2), // Footer
247            ])
248            .split(frame.area());
249
250        // Title
251        let title = Paragraph::new("GHOST-LINK CLUSTER DASHBOARD")
252            .style(
253                Style::default()
254                    .fg(Color::Cyan)
255                    .add_modifier(Modifier::BOLD),
256            )
257            .block(Block::default().borders(Borders::ALL));
258        frame.render_widget(title, chunks[0]);
259
260        // Content
261        self.render_content(frame, chunks[1]);
262
263        // Footer
264        let footer = Paragraph::new("Press 'q' to quit, 'r' to refresh")
265            .style(Style::default().fg(Color::Gray))
266            .block(Block::default().borders(Borders::ALL));
267        frame.render_widget(footer, chunks[2]);
268    }
269
270    /// Render content area
271    fn render_content(&self, frame: &mut ratatui::Frame, area: Rect) {
272        // Status
273        let status = Paragraph::new(self.state.status_message.clone())
274            .style(Style::default().fg(Color::Green))
275            .block(Block::default().borders(Borders::ALL));
276        frame.render_widget(status, area);
277    }
278}
279
280/// Main dashboard runner (combines ASCII and ratatui)
281pub struct Dashboard {
282    /// Cluster state
283    cluster: ClusterState,
284    /// Ring buffer fill percentage
285    ring_fill_percent: u8,
286    /// Gradient steps
287    gradient_steps: u64,
288    /// Nodes metrics
289    nodes: Vec<NodeMetrics>,
290}
291
292impl Dashboard {
293    /// Create new dashboard
294    pub fn new(
295        cluster: ClusterState,
296        ring_fill_percent: u8,
297        gradient_steps: u64,
298        nodes: Vec<NodeMetrics>,
299    ) -> Self {
300        Self {
301            cluster,
302            ring_fill_percent,
303            gradient_steps,
304            nodes,
305        }
306    }
307
308    /// Render ASCII dashboard (simple)
309    pub fn render_ascii(&self) -> String {
310        AsciiDashboard::new(
311            self.cluster.clone(),
312            self.ring_fill_percent,
313            self.gradient_steps,
314            self.nodes.clone(),
315        )
316        .render_ascii()
317    }
318
319    /// Render ratatui terminal application
320    pub fn run_terminal(&self) {
321        let state = DashboardState::new(self.cluster.clone());
322        let mut app = TerminalApp::new(state);
323        app.run();
324    }
325}
326
327/// Health summary widget for dashboard
328#[derive(Clone, Debug)]
329pub struct HealthSummary {
330    /// Active nodes count
331    active_nodes: usize,
332    /// Failed nodes count
333    failed_nodes: usize,
334    /// Average latency in microseconds
335    avg_latency_us: f32,
336    /// Average delivery ratio
337    avg_delivery_ratio: f32,
338}
339
340impl HealthSummary {
341    /// Create new health summary
342    pub fn new(
343        active_nodes: usize,
344        failed_nodes: usize,
345        avg_latency_us: f32,
346        avg_delivery_ratio: f32,
347    ) -> Self {
348        Self {
349            active_nodes,
350            failed_nodes,
351            avg_latency_us,
352            avg_delivery_ratio,
353        }
354    }
355
356    /// Render health summary as widget
357    pub fn render(&self) -> String {
358        format!(
359            "Health Summary\n\
360             ==========\n\
361             Active nodes: {}\n\
362             Failed nodes: {}\n\
363             Avg latency: {:.2}us\n\
364             Avg delivery ratio: {:.2}\n",
365            self.active_nodes, self.failed_nodes, self.avg_latency_us, self.avg_delivery_ratio
366        )
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::cluster::ClusterState;
374    use crate::protocol::NodeResources;
375
376    #[test]
377    fn renders_dashboard_with_stream_information() {
378        let cluster = ClusterState::new();
379        cluster.register(crate::cluster::NodeResources::new(
380            "node-a", 24.0, 64.0, "8.9", None,
381        ));
382
383        let dashboard = Dashboard::new(
384            cluster.clone(),
385            63,
386            42,
387            vec![NodeMetrics {
388                name: "NODE-01".into(),
389                gpu_name: Some("RTX4090".into()),
390                used_vram_gb: 22.4,
391                total_vram_gb: 24.0,
392                streaming_layers: Some((0, 24)),
393                af_xdp_gbps: 9.8,
394                latency_micros: 1.2,
395                ..Default::default()
396            }],
397        );
398
399        let rendered = dashboard.render_ascii();
400
401        assert!(rendered.contains("GHOST-LINK CLUSTER DASHBOARD"));
402        assert!(rendered.contains("Streaming Layers  0-24"));
403        assert!(rendered.contains("Ring Buffer Fill:  63%"));
404    }
405
406    #[test]
407    fn health_summary_reports() {
408        let summary = HealthSummary::new(2, 0, 1.5, 0.98);
409
410        let report = summary.render();
411        assert!(report.contains("Active nodes: 2"));
412        assert!(report.contains("Avg latency: 1.50us"));
413    }
414
415    #[test]
416    fn dashboard_ring_fill_percent() {
417        let cluster = ClusterState::new();
418        cluster.register(NodeResources::new("node-a", 24.0, 64.0, "8.9", None));
419
420        let ring_stats = (153, 1024); // 15% fill
421        let dashboard_state = DashboardState {
422            cluster,
423            health_monitor: None,
424            ring_stats,
425            status_message: "Initializing...".to_string(),
426            quantization_mode: QuantizationMode::None,
427        };
428
429        assert_eq!(dashboard_state.ring_fill_percent(), 15);
430    }
431}