1use super::catalog::{DeploymentStatus, ModelCatalog};
10use super::gateway::FederationGateway;
11use super::health::{CircuitBreaker, HealthChecker};
12use super::traits::*;
13use ratatui::{
14 layout::{Constraint, Direction, Layout, Rect},
15 style::{Color, Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Tabs},
18 Frame,
19};
20use std::sync::Arc;
21use std::time::Duration;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum FederationTab {
30 #[default]
31 Catalog,
32 Health,
33 Routing,
34 Circuits,
35 Stats,
36 Policies,
37 Help,
38}
39
40impl FederationTab {
41 pub fn titles() -> Vec<&'static str> {
42 vec![
43 "Catalog [1]",
44 "Health [2]",
45 "Routing [3]",
46 "Circuits [4]",
47 "Stats [5]",
48 "Policies [6]",
49 "Help [?]",
50 ]
51 }
52
53 pub fn index(self) -> usize {
54 match self {
55 FederationTab::Catalog => 0,
56 FederationTab::Health => 1,
57 FederationTab::Routing => 2,
58 FederationTab::Circuits => 3,
59 FederationTab::Stats => 4,
60 FederationTab::Policies => 5,
61 FederationTab::Help => 6,
62 }
63 }
64
65 pub fn from_index(index: usize) -> Self {
66 match index {
67 0 => FederationTab::Catalog,
68 1 => FederationTab::Health,
69 2 => FederationTab::Routing,
70 3 => FederationTab::Circuits,
71 4 => FederationTab::Stats,
72 5 => FederationTab::Policies,
73 _ => FederationTab::Help,
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct RoutingRecord {
81 pub request_id: String,
82 pub capability: String,
83 pub selected_node: String,
84 pub score: f64,
85 pub reason: String,
86 pub timestamp: std::time::Instant,
87}
88
89#[derive(Debug, Clone)]
91pub struct CircuitDisplay {
92 pub node_id: String,
93 pub state: CircuitState,
94 pub failure_count: u32,
95 pub last_failure: Option<std::time::Instant>,
96 pub reset_remaining: Option<Duration>,
97}
98
99#[derive(Debug, Clone)]
101pub struct PolicyDisplay {
102 pub name: String,
103 pub weight: f64,
104 pub enabled: bool,
105 pub description: String,
106}
107
108pub struct FederationApp {
110 pub current_tab: FederationTab,
112 pub catalog: Arc<ModelCatalog>,
114 pub health: Arc<HealthChecker>,
116 pub circuit_breaker: Arc<CircuitBreaker>,
118 pub gateway: Option<Arc<FederationGateway>>,
120 pub routing_history: Vec<RoutingRecord>,
122 pub max_history: usize,
124 pub policies: Vec<PolicyDisplay>,
126 pub should_quit: bool,
128 pub status_message: Option<String>,
130 pub selected_row: usize,
132}
133
134impl FederationApp {
135 pub fn new(
137 catalog: Arc<ModelCatalog>,
138 health: Arc<HealthChecker>,
139 circuit_breaker: Arc<CircuitBreaker>,
140 ) -> Self {
141 let policies = vec![
142 PolicyDisplay {
143 name: "health".to_string(),
144 weight: 2.0,
145 enabled: true,
146 description: "Strongly penalize unhealthy nodes".to_string(),
147 },
148 PolicyDisplay {
149 name: "latency".to_string(),
150 weight: 1.0,
151 enabled: true,
152 description: "Prefer low-latency nodes".to_string(),
153 },
154 PolicyDisplay {
155 name: "privacy".to_string(),
156 weight: 1.0,
157 enabled: true,
158 description: "Enforce data sovereignty".to_string(),
159 },
160 PolicyDisplay {
161 name: "locality".to_string(),
162 weight: 1.0,
163 enabled: true,
164 description: "Prefer same-region nodes".to_string(),
165 },
166 PolicyDisplay {
167 name: "cost".to_string(),
168 weight: 1.0,
169 enabled: true,
170 description: "Balance cost vs performance".to_string(),
171 },
172 ];
173
174 Self {
175 current_tab: FederationTab::default(),
176 catalog,
177 health,
178 circuit_breaker,
179 gateway: None,
180 routing_history: Vec::new(),
181 max_history: 100,
182 policies,
183 should_quit: false,
184 status_message: None,
185 selected_row: 0,
186 }
187 }
188
189 #[must_use]
191 pub fn with_gateway(mut self, gateway: Arc<FederationGateway>) -> Self {
192 self.gateway = Some(gateway);
193 self
194 }
195
196 pub fn next_tab(&mut self) {
198 let next = (self.current_tab.index() + 1) % FederationTab::titles().len();
199 self.current_tab = FederationTab::from_index(next);
200 self.selected_row = 0;
201 }
202
203 pub fn prev_tab(&mut self) {
205 let len = FederationTab::titles().len();
206 let prev = (self.current_tab.index() + len - 1) % len;
207 self.current_tab = FederationTab::from_index(prev);
208 self.selected_row = 0;
209 }
210
211 pub fn select_next(&mut self) {
213 self.selected_row = self.selected_row.saturating_add(1);
214 }
215
216 pub fn select_prev(&mut self) {
218 self.selected_row = self.selected_row.saturating_sub(1);
219 }
220
221 pub fn record_routing(&mut self, record: RoutingRecord) {
223 self.routing_history.push(record);
224 if self.routing_history.len() > self.max_history {
225 self.routing_history.remove(0);
226 }
227 }
228
229 pub fn healthy_node_count(&self) -> usize {
231 self.health.healthy_count()
232 }
233
234 pub fn total_node_count(&self) -> usize {
236 self.health.total_count()
237 }
238
239 pub fn success_rate(&self) -> f64 {
241 self.gateway.as_ref().map_or(1.0, |g| {
242 let stats = g.stats();
243 if stats.total_requests == 0 {
244 1.0
245 } else {
246 stats.successful_requests as f64 / stats.total_requests as f64
247 }
248 })
249 }
250
251 pub fn requests_per_sec(&self) -> f64 {
253 self.gateway
254 .as_ref()
255 .map_or(0.0, |g| g.stats().total_requests as f64 / 60.0) }
257}
258
259pub fn render_federation_dashboard(f: &mut Frame<'_>, app: &FederationApp) {
265 let chunks = Layout::default()
266 .direction(Direction::Vertical)
267 .constraints([
268 Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
273 .split(f.area());
274
275 render_title(f, chunks[0], app);
276 render_tabs(f, chunks[1], app);
277
278 match app.current_tab {
279 FederationTab::Catalog => render_catalog(f, chunks[2], app),
280 FederationTab::Health => render_health(f, chunks[2], app),
281 FederationTab::Routing => render_routing(f, chunks[2], app),
282 FederationTab::Circuits => render_circuits(f, chunks[2], app),
283 FederationTab::Stats => render_stats(f, chunks[2], app),
284 FederationTab::Policies => render_policies(f, chunks[2], app),
285 FederationTab::Help => render_help(f, chunks[2]),
286 }
287
288 render_status_bar(f, chunks[3], app);
289}
290
291fn render_title(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
292 let healthy = app.healthy_node_count();
293 let total = app.total_node_count();
294
295 let title = format!(
296 " Federation Gateway v1.0 │ {}/{} nodes healthy │ {:.1}% success ",
297 healthy,
298 total,
299 app.success_rate() * 100.0
300 );
301
302 let block = Block::default()
303 .borders(Borders::ALL)
304 .style(Style::default().fg(Color::Cyan))
305 .title(Span::styled(
306 title,
307 Style::default()
308 .fg(Color::White)
309 .add_modifier(Modifier::BOLD),
310 ));
311
312 f.render_widget(block, area);
313}
314
315fn render_tabs(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
316 let titles: Vec<Line<'_>> = FederationTab::titles()
317 .iter()
318 .map(|t| Line::from(*t))
319 .collect();
320
321 let tabs = Tabs::new(titles)
322 .block(Block::default().borders(Borders::ALL).title("Navigation"))
323 .select(app.current_tab.index())
324 .style(Style::default().fg(Color::White))
325 .highlight_style(
326 Style::default()
327 .fg(Color::Yellow)
328 .add_modifier(Modifier::BOLD),
329 );
330
331 f.render_widget(tabs, area);
332}
333
334fn render_catalog(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
335 let block = Block::default()
336 .borders(Borders::ALL)
337 .title(" MODEL CATALOG ");
338
339 let entries = app.catalog.all_entries();
340
341 if entries.is_empty() {
342 let no_models =
343 Paragraph::new("No models registered. Use 'apr federation register' to add models.")
344 .style(Style::default().fg(Color::Yellow))
345 .block(block);
346 f.render_widget(no_models, area);
347 return;
348 }
349
350 let header = Row::new(vec![
351 Cell::from("Model").style(Style::default().fg(Color::Cyan)),
352 Cell::from("Node").style(Style::default().fg(Color::Cyan)),
353 Cell::from("Region").style(Style::default().fg(Color::Cyan)),
354 Cell::from("Capabilities").style(Style::default().fg(Color::Cyan)),
355 Cell::from("Status").style(Style::default().fg(Color::Cyan)),
356 ])
357 .height(1)
358 .bottom_margin(1);
359
360 let rows: Vec<Row<'_>> = entries
361 .iter()
362 .flat_map(|entry| {
363 entry.deployments.iter().map(move |dep| {
364 let caps: String = entry
365 .metadata
366 .capabilities
367 .iter()
368 .map(|c| format!("{:?}", c))
369 .collect::<Vec<_>>()
370 .join(", ");
371
372 let status_style = match dep.status {
373 DeploymentStatus::Ready => Style::default().fg(Color::Green),
374 DeploymentStatus::Loading => Style::default().fg(Color::Yellow),
375 DeploymentStatus::Draining => Style::default().fg(Color::Yellow),
376 DeploymentStatus::Removed => Style::default().fg(Color::Red),
377 };
378
379 Row::new(vec![
380 Cell::from(entry.metadata.model_id.0.clone()),
381 Cell::from(dep.node_id.0.clone()),
382 Cell::from(dep.region_id.0.clone()),
383 Cell::from(caps),
384 Cell::from(format!("{:?}", dep.status)).style(status_style),
385 ])
386 })
387 })
388 .collect();
389
390 let table = Table::new(
391 rows,
392 [
393 Constraint::Percentage(20),
394 Constraint::Percentage(20),
395 Constraint::Percentage(15),
396 Constraint::Percentage(30),
397 Constraint::Percentage(15),
398 ],
399 )
400 .header(header)
401 .block(block);
402
403 f.render_widget(table, area);
404}
405
406fn render_health(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
407 let block = Block::default()
408 .borders(Borders::ALL)
409 .title(" NODE HEALTH ");
410
411 let statuses = app.health.all_statuses();
412
413 if statuses.is_empty() {
414 let no_nodes = Paragraph::new("No nodes registered for health monitoring.")
415 .style(Style::default().fg(Color::Yellow))
416 .block(block);
417 f.render_widget(no_nodes, area);
418 return;
419 }
420
421 let header = Row::new(vec![
422 Cell::from("Node").style(Style::default().fg(Color::Cyan)),
423 Cell::from("State").style(Style::default().fg(Color::Cyan)),
424 Cell::from("Latency P50").style(Style::default().fg(Color::Cyan)),
425 Cell::from("Latency P99").style(Style::default().fg(Color::Cyan)),
426 Cell::from("Queue").style(Style::default().fg(Color::Cyan)),
427 ])
428 .height(1)
429 .bottom_margin(1);
430
431 let rows: Vec<Row<'_>> = statuses
432 .iter()
433 .map(|status| {
434 let state_style = match status.state {
435 HealthState::Healthy => Style::default().fg(Color::Green),
436 HealthState::Degraded => Style::default().fg(Color::Yellow),
437 HealthState::Unhealthy => Style::default().fg(Color::Red),
438 HealthState::Unknown => Style::default().fg(Color::DarkGray),
439 };
440
441 Row::new(vec![
442 Cell::from(status.node_id.0.clone()),
443 Cell::from(format!("{:?}", status.state)).style(state_style),
444 Cell::from(format!("{}ms", status.latency_p50.as_millis())),
445 Cell::from(format!("{}ms", status.latency_p99.as_millis())),
446 Cell::from(format!("{}", status.queue_depth)),
447 ])
448 })
449 .collect();
450
451 let table = Table::new(
452 rows,
453 [
454 Constraint::Percentage(25),
455 Constraint::Percentage(20),
456 Constraint::Percentage(20),
457 Constraint::Percentage(20),
458 Constraint::Percentage(15),
459 ],
460 )
461 .header(header)
462 .block(block);
463
464 f.render_widget(table, area);
465}
466
467fn render_routing(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
468 let block = Block::default()
469 .borders(Borders::ALL)
470 .title(" ROUTING DECISIONS ");
471
472 if app.routing_history.is_empty() {
473 let no_routing = Paragraph::new("No routing decisions recorded yet.")
474 .style(Style::default().fg(Color::Yellow))
475 .block(block);
476 f.render_widget(no_routing, area);
477 return;
478 }
479
480 let header = Row::new(vec![
481 Cell::from("Request").style(Style::default().fg(Color::Cyan)),
482 Cell::from("Capability").style(Style::default().fg(Color::Cyan)),
483 Cell::from("Selected").style(Style::default().fg(Color::Cyan)),
484 Cell::from("Score").style(Style::default().fg(Color::Cyan)),
485 Cell::from("Reason").style(Style::default().fg(Color::Cyan)),
486 ])
487 .height(1)
488 .bottom_margin(1);
489
490 let rows: Vec<Row<'_>> = app
491 .routing_history
492 .iter()
493 .rev()
494 .take(20)
495 .map(|record| {
496 Row::new(vec![
497 Cell::from(truncate(&record.request_id, 12)),
498 Cell::from(record.capability.clone()),
499 Cell::from(record.selected_node.clone()),
500 Cell::from(format!("{:.2}", record.score)),
501 Cell::from(truncate(&record.reason, 30)),
502 ])
503 })
504 .collect();
505
506 let table = Table::new(
507 rows,
508 [
509 Constraint::Percentage(15),
510 Constraint::Percentage(20),
511 Constraint::Percentage(20),
512 Constraint::Percentage(15),
513 Constraint::Percentage(30),
514 ],
515 )
516 .header(header)
517 .block(block);
518
519 f.render_widget(table, area);
520}
521
522fn render_circuits(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
523 let block = Block::default()
524 .borders(Borders::ALL)
525 .title(" CIRCUIT BREAKERS ");
526
527 let circuits = app.circuit_breaker.all_states();
528
529 if circuits.is_empty() {
530 let no_circuits = Paragraph::new("No circuit breakers active.")
531 .style(Style::default().fg(Color::DarkGray))
532 .block(block);
533 f.render_widget(no_circuits, area);
534 return;
535 }
536
537 let header = Row::new(vec![
538 Cell::from("Node").style(Style::default().fg(Color::Cyan)),
539 Cell::from("State").style(Style::default().fg(Color::Cyan)),
540 Cell::from("Failures").style(Style::default().fg(Color::Cyan)),
541 Cell::from("Last Failure").style(Style::default().fg(Color::Cyan)),
542 Cell::from("Reset In").style(Style::default().fg(Color::Cyan)),
543 ])
544 .height(1)
545 .bottom_margin(1);
546
547 let rows: Vec<Row<'_>> = circuits
548 .iter()
549 .map(|(node_id, state)| {
550 let state_style = match state {
551 CircuitState::Closed => Style::default().fg(Color::Green),
552 CircuitState::HalfOpen => Style::default().fg(Color::Yellow),
553 CircuitState::Open => Style::default().fg(Color::Red),
554 };
555
556 Row::new(vec![
557 Cell::from(node_id.0.clone()),
558 Cell::from(format!("{:?}", state)).style(state_style),
559 Cell::from("-"), Cell::from("-"),
561 Cell::from("-"),
562 ])
563 })
564 .collect();
565
566 let table = Table::new(
567 rows,
568 [
569 Constraint::Percentage(25),
570 Constraint::Percentage(20),
571 Constraint::Percentage(15),
572 Constraint::Percentage(20),
573 Constraint::Percentage(20),
574 ],
575 )
576 .header(header)
577 .block(block);
578
579 f.render_widget(table, area);
580}
581
582fn render_stats(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
583 let block = Block::default()
584 .borders(Borders::ALL)
585 .title(" GATEWAY STATS ");
586
587 let stats = app
588 .gateway
589 .as_ref()
590 .map_or_else(GatewayStats::default, |g| g.stats());
591
592 let chunks = Layout::default()
594 .direction(Direction::Vertical)
595 .constraints([
596 Constraint::Length(8), Constraint::Length(3), Constraint::Min(0), ])
600 .split(area);
601
602 let metrics = vec![
604 Line::from(vec![
605 Span::styled("Total Requests: ", Style::default().fg(Color::Cyan)),
606 Span::raw(format!("{}", stats.total_requests)),
607 ]),
608 Line::from(vec![
609 Span::styled("Successful: ", Style::default().fg(Color::Cyan)),
610 Span::styled(
611 format!("{}", stats.successful_requests),
612 Style::default().fg(Color::Green),
613 ),
614 ]),
615 Line::from(vec![
616 Span::styled("Failed: ", Style::default().fg(Color::Cyan)),
617 Span::styled(
618 format!("{}", stats.failed_requests),
619 Style::default().fg(Color::Red),
620 ),
621 ]),
622 Line::from(vec![
623 Span::styled("Total Tokens: ", Style::default().fg(Color::Cyan)),
624 Span::raw(format!("{}", stats.total_tokens)),
625 ]),
626 Line::from(vec![
627 Span::styled("Avg Latency: ", Style::default().fg(Color::Cyan)),
628 Span::raw(format!("{}ms", stats.avg_latency.as_millis())),
629 ]),
630 Line::from(vec![
631 Span::styled("Active Streams: ", Style::default().fg(Color::Cyan)),
632 Span::raw(format!("{}", stats.active_streams)),
633 ]),
634 ];
635
636 let metrics_widget = Paragraph::new(metrics).block(block);
637 f.render_widget(metrics_widget, chunks[0]);
638
639 let success_rate = app.success_rate();
641 let gauge_color = if success_rate >= 0.99 {
642 Color::Green
643 } else if success_rate >= 0.95 {
644 Color::Yellow
645 } else {
646 Color::Red
647 };
648
649 let gauge = Gauge::default()
650 .block(Block::default().borders(Borders::ALL).title("Success Rate"))
651 .gauge_style(Style::default().fg(gauge_color))
652 .ratio(success_rate)
653 .label(format!("{:.1}%", success_rate * 100.0));
654
655 f.render_widget(gauge, chunks[1]);
656}
657
658fn render_policies(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
659 let block = Block::default()
660 .borders(Borders::ALL)
661 .title(" ACTIVE POLICIES ");
662
663 let header = Row::new(vec![
664 Cell::from("Policy").style(Style::default().fg(Color::Cyan)),
665 Cell::from("Weight").style(Style::default().fg(Color::Cyan)),
666 Cell::from("Status").style(Style::default().fg(Color::Cyan)),
667 Cell::from("Description").style(Style::default().fg(Color::Cyan)),
668 ])
669 .height(1)
670 .bottom_margin(1);
671
672 let rows: Vec<Row<'_>> = app
673 .policies
674 .iter()
675 .map(|policy| {
676 let status_style = if policy.enabled {
677 Style::default().fg(Color::Green)
678 } else {
679 Style::default().fg(Color::DarkGray)
680 };
681
682 Row::new(vec![
683 Cell::from(policy.name.clone()),
684 Cell::from(format!("{:.1}", policy.weight)),
685 Cell::from(if policy.enabled { "Active" } else { "Disabled" }).style(status_style),
686 Cell::from(policy.description.clone()),
687 ])
688 })
689 .collect();
690
691 let table = Table::new(
692 rows,
693 [
694 Constraint::Percentage(15),
695 Constraint::Percentage(10),
696 Constraint::Percentage(15),
697 Constraint::Percentage(60),
698 ],
699 )
700 .header(header)
701 .block(block);
702
703 f.render_widget(table, area);
704}
705
706fn render_help(f: &mut Frame<'_>, area: Rect) {
707 let help_text = vec![
708 Line::from(Span::styled(
709 "Federation Gateway Dashboard",
710 Style::default()
711 .fg(Color::Cyan)
712 .add_modifier(Modifier::BOLD),
713 )),
714 Line::from(""),
715 Line::from(Span::styled(
716 "Keyboard Shortcuts",
717 Style::default()
718 .fg(Color::Cyan)
719 .add_modifier(Modifier::BOLD),
720 )),
721 Line::from(""),
722 Line::from(vec![
723 Span::styled("1-6, ? ", Style::default().fg(Color::Yellow)),
724 Span::raw("Switch to Catalog/Health/Routing/Circuits/Stats/Policies/Help"),
725 ]),
726 Line::from(vec![
727 Span::styled("Tab ", Style::default().fg(Color::Yellow)),
728 Span::raw("Next tab"),
729 ]),
730 Line::from(vec![
731 Span::styled("Shift+Tab ", Style::default().fg(Color::Yellow)),
732 Span::raw("Previous tab"),
733 ]),
734 Line::from(vec![
735 Span::styled("j / Down ", Style::default().fg(Color::Yellow)),
736 Span::raw("Select next row"),
737 ]),
738 Line::from(vec![
739 Span::styled("k / Up ", Style::default().fg(Color::Yellow)),
740 Span::raw("Select previous row"),
741 ]),
742 Line::from(vec![
743 Span::styled("r ", Style::default().fg(Color::Yellow)),
744 Span::raw("Refresh data"),
745 ]),
746 Line::from(vec![
747 Span::styled("h ", Style::default().fg(Color::Yellow)),
748 Span::raw("Toggle health panel"),
749 ]),
750 Line::from(vec![
751 Span::styled("c ", Style::default().fg(Color::Yellow)),
752 Span::raw("Toggle circuit panel"),
753 ]),
754 Line::from(vec![
755 Span::styled("s ", Style::default().fg(Color::Yellow)),
756 Span::raw("Toggle stats panel"),
757 ]),
758 Line::from(vec![
759 Span::styled("q / Esc ", Style::default().fg(Color::Yellow)),
760 Span::raw("Quit"),
761 ]),
762 Line::from(""),
763 Line::from(Span::styled(
764 "Panels",
765 Style::default()
766 .fg(Color::Cyan)
767 .add_modifier(Modifier::BOLD),
768 )),
769 Line::from(""),
770 Line::from("• Catalog: View registered models and deployments"),
771 Line::from("• Health: Monitor node health and latency"),
772 Line::from("• Routing: Track routing decisions and scores"),
773 Line::from("• Circuits: View circuit breaker states"),
774 Line::from("• Stats: Gateway statistics and success rate"),
775 Line::from("• Policies: Active routing policies"),
776 ];
777
778 let help =
779 Paragraph::new(help_text).block(Block::default().borders(Borders::ALL).title(" Help "));
780 f.render_widget(help, area);
781}
782
783fn render_status_bar(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
784 let _status = app
785 .status_message
786 .as_deref()
787 .unwrap_or("Tab to switch views │ q to quit │ ? for help");
788
789 let left = "Federation Gateway v1.0";
790 let center = format!(
791 "{}/{} nodes healthy",
792 app.healthy_node_count(),
793 app.total_node_count()
794 );
795 let right = format!(
796 "{:.1} req/s │ {:.1}% success",
797 app.requests_per_sec(),
798 app.success_rate() * 100.0
799 );
800
801 let full_status = format!("{} │ {} │ {}", left, center, right);
802
803 let status_bar = Paragraph::new(full_status).style(Style::default().fg(Color::DarkGray));
804 f.render_widget(status_bar, area);
805}
806
807fn truncate(s: &str, max_len: usize) -> String {
808 if s.len() <= max_len {
809 s.to_string()
810 } else {
811 format!("{}...", &s[..max_len.saturating_sub(3)])
812 }
813}
814
815#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_federation_tab_titles() {
825 let titles = FederationTab::titles();
826 assert_eq!(titles.len(), 7);
827 assert!(titles[0].contains("Catalog"));
828 assert!(titles[1].contains("Health"));
829 assert!(titles[2].contains("Routing"));
830 assert!(titles[3].contains("Circuits"));
831 assert!(titles[4].contains("Stats"));
832 assert!(titles[5].contains("Policies"));
833 assert!(titles[6].contains("Help"));
834 }
835
836 #[test]
837 fn test_federation_tab_index() {
838 assert_eq!(FederationTab::Catalog.index(), 0);
839 assert_eq!(FederationTab::Health.index(), 1);
840 assert_eq!(FederationTab::Routing.index(), 2);
841 assert_eq!(FederationTab::Circuits.index(), 3);
842 assert_eq!(FederationTab::Stats.index(), 4);
843 assert_eq!(FederationTab::Policies.index(), 5);
844 assert_eq!(FederationTab::Help.index(), 6);
845 }
846
847 #[test]
848 fn test_federation_tab_from_index() {
849 assert_eq!(FederationTab::from_index(0), FederationTab::Catalog);
850 assert_eq!(FederationTab::from_index(1), FederationTab::Health);
851 assert_eq!(FederationTab::from_index(2), FederationTab::Routing);
852 assert_eq!(FederationTab::from_index(3), FederationTab::Circuits);
853 assert_eq!(FederationTab::from_index(4), FederationTab::Stats);
854 assert_eq!(FederationTab::from_index(5), FederationTab::Policies);
855 assert_eq!(FederationTab::from_index(6), FederationTab::Help);
856 assert_eq!(FederationTab::from_index(99), FederationTab::Help);
857 }
858
859 #[test]
860 fn test_federation_app_new() {
861 let catalog = Arc::new(ModelCatalog::new());
862 let health = Arc::new(HealthChecker::default());
863 let circuit_breaker = Arc::new(CircuitBreaker::default());
864
865 let app = FederationApp::new(catalog, health, circuit_breaker);
866
867 assert_eq!(app.current_tab, FederationTab::Catalog);
868 assert!(!app.should_quit);
869 assert!(app.routing_history.is_empty());
870 assert_eq!(app.policies.len(), 5);
871 }
872
873 #[test]
874 fn test_federation_app_tab_navigation() {
875 let catalog = Arc::new(ModelCatalog::new());
876 let health = Arc::new(HealthChecker::default());
877 let circuit_breaker = Arc::new(CircuitBreaker::default());
878
879 let mut app = FederationApp::new(catalog, health, circuit_breaker);
880
881 assert_eq!(app.current_tab, FederationTab::Catalog);
882 app.next_tab();
883 assert_eq!(app.current_tab, FederationTab::Health);
884 app.next_tab();
885 assert_eq!(app.current_tab, FederationTab::Routing);
886 app.prev_tab();
887 assert_eq!(app.current_tab, FederationTab::Health);
888 }
889
890 #[test]
891 fn test_federation_app_routing_history() {
892 let catalog = Arc::new(ModelCatalog::new());
893 let health = Arc::new(HealthChecker::default());
894 let circuit_breaker = Arc::new(CircuitBreaker::default());
895
896 let mut app = FederationApp::new(catalog, health, circuit_breaker);
897
898 let record = RoutingRecord {
899 request_id: "req-1".to_string(),
900 capability: "Transcribe".to_string(),
901 selected_node: "node-1".to_string(),
902 score: 0.95,
903 reason: "lowest latency".to_string(),
904 timestamp: std::time::Instant::now(),
905 };
906
907 app.record_routing(record);
908 assert_eq!(app.routing_history.len(), 1);
909 }
910
911 #[test]
912 fn test_truncate() {
913 assert_eq!(truncate("short", 10), "short");
914 assert_eq!(truncate("very long string", 10), "very lo...");
915 assert_eq!(truncate("exactly10", 10), "exactly10");
916 }
917
918 mod tui_frame_tests {
923 use super::*;
924 use jugar_probar::tui::{
925 expect_frame, FrameSequence, SnapshotManager, TuiFrame, TuiSnapshot,
926 };
927 use ratatui::backend::TestBackend;
928 use ratatui::Terminal;
929
930 fn render_frame(app: &FederationApp, width: u16, height: u16) -> TuiFrame {
932 let backend = TestBackend::new(width, height);
933 let mut terminal = Terminal::new(backend).expect("terminal creation");
934 terminal
935 .draw(|f| render_federation_dashboard(f, app))
936 .expect("draw");
937 TuiFrame::from_buffer(terminal.backend().buffer(), 0)
938 }
939
940 fn create_test_app() -> FederationApp {
941 let catalog = Arc::new(ModelCatalog::new());
942 let health = Arc::new(HealthChecker::default());
943 let circuit_breaker = Arc::new(CircuitBreaker::default());
944 FederationApp::new(catalog, health, circuit_breaker)
945 }
946
947 #[test]
948 fn test_federation_tui_title_displayed() {
949 let app = create_test_app();
950 let frame = render_frame(&app, 100, 30);
951
952 assert!(
953 frame.contains("Federation Gateway"),
954 "Should show title: {}",
955 frame.as_text()
956 );
957 }
958
959 #[test]
960 fn test_federation_tui_all_tabs_displayed() {
961 let app = create_test_app();
962 let frame = render_frame(&app, 100, 30);
963
964 assert!(frame.contains("Catalog"), "Should show Catalog tab");
965 assert!(frame.contains("Health"), "Should show Health tab");
966 assert!(frame.contains("Routing"), "Should show Routing tab");
967 assert!(frame.contains("Circuits"), "Should show Circuits tab");
968 assert!(frame.contains("Stats"), "Should show Stats tab");
969 assert!(frame.contains("Policies"), "Should show Policies tab");
970 assert!(frame.contains("Help"), "Should show Help tab");
971 }
972
973 #[test]
974 fn test_federation_tui_catalog_empty() {
975 let app = create_test_app();
976 let frame = render_frame(&app, 100, 30);
977
978 assert!(
980 frame.contains("No models registered") || frame.contains("MODEL CATALOG"),
981 "Should show catalog panel"
982 );
983 }
984
985 #[test]
986 fn test_federation_tui_health_tab() {
987 let mut app = create_test_app();
988 app.current_tab = FederationTab::Health;
989 let frame = render_frame(&app, 100, 30);
990
991 assert!(
992 frame.contains("NODE HEALTH"),
993 "Should show health panel title"
994 );
995 }
996
997 #[test]
998 fn test_federation_tui_routing_tab() {
999 let mut app = create_test_app();
1000 app.current_tab = FederationTab::Routing;
1001 let frame = render_frame(&app, 100, 30);
1002
1003 assert!(
1004 frame.contains("ROUTING DECISIONS"),
1005 "Should show routing panel title"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_federation_tui_circuits_tab() {
1011 let mut app = create_test_app();
1012 app.current_tab = FederationTab::Circuits;
1013 let frame = render_frame(&app, 100, 30);
1014
1015 assert!(
1016 frame.contains("CIRCUIT BREAKERS"),
1017 "Should show circuits panel title"
1018 );
1019 }
1020
1021 #[test]
1022 fn test_federation_tui_stats_tab() {
1023 let mut app = create_test_app();
1024 app.current_tab = FederationTab::Stats;
1025 let frame = render_frame(&app, 100, 30);
1026
1027 assert!(
1028 frame.contains("GATEWAY STATS"),
1029 "Should show stats panel title"
1030 );
1031 assert!(
1032 frame.contains("Total Requests"),
1033 "Should show request count"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_federation_tui_policies_tab() {
1039 let mut app = create_test_app();
1040 app.current_tab = FederationTab::Policies;
1041 let frame = render_frame(&app, 100, 30);
1042
1043 assert!(
1044 frame.contains("ACTIVE POLICIES"),
1045 "Should show policies panel title"
1046 );
1047 assert!(frame.contains("health"), "Should show health policy");
1048 assert!(frame.contains("latency"), "Should show latency policy");
1049 }
1050
1051 #[test]
1052 fn test_federation_tui_help_tab() {
1053 let mut app = create_test_app();
1054 app.current_tab = FederationTab::Help;
1055 let frame = render_frame(&app, 100, 30);
1056
1057 assert!(
1058 frame.contains("Keyboard Shortcuts"),
1059 "Should show keyboard shortcuts"
1060 );
1061 assert!(frame.contains("q / Esc"), "Should show quit shortcut");
1062 }
1063
1064 #[test]
1065 fn test_federation_tui_frame_dimensions() {
1066 let app = create_test_app();
1067 let frame = render_frame(&app, 120, 40);
1068
1069 assert_eq!(frame.width(), 120, "Frame width");
1070 assert_eq!(frame.height(), 40, "Frame height");
1071 }
1072
1073 #[test]
1078 fn test_probar_chained_assertions() {
1079 let app = create_test_app();
1080 let frame = render_frame(&app, 100, 30);
1081
1082 let mut assertion = expect_frame(&frame);
1083 assert!(assertion.to_contain_text("Federation Gateway").is_ok());
1084 assert!(assertion.to_contain_text("Navigation").is_ok());
1085 assert!(assertion.not_to_contain_text("ERROR").is_ok());
1086 }
1087
1088 #[test]
1089 fn test_probar_soft_assertions() {
1090 let app = create_test_app();
1091 let frame = render_frame(&app, 100, 30);
1092
1093 let mut assertion = expect_frame(&frame).soft();
1094 let _ = assertion.to_contain_text("Federation Gateway");
1095 let _ = assertion.to_contain_text("Catalog");
1096 let _ = assertion.to_have_size(100, 30);
1097
1098 assert!(assertion.errors().is_empty(), "No soft assertion errors");
1099 assert!(assertion.finalize().is_ok());
1100 }
1101
1102 #[test]
1107 fn test_snapshot_creation() {
1108 let app = create_test_app();
1109 let frame = render_frame(&app, 100, 30);
1110
1111 let snapshot = TuiSnapshot::from_frame("federation_catalog", &frame);
1112
1113 assert_eq!(snapshot.name, "federation_catalog");
1114 assert_eq!(snapshot.width, 100);
1115 assert_eq!(snapshot.height, 30);
1116 assert!(!snapshot.hash.is_empty());
1117 }
1118
1119 #[test]
1120 fn test_snapshot_different_tabs_differ() {
1121 let mut app = create_test_app();
1122
1123 app.current_tab = FederationTab::Catalog;
1124 let frame_catalog = render_frame(&app, 100, 30);
1125 let snap_catalog = TuiSnapshot::from_frame("catalog", &frame_catalog);
1126
1127 app.current_tab = FederationTab::Help;
1128 let frame_help = render_frame(&app, 100, 30);
1129 let snap_help = TuiSnapshot::from_frame("help", &frame_help);
1130
1131 assert!(
1132 !snap_catalog.matches(&snap_help),
1133 "Different tabs should have different snapshots"
1134 );
1135 }
1136
1137 #[test]
1142 fn test_frame_sequence_tab_navigation() {
1143 let mut app = create_test_app();
1144 let mut sequence = FrameSequence::new("federation_tabs");
1145
1146 for tab in [
1147 FederationTab::Catalog,
1148 FederationTab::Health,
1149 FederationTab::Routing,
1150 FederationTab::Circuits,
1151 FederationTab::Stats,
1152 FederationTab::Policies,
1153 FederationTab::Help,
1154 ] {
1155 app.current_tab = tab;
1156 sequence.add_frame(&render_frame(&app, 100, 30));
1157 }
1158
1159 assert_eq!(sequence.len(), 7, "Should have 7 frames");
1160
1161 let first = sequence.first().expect("first");
1162 let last = sequence.last().expect("last");
1163 assert!(!first.matches(last), "First and last should differ");
1164 }
1165
1166 #[test]
1171 fn test_snapshot_manager_workflow() {
1172 use tempfile::TempDir;
1173
1174 let temp_dir = TempDir::new().expect("temp dir");
1175 let manager = SnapshotManager::new(temp_dir.path());
1176
1177 let app = create_test_app();
1178 let frame = render_frame(&app, 100, 30);
1179
1180 let result = manager.assert_snapshot("federation_dashboard", &frame);
1181 assert!(result.is_ok(), "First snapshot should be created");
1182 assert!(
1183 manager.exists("federation_dashboard"),
1184 "Snapshot should exist"
1185 );
1186
1187 let result2 = manager.assert_snapshot("federation_dashboard", &frame);
1188 assert!(result2.is_ok(), "Same frame should match");
1189 }
1190
1191 #[test]
1196 fn test_ux_coverage_federation_elements() {
1197 use jugar_probar::ux_coverage::{InteractionType, StateId, UxCoverageBuilder};
1198
1199 let mut tracker = UxCoverageBuilder::new()
1200 .clickable("tab", "catalog")
1201 .clickable("tab", "health")
1202 .clickable("tab", "routing")
1203 .clickable("tab", "circuits")
1204 .clickable("tab", "stats")
1205 .clickable("tab", "policies")
1206 .clickable("tab", "help")
1207 .clickable("nav", "next_tab")
1208 .clickable("nav", "prev_tab")
1209 .clickable("nav", "next_row")
1210 .clickable("nav", "prev_row")
1211 .clickable("nav", "quit")
1212 .screen("catalog")
1213 .screen("health")
1214 .screen("routing")
1215 .screen("circuits")
1216 .screen("stats")
1217 .screen("policies")
1218 .screen("help")
1219 .build();
1220
1221 let mut app = create_test_app();
1222
1223 for (tab, name) in [
1225 (FederationTab::Catalog, "catalog"),
1226 (FederationTab::Health, "health"),
1227 (FederationTab::Routing, "routing"),
1228 (FederationTab::Circuits, "circuits"),
1229 (FederationTab::Stats, "stats"),
1230 (FederationTab::Policies, "policies"),
1231 (FederationTab::Help, "help"),
1232 ] {
1233 app.current_tab = tab;
1234 tracker.record_interaction(
1235 &jugar_probar::ux_coverage::ElementId::new("tab", name),
1236 InteractionType::Click,
1237 );
1238 tracker.record_state(StateId::new("screen", name));
1239 }
1240
1241 app.next_tab();
1243 tracker.record_interaction(
1244 &jugar_probar::ux_coverage::ElementId::new("nav", "next_tab"),
1245 InteractionType::Click,
1246 );
1247
1248 app.prev_tab();
1249 tracker.record_interaction(
1250 &jugar_probar::ux_coverage::ElementId::new("nav", "prev_tab"),
1251 InteractionType::Click,
1252 );
1253
1254 app.select_next();
1255 tracker.record_interaction(
1256 &jugar_probar::ux_coverage::ElementId::new("nav", "next_row"),
1257 InteractionType::Click,
1258 );
1259
1260 app.select_prev();
1261 tracker.record_interaction(
1262 &jugar_probar::ux_coverage::ElementId::new("nav", "prev_row"),
1263 InteractionType::Click,
1264 );
1265
1266 app.should_quit = true;
1267 tracker.record_interaction(
1268 &jugar_probar::ux_coverage::ElementId::new("nav", "quit"),
1269 InteractionType::Click,
1270 );
1271
1272 let report = tracker.generate_report();
1273 println!("{}", report.summary());
1274
1275 assert!(report.is_complete, "UX coverage must be COMPLETE");
1276 assert!(tracker.meets(100.0), "Coverage: {}", tracker.summary());
1277 }
1278
1279 #[test]
1280 fn test_ux_coverage_with_gui_macro() {
1281 use jugar_probar::gui_coverage;
1282
1283 let mut gui = gui_coverage! {
1284 buttons: [
1285 "tab_catalog", "tab_health", "tab_routing",
1286 "tab_circuits", "tab_stats", "tab_policies", "tab_help",
1287 "nav_next", "nav_prev", "quit"
1288 ],
1289 screens: [
1290 "catalog", "health", "routing",
1291 "circuits", "stats", "policies", "help"
1292 ]
1293 };
1294
1295 gui.click("tab_catalog");
1297 gui.click("tab_health");
1298 gui.click("tab_routing");
1299 gui.click("tab_circuits");
1300 gui.click("tab_stats");
1301 gui.click("tab_policies");
1302 gui.click("tab_help");
1303 gui.click("nav_next");
1304 gui.click("nav_prev");
1305 gui.click("quit");
1306
1307 gui.visit("catalog");
1309 gui.visit("health");
1310 gui.visit("routing");
1311 gui.visit("circuits");
1312 gui.visit("stats");
1313 gui.visit("policies");
1314 gui.visit("help");
1315
1316 assert!(gui.is_complete(), "Should have 100% coverage");
1317 assert!(gui.meets(100.0), "Coverage: {}", gui.summary());
1318
1319 let report = gui.generate_report();
1320 println!(
1321 "Federation UX Coverage:\n Elements: {}/{}\n States: {}/{}\n Overall: {:.1}%",
1322 report.covered_elements,
1323 report.total_elements,
1324 report.covered_states,
1325 report.total_states,
1326 report.overall_coverage * 100.0
1327 );
1328 }
1329 }
1330}