apr_cli/federation/
tui.rs

1//! Federation Gateway TUI Dashboard
2//!
3//! Interactive terminal dashboard for monitoring the federation gateway.
4//! Displays: model catalog, node health, routing decisions, circuit breakers,
5//! gateway stats, and active policies.
6//!
7//! Panels match the playbook configuration in playbooks/federation-gateway.yaml
8
9use 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// ============================================================================
24// TUI State
25// ============================================================================
26
27/// Active tab in the federation dashboard
28#[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/// Routing decision record for display
79#[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/// Circuit breaker display state
90#[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/// Active policy display
100#[derive(Debug, Clone)]
101pub struct PolicyDisplay {
102    pub name: String,
103    pub weight: f64,
104    pub enabled: bool,
105    pub description: String,
106}
107
108/// Federation dashboard application state
109pub struct FederationApp {
110    /// Current active tab
111    pub current_tab: FederationTab,
112    /// Model catalog reference
113    pub catalog: Arc<ModelCatalog>,
114    /// Health checker reference
115    pub health: Arc<HealthChecker>,
116    /// Circuit breaker reference
117    pub circuit_breaker: Arc<CircuitBreaker>,
118    /// Gateway reference (optional)
119    pub gateway: Option<Arc<FederationGateway>>,
120    /// Recent routing decisions
121    pub routing_history: Vec<RoutingRecord>,
122    /// Maximum routing history entries
123    pub max_history: usize,
124    /// Active policies
125    pub policies: Vec<PolicyDisplay>,
126    /// Should quit flag
127    pub should_quit: bool,
128    /// Status message
129    pub status_message: Option<String>,
130    /// Selected row index (for navigation)
131    pub selected_row: usize,
132}
133
134impl FederationApp {
135    /// Create new federation dashboard app
136    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    /// Attach a gateway for stats
190    #[must_use]
191    pub fn with_gateway(mut self, gateway: Arc<FederationGateway>) -> Self {
192        self.gateway = Some(gateway);
193        self
194    }
195
196    /// Navigate to next tab
197    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    /// Navigate to previous tab
204    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    /// Select next row
212    pub fn select_next(&mut self) {
213        self.selected_row = self.selected_row.saturating_add(1);
214    }
215
216    /// Select previous row
217    pub fn select_prev(&mut self) {
218        self.selected_row = self.selected_row.saturating_sub(1);
219    }
220
221    /// Record a routing decision
222    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    /// Get healthy node count
230    pub fn healthy_node_count(&self) -> usize {
231        self.health.healthy_count()
232    }
233
234    /// Get total node count
235    pub fn total_node_count(&self) -> usize {
236        self.health.total_count()
237    }
238
239    /// Get success rate
240    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    /// Get requests per second (mock for now)
252    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) // Approximation
256    }
257}
258
259// ============================================================================
260// Rendering
261// ============================================================================
262
263/// Render the federation dashboard
264pub fn render_federation_dashboard(f: &mut Frame<'_>, app: &FederationApp) {
265    let chunks = Layout::default()
266        .direction(Direction::Vertical)
267        .constraints([
268            Constraint::Length(3), // Title
269            Constraint::Length(3), // Tabs
270            Constraint::Min(0),    // Content
271            Constraint::Length(1), // Status bar
272        ])
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("-"), // Would need failure count from circuit breaker
560                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    // Split area for metrics and gauges
593    let chunks = Layout::default()
594        .direction(Direction::Vertical)
595        .constraints([
596            Constraint::Length(8), // Metrics
597            Constraint::Length(3), // Success rate gauge
598            Constraint::Min(0),    // Spacer
599        ])
600        .split(area);
601
602    // Metrics
603    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    // Success rate gauge
640    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// ============================================================================
816// Tests
817// ============================================================================
818
819#[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    // =========================================================================
919    // Probar TUI Frame Tests
920    // =========================================================================
921
922    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        /// Helper to render federation app to a test backend
931        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            // Default tab is catalog, should show empty message
979            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        // =====================================================================
1074        // Playwright-style Assertions
1075        // =====================================================================
1076
1077        #[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        // =====================================================================
1103        // Snapshot Testing
1104        // =====================================================================
1105
1106        #[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        // =====================================================================
1138        // Frame Sequence Testing
1139        // =====================================================================
1140
1141        #[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        // =====================================================================
1167        // Snapshot Manager Tests
1168        // =====================================================================
1169
1170        #[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        // =====================================================================
1192        // UX Coverage Tests
1193        // =====================================================================
1194
1195        #[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            // Cover all tabs
1224            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            // Cover navigation
1242            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            // Cover all buttons
1296            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            // Cover all screens
1308            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}