1use 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#[derive(Clone, Debug)]
24pub struct DashboardState {
25 cluster: ClusterState,
27 health_monitor: Option<NetworkHealthMonitor>,
29 ring_stats: (usize, usize),
31 status_message: String,
33 quantization_mode: QuantizationMode,
35}
36
37impl DashboardState {
38 pub fn new(cluster: ClusterState) -> Self {
40 Self {
41 cluster,
42 health_monitor: None, ring_stats: (0, 1024),
44 status_message: "Initializing...".to_string(),
45 quantization_mode: QuantizationMode::None,
46 }
47 }
48
49 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 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 pub fn total_vram_gb(&self) -> f32 {
91 self.cluster.total_vram_gb()
92 }
93}
94
95#[derive(Clone, Debug)]
97pub struct AsciiDashboard {
98 cluster: ClusterState,
100 ring_fill_percent: u8,
102 gradient_steps: u64,
104 nodes: Vec<NodeMetrics>,
106}
107
108impl AsciiDashboard {
109 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 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
170pub struct TerminalApp {
172 state: DashboardState,
174}
175
176impl TerminalApp {
177 pub fn new(state: DashboardState) -> Self {
179 Self { state }
180 }
181
182 pub fn run(&mut self) {
184 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 let result = self.run_app_loop(&mut terminal);
197
198 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 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 terminal
218 .draw(|frame| self.render(frame))
219 .map_err(|e| e.to_string())?;
220
221 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), KeyCode::Char('r') => self.state.update_cluster(), _ => {}
229 }
230 }
231 }
232 }
233
234 result.push_str(&self.state.status_message);
235 }
236 }
237
238 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), Constraint::Min(0), Constraint::Length(2), ])
248 .split(frame.area());
249
250 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 self.render_content(frame, chunks[1]);
262
263 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 fn render_content(&self, frame: &mut ratatui::Frame, area: Rect) {
272 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
280pub struct Dashboard {
282 cluster: ClusterState,
284 ring_fill_percent: u8,
286 gradient_steps: u64,
288 nodes: Vec<NodeMetrics>,
290}
291
292impl Dashboard {
293 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 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 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#[derive(Clone, Debug)]
329pub struct HealthSummary {
330 active_nodes: usize,
332 failed_nodes: usize,
334 avg_latency_us: f32,
336 avg_delivery_ratio: f32,
338}
339
340impl HealthSummary {
341 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 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); 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}