Skip to main content

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
406include!("rendering.rs");
407include!("tui_03.rs");