Skip to main content

unifi_cli/
tui.rs

1use std::collections::HashMap;
2use std::io;
3use std::time::{Duration, Instant};
4
5use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
6use crossterm::execute;
7use crossterm::terminal::{
8    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9};
10use ratatui::Terminal;
11use ratatui::backend::CrosstermBackend;
12use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table};
16
17use crate::api::{
18    ApiError, HealthSubsystem, HostSystem, LegacyClient, LegacyDevice, SysInfo, UnifiClient,
19    format_bytes, format_uptime,
20};
21
22const HEADER_COLOR: Color = Color::Cyan;
23const ONLINE_COLOR: Color = Color::Green;
24const OFFLINE_COLOR: Color = Color::Red;
25const WARN_COLOR: Color = Color::Yellow;
26const DIM_COLOR: Color = Color::DarkGray;
27const ACCENT_COLOR: Color = Color::Cyan;
28const SELECTED_BG: Color = Color::Rgb(40, 40, 60);
29
30#[derive(Clone, Copy, Debug, PartialEq)]
31enum Panel {
32    Clients,
33    Devices,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq)]
37enum SortMode {
38    Bandwidth,
39    Name,
40    Ip,
41}
42
43impl SortMode {
44    fn label(self) -> &'static str {
45        match self {
46            SortMode::Bandwidth => "total ↓",
47            SortMode::Name => "name ↓",
48            SortMode::Ip => "ip ↓",
49        }
50    }
51
52    fn next(self) -> Self {
53        match self {
54            SortMode::Bandwidth => SortMode::Name,
55            SortMode::Name => SortMode::Ip,
56            SortMode::Ip => SortMode::Bandwidth,
57        }
58    }
59}
60
61enum Overlay {
62    ClientDetail(usize), // index into sorted_clients
63    DeviceDetail(usize), // index into devices
64}
65
66enum ClientAction {
67    Kick(String),    // MAC
68    Block(String),   // MAC
69    Unblock(String), // MAC
70}
71
72enum DeviceAction {
73    Restart(String),      // MAC
74    Upgrade(String),      // MAC
75    Locate(String, bool), // MAC, enable
76}
77
78struct AppState {
79    sysinfo: Option<SysInfo>,
80    host_system: Option<HostSystem>,
81    health: Vec<HealthSubsystem>,
82    clients: Vec<LegacyClient>,
83    devices: Vec<LegacyDevice>,
84    focus: Panel,
85    sort: SortMode,
86    client_scroll: usize,
87    device_scroll: usize,
88    filter: String,
89    filtering: bool,
90    overlay: Option<Overlay>,
91    loading: bool,
92    last_error: Option<String>,
93    status_msg: Option<(String, Instant)>,
94    locating: HashMap<String, bool>, // MAC -> currently locating
95}
96
97impl AppState {
98    fn new() -> Self {
99        Self {
100            sysinfo: None,
101            host_system: None,
102            health: Vec::new(),
103            clients: Vec::new(),
104            devices: Vec::new(),
105            focus: Panel::Clients,
106            sort: SortMode::Bandwidth,
107            client_scroll: 0,
108            device_scroll: 0,
109            filter: String::new(),
110            filtering: false,
111            overlay: None,
112            loading: true,
113            last_error: None,
114            status_msg: None,
115            locating: HashMap::new(),
116        }
117    }
118
119    fn sorted_clients(&self) -> Vec<&LegacyClient> {
120        let mut clients: Vec<&LegacyClient> = self
121            .clients
122            .iter()
123            .filter(|c| {
124                if self.filter.is_empty() {
125                    return true;
126                }
127                let needle = self.filter.to_lowercase();
128                let name = c.display_name().to_lowercase();
129                let ip = c.ip.as_deref().unwrap_or("").to_lowercase();
130                let mac = c.mac.as_deref().unwrap_or("").to_lowercase();
131                name.contains(&needle) || ip.contains(&needle) || mac.contains(&needle)
132            })
133            .collect();
134
135        match self.sort {
136            SortMode::Bandwidth => {
137                clients.sort_by(|a, b| {
138                    let total_a = a.tx_bytes.unwrap_or(0) + a.rx_bytes.unwrap_or(0);
139                    let total_b = b.tx_bytes.unwrap_or(0) + b.rx_bytes.unwrap_or(0);
140                    total_b.cmp(&total_a)
141                });
142            }
143            SortMode::Name => {
144                clients.sort_by_key(|c| c.display_name().to_lowercase());
145            }
146            SortMode::Ip => {
147                clients.sort_by(|a, b| {
148                    let ip_a = a.ip.as_deref().unwrap_or("255.255.255.255");
149                    let ip_b = b.ip.as_deref().unwrap_or("255.255.255.255");
150                    ip_sort_key(ip_a).cmp(&ip_sort_key(ip_b))
151                });
152            }
153        }
154
155        clients
156    }
157
158    fn scroll_up(&mut self) {
159        match self.focus {
160            Panel::Clients => {
161                self.client_scroll = self.client_scroll.saturating_sub(1);
162            }
163            Panel::Devices => {
164                self.device_scroll = self.device_scroll.saturating_sub(1);
165            }
166        }
167    }
168
169    fn scroll_down(&mut self, max_clients: usize, max_devices: usize) {
170        match self.focus {
171            Panel::Clients => {
172                if self.client_scroll + 1 < max_clients {
173                    self.client_scroll += 1;
174                }
175            }
176            Panel::Devices => {
177                if self.device_scroll + 1 < max_devices {
178                    self.device_scroll += 1;
179                }
180            }
181        }
182    }
183}
184
185fn ip_sort_key(ip: &str) -> Vec<u32> {
186    ip.split('.')
187        .filter_map(|s| s.parse::<u32>().ok())
188        .collect()
189}
190
191fn format_rate(bytes_per_sec: f64) -> String {
192    if bytes_per_sec >= 1_073_741_824.0 {
193        format!("{:.1} GB/s", bytes_per_sec / 1_073_741_824.0)
194    } else if bytes_per_sec >= 1_048_576.0 {
195        format!("{:.1} MB/s", bytes_per_sec / 1_048_576.0)
196    } else if bytes_per_sec >= 1024.0 {
197        format!("{:.1} KB/s", bytes_per_sec / 1024.0)
198    } else if bytes_per_sec >= 1.0 {
199        format!("{:.0} B/s", bytes_per_sec)
200    } else {
201        "0 B/s".into()
202    }
203}
204
205fn signal_bar(dbm: i32) -> &'static str {
206    match dbm {
207        -50..=0 => "▂▄▆█",
208        -60..=-51 => "▂▄▆░",
209        -70..=-61 => "▂▄░░",
210        -80..=-71 => "▂░░░",
211        _ => "░░░░",
212    }
213}
214
215fn signal_color(dbm: i32) -> Color {
216    match dbm {
217        -50..=0 => ONLINE_COLOR,
218        -60..=-51 => ONLINE_COLOR,
219        -70..=-61 => WARN_COLOR,
220        _ => OFFLINE_COLOR,
221    }
222}
223
224fn status_color(status: &str) -> Color {
225    match status {
226        "ok" => ONLINE_COLOR,
227        "unknown" => DIM_COLOR,
228        _ => WARN_COLOR,
229    }
230}
231
232fn device_state_str(state: Option<u32>) -> (&'static str, Color) {
233    match state {
234        Some(1) => ("ONLINE", ONLINE_COLOR),
235        Some(0) => ("OFFLINE", OFFLINE_COLOR),
236        Some(2) => ("ADOPTING", WARN_COLOR),
237        Some(4) => ("UPGRADING", WARN_COLOR),
238        Some(5) => ("PROVISIONING", WARN_COLOR),
239        _ => ("UNKNOWN", DIM_COLOR),
240    }
241}
242
243async fn fetch_data_standalone(
244    http: &reqwest::Client,
245    base_url: &str,
246) -> Result<
247    (
248        Option<SysInfo>,
249        Option<HostSystem>,
250        Vec<HealthSubsystem>,
251        Vec<LegacyClient>,
252        Vec<LegacyDevice>,
253    ),
254    ApiError,
255> {
256    let sysinfo: Option<SysInfo> = legacy_get(http, base_url, "/stat/sysinfo")
257        .await
258        .ok()
259        .and_then(|mut v: Vec<SysInfo>| v.pop());
260
261    let host_system: Option<HostSystem> = async {
262        let url = format!("{base_url}/api/system");
263        let resp = http.get(&url).send().await.ok()?;
264        if !resp.status().is_success() {
265            return None;
266        }
267        resp.json::<HostSystem>().await.ok()
268    }
269    .await;
270
271    let health: Vec<HealthSubsystem> = legacy_get(http, base_url, "/stat/health")
272        .await
273        .unwrap_or_default();
274    let clients: Vec<LegacyClient> = legacy_get(http, base_url, "/stat/sta").await?;
275    let devices: Vec<LegacyDevice> = legacy_get(http, base_url, "/stat/device")
276        .await
277        .unwrap_or_default();
278
279    Ok((sysinfo, host_system, health, clients, devices))
280}
281
282async fn legacy_get<T: serde::de::DeserializeOwned>(
283    http: &reqwest::Client,
284    base_url: &str,
285    path: &str,
286) -> Result<Vec<T>, ApiError> {
287    use crate::api::types::LegacyResponse;
288    let url = format!("{base_url}/proxy/network/api/s/default{path}");
289    let resp = http.get(&url).send().await?;
290    let status = resp.status().as_u16();
291    if !resp.status().is_success() {
292        let body = resp.text().await.unwrap_or_default();
293        return Err(UnifiClient::error_for_status_pub(status, body));
294    }
295    let legacy: LegacyResponse<T> = resp.json().await?;
296    if legacy.meta.rc != "ok" {
297        return Err(ApiError::Api {
298            status: 200,
299            message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
300        });
301    }
302    Ok(legacy.data)
303}
304
305async fn legacy_post_cmd(
306    http: &reqwest::Client,
307    base_url: &str,
308    manager: &str,
309    body: serde_json::Value,
310) -> Result<(), String> {
311    let url = format!("{base_url}/proxy/network/api/s/default/cmd/{manager}");
312    let resp = http
313        .post(&url)
314        .json(&body)
315        .send()
316        .await
317        .map_err(|e| e.to_string())?;
318    if !resp.status().is_success() {
319        let body = resp.text().await.unwrap_or_default();
320        return Err(format!("API error: {body}"));
321    }
322    Ok(())
323}
324
325async fn execute_client_action(
326    http: &reqwest::Client,
327    base_url: &str,
328    action: ClientAction,
329) -> Result<String, String> {
330    match action {
331        ClientAction::Kick(mac) => {
332            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
333            legacy_post_cmd(
334                http,
335                base_url,
336                "stamgr",
337                serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
338            )
339            .await?;
340            Ok(format!("Kicked {formatted}"))
341        }
342        ClientAction::Block(mac) => {
343            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
344            legacy_post_cmd(
345                http,
346                base_url,
347                "stamgr",
348                serde_json::json!({"cmd": "block-sta", "mac": formatted}),
349            )
350            .await?;
351            Ok(format!("Blocked {formatted}"))
352        }
353        ClientAction::Unblock(mac) => {
354            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
355            legacy_post_cmd(
356                http,
357                base_url,
358                "stamgr",
359                serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
360            )
361            .await?;
362            Ok(format!("Unblocked {formatted}"))
363        }
364    }
365}
366
367async fn execute_device_action(
368    http: &reqwest::Client,
369    base_url: &str,
370    action: DeviceAction,
371) -> Result<String, String> {
372    match action {
373        DeviceAction::Restart(mac) => {
374            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
375            legacy_post_cmd(
376                http,
377                base_url,
378                "devmgr",
379                serde_json::json!({"cmd": "restart", "mac": formatted}),
380            )
381            .await?;
382            Ok(format!("Restarting {formatted}"))
383        }
384        DeviceAction::Upgrade(mac) => {
385            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
386            legacy_post_cmd(
387                http,
388                base_url,
389                "devmgr",
390                serde_json::json!({"cmd": "upgrade", "mac": formatted}),
391            )
392            .await?;
393            Ok(format!("Upgrading {formatted}"))
394        }
395        DeviceAction::Locate(mac, enable) => {
396            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
397            let cmd = if enable { "set-locate" } else { "unset-locate" };
398            legacy_post_cmd(
399                http,
400                base_url,
401                "devmgr",
402                serde_json::json!({"cmd": cmd, "mac": formatted}),
403            )
404            .await?;
405            let action_str = if enable {
406                "Locating"
407            } else {
408                "Stopped locating"
409            };
410            Ok(format!("{action_str} {formatted}"))
411        }
412    }
413}
414
415fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
416    let info = state.sysinfo.as_ref();
417    let hostname = info
418        .and_then(|s| s.hostname.as_deref())
419        .unwrap_or("UniFi Controller");
420    let version = info.and_then(|s| s.version.as_deref()).unwrap_or("-");
421    let uptime_str = info
422        .and_then(|s| s.uptime)
423        .map(format_uptime)
424        .unwrap_or_else(|| "-".into());
425
426    let title = format!(" {} v{} │ Up {} ", hostname, version, uptime_str);
427
428    // Build health spans
429    let mut health_spans: Vec<Span> = vec![Span::raw("  ")];
430    for h in &state.health {
431        let color = status_color(h.status.as_deref().unwrap_or("unknown"));
432        let bullet = Span::styled("● ", Style::default().fg(color));
433        let sub = h.subsystem.to_uppercase();
434        let detail = match h.subsystem.as_str() {
435            "wan" => h
436                .wan_ip
437                .as_deref()
438                .map(|ip| format!(" ({ip})"))
439                .unwrap_or_default(),
440            "wlan" => {
441                let ap = h.num_ap.unwrap_or(0);
442                let sta = h.num_sta.unwrap_or(0);
443                format!(" ({ap} AP, {sta} sta)")
444            }
445            "lan" => {
446                let sw = h.num_switches.unwrap_or(0);
447                let sta = h.num_sta.unwrap_or(0);
448                format!(" ({sw} sw, {sta} sta)")
449            }
450            _ => String::new(),
451        };
452        health_spans.push(bullet);
453        health_spans.push(Span::styled(
454            format!("{sub}{detail}"),
455            Style::default().fg(Color::White),
456        ));
457        health_spans.push(Span::raw("  "));
458    }
459
460    if state
461        .host_system
462        .as_ref()
463        .is_some_and(|h| h.update_available())
464    {
465        health_spans.push(Span::styled(
466            "⬆ Update available",
467            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
468        ));
469    }
470
471    let block = Block::default()
472        .borders(Borders::ALL)
473        .border_type(BorderType::Rounded)
474        .border_style(Style::default().fg(HEADER_COLOR))
475        .title(Span::styled(
476            title,
477            Style::default()
478                .fg(HEADER_COLOR)
479                .add_modifier(Modifier::BOLD),
480        ));
481
482    let health_line = Line::from(health_spans);
483    let paragraph = Paragraph::new(health_line).block(block);
484    f.render_widget(paragraph, area);
485}
486
487fn draw_clients(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
488    let clients = state.sorted_clients();
489    let is_focused = state.focus == Panel::Clients;
490
491    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
492
493    let filter_info = if !state.filter.is_empty() {
494        format!(" │ filter: {}", state.filter)
495    } else {
496        String::new()
497    };
498
499    let title = format!(
500        " Clients ({}) │ sort: {}{} ",
501        clients.len(),
502        state.sort.label(),
503        filter_info,
504    );
505
506    let block = Block::default()
507        .borders(Borders::ALL)
508        .border_type(BorderType::Rounded)
509        .border_style(Style::default().fg(border_color))
510        .title(Span::styled(
511            title,
512            Style::default()
513                .fg(border_color)
514                .add_modifier(Modifier::BOLD),
515        ));
516
517    let header_style = Style::default()
518        .fg(HEADER_COLOR)
519        .add_modifier(Modifier::BOLD);
520
521    let header = Row::new(vec![
522        Cell::from("Name").style(header_style),
523        Cell::from("Connection").style(header_style),
524        Cell::from("IP").style(header_style),
525        Cell::from("Total").style(header_style),
526    ])
527    .height(1);
528
529    // Calculate visible area (subtract borders + header)
530    let inner_height = area.height.saturating_sub(4) as usize;
531
532    let rows: Vec<Row> = clients
533        .iter()
534        .enumerate()
535        .skip(state.client_scroll)
536        .take(inner_height)
537        .map(|(i, c)| {
538            let total_bytes = c.tx_bytes.unwrap_or(0) + c.rx_bytes.unwrap_or(0);
539            let is_idle = total_bytes == 0;
540
541            let type_icon = if c.is_wired { "⌐ " } else { "◦ " };
542
543            // Show full MAC for unnamed clients
544            let display = if c.display_name() == "-" {
545                c.mac
546                    .as_deref()
547                    .map(crate::api::format_mac)
548                    .unwrap_or_else(|| "-".into())
549            } else {
550                c.display_name().to_string()
551            };
552            let name = format!("{type_icon}{display}");
553
554            let name_style = if is_idle {
555                Style::default().fg(DIM_COLOR)
556            } else {
557                Style::default()
558                    .fg(Color::White)
559                    .add_modifier(Modifier::BOLD)
560            };
561
562            let is_selected = is_focused && i == state.client_scroll;
563            let row_style = if is_selected {
564                Style::default().bg(SELECTED_BG)
565            } else {
566                Style::default()
567            };
568
569            let total_style = if is_idle {
570                Style::default().fg(DIM_COLOR)
571            } else {
572                Style::default().fg(Color::White)
573            };
574
575            // Connection info: SSID + signal bar for wireless, "Wired" for wired
576            let (conn_str, conn_color) = if c.is_wired {
577                ("Wired".to_string(), DIM_COLOR)
578            } else {
579                let ssid = c.ssid.as_deref().unwrap_or("?");
580                let signal_info = c
581                    .signal
582                    .map(|s| format!(" {}", signal_bar(s)))
583                    .unwrap_or_default();
584                let color = c.signal.map(signal_color).unwrap_or(DIM_COLOR);
585                (format!("{ssid}{signal_info}"), color)
586            };
587
588            Row::new(vec![
589                Cell::from(name).style(name_style),
590                Cell::from(conn_str).style(Style::default().fg(conn_color)),
591                Cell::from(c.ip.as_deref().unwrap_or("-").to_string())
592                    .style(Style::default().fg(DIM_COLOR)),
593                Cell::from(format_bytes(total_bytes)).style(total_style),
594            ])
595            .style(row_style)
596        })
597        .collect();
598
599    let widths = [
600        Constraint::Min(20),
601        Constraint::Length(22),
602        Constraint::Length(16),
603        Constraint::Length(10),
604    ];
605
606    if clients.is_empty() {
607        let msg = if state.filter.is_empty() {
608            "No clients connected"
609        } else {
610            "No clients match filter"
611        };
612        let empty = Paragraph::new(Line::from(Span::styled(
613            msg,
614            Style::default().fg(DIM_COLOR),
615        )))
616        .block(block)
617        .alignment(Alignment::Center);
618        f.render_widget(empty, area);
619    } else {
620        let table = Table::new(rows, widths)
621            .header(header)
622            .block(block)
623            .row_highlight_style(Style::default().bg(SELECTED_BG));
624        f.render_widget(table, area);
625    }
626}
627
628fn draw_devices(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
629    let is_focused = state.focus == Panel::Devices;
630    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
631
632    let title = format!(" Devices ({}) ", state.devices.len());
633    let block = Block::default()
634        .borders(Borders::ALL)
635        .border_type(BorderType::Rounded)
636        .border_style(Style::default().fg(border_color))
637        .title(Span::styled(
638            title,
639            Style::default()
640                .fg(border_color)
641                .add_modifier(Modifier::BOLD),
642        ));
643
644    let header = Row::new(vec![
645        Cell::from("Name").style(
646            Style::default()
647                .fg(HEADER_COLOR)
648                .add_modifier(Modifier::BOLD),
649        ),
650        Cell::from("Model").style(
651            Style::default()
652                .fg(HEADER_COLOR)
653                .add_modifier(Modifier::BOLD),
654        ),
655        Cell::from("IP").style(
656            Style::default()
657                .fg(HEADER_COLOR)
658                .add_modifier(Modifier::BOLD),
659        ),
660        Cell::from("State").style(
661            Style::default()
662                .fg(HEADER_COLOR)
663                .add_modifier(Modifier::BOLD),
664        ),
665        Cell::from("Clients").style(
666            Style::default()
667                .fg(HEADER_COLOR)
668                .add_modifier(Modifier::BOLD),
669        ),
670        Cell::from("Uptime").style(
671            Style::default()
672                .fg(HEADER_COLOR)
673                .add_modifier(Modifier::BOLD),
674        ),
675        Cell::from("Firmware").style(
676            Style::default()
677                .fg(HEADER_COLOR)
678                .add_modifier(Modifier::BOLD),
679        ),
680    ])
681    .height(1);
682
683    let inner_height = area.height.saturating_sub(4) as usize;
684
685    let rows: Vec<Row> = state
686        .devices
687        .iter()
688        .enumerate()
689        .skip(state.device_scroll)
690        .take(inner_height)
691        .map(|(i, d)| {
692            let (state_str, state_color) = device_state_str(d.state);
693
694            let is_selected = is_focused && i == state.device_scroll;
695            let row_style = if is_selected {
696                Style::default().bg(SELECTED_BG)
697            } else {
698                Style::default()
699            };
700
701            Row::new(vec![
702                Cell::from(d.name.as_deref().unwrap_or("-").to_string()).style(
703                    Style::default()
704                        .fg(Color::White)
705                        .add_modifier(Modifier::BOLD),
706                ),
707                Cell::from(d.model.as_deref().unwrap_or("-").to_string())
708                    .style(Style::default().fg(DIM_COLOR)),
709                Cell::from(d.ip.as_deref().unwrap_or("-").to_string())
710                    .style(Style::default().fg(DIM_COLOR)),
711                Cell::from(format!("● {state_str}")).style(Style::default().fg(state_color)),
712                Cell::from(
713                    d.num_sta
714                        .map(|n| n.to_string())
715                        .unwrap_or_else(|| "-".into()),
716                )
717                .style(Style::default().fg(Color::White)),
718                Cell::from(d.uptime.map(format_uptime).unwrap_or_else(|| "-".into()))
719                    .style(Style::default().fg(DIM_COLOR)),
720                Cell::from(d.version.as_deref().unwrap_or("-").to_string())
721                    .style(Style::default().fg(DIM_COLOR)),
722            ])
723            .style(row_style)
724        })
725        .collect();
726
727    let widths = [
728        Constraint::Min(18),
729        Constraint::Length(10),
730        Constraint::Length(16),
731        Constraint::Length(12),
732        Constraint::Length(8),
733        Constraint::Length(16),
734        Constraint::Length(14),
735    ];
736
737    if state.devices.is_empty() {
738        let empty = Paragraph::new(Line::from(Span::styled(
739            "No devices found",
740            Style::default().fg(DIM_COLOR),
741        )))
742        .block(block)
743        .alignment(Alignment::Center);
744        f.render_widget(empty, area);
745    } else {
746        let table = Table::new(rows, widths).header(header).block(block);
747        f.render_widget(table, area);
748    }
749}
750
751fn draw_footer(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
752    let error_span = if let Some(ref err) = state.last_error {
753        Span::styled(format!(" ⚠ {err} "), Style::default().fg(OFFLINE_COLOR))
754    } else {
755        Span::raw("")
756    };
757
758    let key_style = Style::default()
759        .fg(ACCENT_COLOR)
760        .add_modifier(Modifier::BOLD);
761    let dim = Style::default().fg(DIM_COLOR);
762
763    let status_span = if let Some((ref msg, _)) = state.status_msg {
764        Span::styled(format!(" ✓ {msg} "), Style::default().fg(ONLINE_COLOR))
765    } else {
766        Span::raw("")
767    };
768
769    let line = if let Some(Overlay::ClientDetail(idx)) = &state.overlay {
770        let block_label = state
771            .sorted_clients()
772            .get(*idx)
773            .map(|c| if c.blocked { " unblock " } else { " block " })
774            .unwrap_or(" block ");
775        Line::from(vec![
776            Span::styled(" esc", key_style),
777            Span::styled(" back ", dim),
778            Span::styled("k", key_style),
779            Span::styled(" kick ", dim),
780            Span::styled("b", key_style),
781            Span::styled(block_label, dim),
782            Span::styled("q", key_style),
783            Span::styled(" quit", dim),
784            error_span,
785            status_span,
786        ])
787    } else if let Some(Overlay::DeviceDetail(idx)) = &state.overlay {
788        let locate_label = state
789            .devices
790            .get(*idx)
791            .and_then(|d| d.mac.as_ref())
792            .map(|mac| {
793                let normalized = crate::api::normalize_mac(mac);
794                if state.locating.get(&normalized).copied().unwrap_or(false) {
795                    " stop locate "
796                } else {
797                    " locate "
798                }
799            })
800            .unwrap_or(" locate ");
801        Line::from(vec![
802            Span::styled(" esc", key_style),
803            Span::styled(" back ", dim),
804            Span::styled("r", key_style),
805            Span::styled(" restart ", dim),
806            Span::styled("u", key_style),
807            Span::styled(" upgrade ", dim),
808            Span::styled("l", key_style),
809            Span::styled(locate_label, dim),
810            Span::styled("q", key_style),
811            Span::styled(" quit", dim),
812            error_span,
813            status_span,
814        ])
815    } else if state.filtering {
816        Line::from(vec![
817            Span::styled(" ", Style::default()),
818            Span::styled(
819                format!("filter: {}▌", state.filter),
820                Style::default()
821                    .fg(Color::Yellow)
822                    .add_modifier(Modifier::BOLD),
823            ),
824            Span::styled("  esc", key_style),
825            Span::styled(" clear ", dim),
826            Span::styled("enter", key_style),
827            Span::styled(" apply", dim),
828            error_span,
829        ])
830    } else {
831        Line::from(vec![
832            Span::styled(" q", key_style),
833            Span::styled(" quit ", dim),
834            Span::styled("s", key_style),
835            Span::styled(" sort ", dim),
836            Span::styled("/", key_style),
837            Span::styled(" filter ", dim),
838            Span::styled("enter", key_style),
839            Span::styled(" details ", dim),
840            Span::styled("tab", key_style),
841            Span::styled(" switch panel", dim),
842            error_span,
843            status_span,
844        ])
845    };
846
847    let paragraph = Paragraph::new(line);
848    f.render_widget(paragraph, area);
849}
850
851fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
852    let vertical = Layout::default()
853        .direction(Direction::Vertical)
854        .constraints([
855            Constraint::Min(0),
856            Constraint::Length(height),
857            Constraint::Min(0),
858        ])
859        .split(area);
860    Layout::default()
861        .direction(Direction::Horizontal)
862        .constraints([
863            Constraint::Min(0),
864            Constraint::Length(width),
865            Constraint::Min(0),
866        ])
867        .split(vertical[1])[1]
868}
869
870fn draw_overlay(f: &mut ratatui::Frame, state: &AppState) {
871    let overlay = match &state.overlay {
872        Some(o) => o,
873        None => return,
874    };
875
876    // Count rows to size the overlay
877    let row_count = match overlay {
878        Overlay::ClientDetail(idx) => {
879            let clients = state.sorted_clients();
880            let c = match clients.get(*idx) {
881                Some(c) => c,
882                None => return,
883            };
884            let mut n = 3; // MAC, IP, Type
885            if c.uptime.is_some() {
886                n += 1;
887            }
888            if c.tx_bytes.is_some() {
889                n += 1;
890            }
891            if c.rx_bytes.is_some() {
892                n += 1;
893            }
894            if !c.is_wired {
895                if c.signal.is_some() {
896                    n += 1;
897                }
898                if c.ssid.is_some() {
899                    n += 1;
900                }
901                if c.ap_mac.is_some() {
902                    n += 1;
903                }
904            }
905            n
906        }
907        Overlay::DeviceDetail(idx) => {
908            let d = match state.devices.get(*idx) {
909                Some(d) => d,
910                None => return,
911            };
912            let mut n = 4; // Model, MAC, IP, State
913            if d.version.is_some() {
914                n += 1;
915            }
916            if d.uptime.is_some() {
917                n += 1;
918            }
919            if d.num_sta.is_some() {
920                n += 1;
921            }
922            n
923        }
924    };
925
926    // 2 borders + 1 header row gap + data rows
927    let height = (row_count as u16 + 3).min(f.area().height.saturating_sub(4));
928    let width = 44_u16.min(f.area().width.saturating_sub(4));
929    let area = centered_rect_fixed(width, height, f.area());
930    f.render_widget(Clear, area);
931
932    match overlay {
933        Overlay::ClientDetail(idx) => {
934            let clients = state.sorted_clients();
935            let Some(c) = clients.get(*idx) else { return };
936
937            let title = format!(" {} ", c.display_name());
938            let block = Block::default()
939                .borders(Borders::ALL)
940                .border_type(BorderType::Rounded)
941                .border_style(Style::default().fg(ACCENT_COLOR))
942                .style(Style::default().bg(Color::Black))
943                .title(Span::styled(
944                    title,
945                    Style::default()
946                        .fg(ACCENT_COLOR)
947                        .add_modifier(Modifier::BOLD),
948                ));
949
950            let mut rows = vec![
951                detail_row(
952                    "MAC",
953                    &c.mac
954                        .as_deref()
955                        .map(crate::api::format_mac)
956                        .unwrap_or_else(|| "-".into()),
957                ),
958                detail_row("IP", c.ip.as_deref().unwrap_or("-")),
959                detail_row("Type", if c.is_wired { "Wired" } else { "Wireless" }),
960            ];
961
962            if let Some(uptime) = c.uptime {
963                rows.push(detail_row("Uptime", &format_uptime(uptime)));
964            }
965            if let Some(tx) = c.tx_bytes {
966                rows.push(detail_row("TX", &format_bytes(tx)));
967            }
968            if let Some(rx) = c.rx_bytes {
969                rows.push(detail_row("RX", &format_bytes(rx)));
970            }
971            if !c.is_wired {
972                if let Some(signal) = c.signal {
973                    rows.push(detail_row("Signal", &format!("{signal} dBm")));
974                }
975                if let Some(ref ssid) = c.ssid {
976                    rows.push(detail_row("SSID", ssid));
977                }
978                if let Some(ref ap) = c.ap_mac {
979                    rows.push(detail_row("AP", &crate::api::format_mac(ap)));
980                }
981            }
982
983            let widths = [Constraint::Length(10), Constraint::Min(20)];
984            let table = Table::new(rows, widths).block(block);
985            f.render_widget(table, area);
986        }
987        Overlay::DeviceDetail(idx) => {
988            let Some(d) = state.devices.get(*idx) else {
989                return;
990            };
991
992            let name = d.name.as_deref().unwrap_or("Device");
993            let title = format!(" {name} ");
994            let block = Block::default()
995                .borders(Borders::ALL)
996                .border_type(BorderType::Rounded)
997                .border_style(Style::default().fg(ACCENT_COLOR))
998                .style(Style::default().bg(Color::Black))
999                .title(Span::styled(
1000                    title,
1001                    Style::default()
1002                        .fg(ACCENT_COLOR)
1003                        .add_modifier(Modifier::BOLD),
1004                ));
1005
1006            let (state_str, _) = device_state_str(d.state);
1007            let mut rows = vec![
1008                detail_row("Model", d.model.as_deref().unwrap_or("-")),
1009                detail_row(
1010                    "MAC",
1011                    &d.mac
1012                        .as_deref()
1013                        .map(crate::api::format_mac)
1014                        .unwrap_or_else(|| "-".into()),
1015                ),
1016                detail_row("IP", d.ip.as_deref().unwrap_or("-")),
1017                detail_row("State", state_str),
1018            ];
1019
1020            if let Some(ref v) = d.version {
1021                rows.push(detail_row("Firmware", v));
1022            }
1023            if let Some(uptime) = d.uptime {
1024                rows.push(detail_row("Uptime", &format_uptime(uptime)));
1025            }
1026            if let Some(num_sta) = d.num_sta {
1027                rows.push(detail_row("Clients", &num_sta.to_string()));
1028            }
1029
1030            let widths = [Constraint::Length(10), Constraint::Min(20)];
1031            let table = Table::new(rows, widths).block(block);
1032            f.render_widget(table, area);
1033        }
1034    }
1035}
1036
1037fn detail_row(field: &str, value: &str) -> Row<'static> {
1038    Row::new(vec![
1039        Cell::from(field.to_string()).style(
1040            Style::default()
1041                .fg(HEADER_COLOR)
1042                .add_modifier(Modifier::BOLD),
1043        ),
1044        Cell::from(value.to_string()).style(Style::default().fg(Color::White)),
1045    ])
1046}
1047
1048fn draw(f: &mut ratatui::Frame, state: &AppState) {
1049    if state.loading {
1050        let area = f.area();
1051        let block = Block::default()
1052            .borders(Borders::ALL)
1053            .border_type(BorderType::Rounded)
1054            .border_style(Style::default().fg(ACCENT_COLOR));
1055        let text = Paragraph::new(Line::from(Span::styled(
1056            "Connecting to controller…",
1057            Style::default()
1058                .fg(ACCENT_COLOR)
1059                .add_modifier(Modifier::BOLD),
1060        )))
1061        .alignment(Alignment::Center)
1062        .block(block);
1063        let centered = Layout::default()
1064            .direction(Direction::Vertical)
1065            .constraints([
1066                Constraint::Min(0),
1067                Constraint::Length(3),
1068                Constraint::Min(0),
1069            ])
1070            .split(area)[1];
1071        f.render_widget(text, centered);
1072        return;
1073    }
1074
1075    // Devices: 2 borders + 1 header + 1 header gap + data rows, minimum 5
1076    let device_rows = (state.devices.len() + 4).max(5) as u16;
1077    let chunks = Layout::default()
1078        .direction(Direction::Vertical)
1079        .constraints([
1080            Constraint::Length(3),           // header
1081            Constraint::Min(10),             // clients (takes remaining)
1082            Constraint::Length(device_rows), // devices (sized to content)
1083            Constraint::Length(1),           // footer
1084        ])
1085        .split(f.area());
1086
1087    draw_header(f, chunks[0], state);
1088    draw_clients(f, chunks[1], state);
1089    draw_devices(f, chunks[2], state);
1090    draw_footer(f, chunks[3], state);
1091    draw_overlay(f, state);
1092}
1093
1094type FetchResult = Result<
1095    (
1096        Option<SysInfo>,
1097        Option<HostSystem>,
1098        Vec<HealthSubsystem>,
1099        Vec<LegacyClient>,
1100        Vec<LegacyDevice>,
1101    ),
1102    String,
1103>;
1104
1105pub async fn run(api: &UnifiClient, interval_secs: u64) -> Result<(), Box<dyn std::error::Error>> {
1106    // Setup terminal
1107    enable_raw_mode()?;
1108    let mut stdout = io::stdout();
1109    execute!(stdout, EnterAlternateScreen)?;
1110    let backend = CrosstermBackend::new(stdout);
1111    let mut terminal = Terminal::new(backend)?;
1112
1113    let mut state = AppState::new();
1114    let tick_rate = Duration::from_secs(interval_secs);
1115    let mut last_tick = Instant::now() - tick_rate; // Force immediate first fetch
1116
1117    let (tx, mut rx) = tokio::sync::mpsc::channel::<FetchResult>(1);
1118    let (action_tx, mut action_rx) = tokio::sync::mpsc::channel::<Result<String, String>>(4);
1119    let mut fetch_in_progress = false;
1120
1121    let result = loop {
1122        // Kick off background fetch if tick elapsed and no fetch is running
1123        if !fetch_in_progress && last_tick.elapsed() >= tick_rate {
1124            let tx = tx.clone();
1125            let http = api.clone_http();
1126            let base_url = api.base_url().to_string();
1127            fetch_in_progress = true;
1128            state.loading = state.clients.is_empty();
1129            tokio::spawn(async move {
1130                let result = fetch_data_standalone(&http, &base_url).await;
1131                let _ = tx.send(result.map_err(|e| e.to_string())).await;
1132            });
1133        }
1134
1135        // Check for completed fetch (non-blocking)
1136        if let Ok(result) = rx.try_recv() {
1137            fetch_in_progress = false;
1138            state.loading = false;
1139            last_tick = Instant::now();
1140            match result {
1141                Ok((sysinfo, host_system, health, clients, devices)) => {
1142                    state.sysinfo = sysinfo;
1143                    state.host_system = host_system;
1144                    state.health = health;
1145                    state.clients = clients;
1146                    state.devices = devices;
1147                    state.last_error = None;
1148                }
1149                Err(e) => {
1150                    state.last_error = Some(e);
1151                }
1152            }
1153        }
1154
1155        // Check for completed actions (non-blocking)
1156        if let Ok(result) = action_rx.try_recv() {
1157            match result {
1158                Ok(msg) => {
1159                    state.status_msg = Some((msg, Instant::now()));
1160                    // Force refresh after action
1161                    last_tick = Instant::now() - tick_rate;
1162                }
1163                Err(msg) => {
1164                    state.last_error = Some(msg);
1165                }
1166            }
1167        }
1168
1169        // Clear status message after 3 seconds
1170        if let Some((_, t)) = &state.status_msg
1171            && t.elapsed() >= Duration::from_secs(3)
1172        {
1173            state.status_msg = None;
1174        }
1175
1176        // Draw
1177        terminal.draw(|f| draw(f, &state))?;
1178
1179        // Handle events (poll with short timeout for responsiveness)
1180        if event::poll(Duration::from_millis(100))?
1181            && let Event::Key(key) = event::read()?
1182        {
1183            if key.kind != KeyEventKind::Press {
1184                continue;
1185            }
1186
1187            if state.filtering {
1188                match key.code {
1189                    KeyCode::Esc => {
1190                        state.filtering = false;
1191                        state.filter.clear();
1192                    }
1193                    KeyCode::Enter => {
1194                        state.filtering = false;
1195                    }
1196                    KeyCode::Backspace => {
1197                        state.filter.pop();
1198                    }
1199                    KeyCode::Char(c) => {
1200                        state.filter.push(c);
1201                        state.client_scroll = 0;
1202                    }
1203                    _ => {}
1204                }
1205                continue;
1206            }
1207
1208            // Overlay is open: Esc closes it, q quits, action keys
1209            if state.overlay.is_some() {
1210                match key.code {
1211                    KeyCode::Esc => {
1212                        state.overlay = None;
1213                    }
1214                    KeyCode::Char('q') => break Ok(()),
1215                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1216                        break Ok(());
1217                    }
1218                    KeyCode::Char('k') | KeyCode::Char('b') => {
1219                        if let Some(Overlay::ClientDetail(idx)) = &state.overlay {
1220                            let clients = state.sorted_clients();
1221                            if let Some(c) = clients.get(*idx)
1222                                && let Some(ref mac) = c.mac
1223                            {
1224                                let action = match key.code {
1225                                    KeyCode::Char('k') => ClientAction::Kick(mac.clone()),
1226                                    KeyCode::Char('b') => {
1227                                        if c.blocked {
1228                                            ClientAction::Unblock(mac.clone())
1229                                        } else {
1230                                            ClientAction::Block(mac.clone())
1231                                        }
1232                                    }
1233                                    _ => unreachable!(),
1234                                };
1235                                let http = api.clone_http();
1236                                let base_url = api.base_url().to_string();
1237                                let action_tx = action_tx.clone();
1238                                tokio::spawn(async move {
1239                                    let result =
1240                                        execute_client_action(&http, &base_url, action).await;
1241                                    let _ = action_tx.send(result).await;
1242                                });
1243                                state.overlay = None;
1244                            }
1245                        }
1246                    }
1247                    KeyCode::Char('r') | KeyCode::Char('u') | KeyCode::Char('l') => {
1248                        if let Some(Overlay::DeviceDetail(idx)) = &state.overlay
1249                            && let Some(d) = state.devices.get(*idx)
1250                            && let Some(ref mac) = d.mac
1251                        {
1252                            let action = match key.code {
1253                                KeyCode::Char('r') => DeviceAction::Restart(mac.clone()),
1254                                KeyCode::Char('u') => DeviceAction::Upgrade(mac.clone()),
1255                                KeyCode::Char('l') => {
1256                                    let normalized = crate::api::normalize_mac(mac);
1257                                    let currently_locating =
1258                                        state.locating.get(&normalized).copied().unwrap_or(false);
1259                                    state.locating.insert(normalized, !currently_locating);
1260                                    DeviceAction::Locate(mac.clone(), !currently_locating)
1261                                }
1262                                _ => unreachable!(),
1263                            };
1264                            let close_overlay = !matches!(key.code, KeyCode::Char('l'));
1265                            let http = api.clone_http();
1266                            let base_url = api.base_url().to_string();
1267                            let action_tx = action_tx.clone();
1268                            tokio::spawn(async move {
1269                                let result = execute_device_action(&http, &base_url, action).await;
1270                                let _ = action_tx.send(result).await;
1271                            });
1272                            if close_overlay {
1273                                state.overlay = None;
1274                            }
1275                        }
1276                    }
1277                    _ => {}
1278                }
1279                continue;
1280            }
1281
1282            match key.code {
1283                KeyCode::Char('q') => break Ok(()),
1284                KeyCode::Esc => break Ok(()),
1285                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
1286                KeyCode::Enter => {
1287                    let overlay = match state.focus {
1288                        Panel::Clients => {
1289                            let clients = state.sorted_clients();
1290                            if !clients.is_empty() {
1291                                Some(Overlay::ClientDetail(state.client_scroll))
1292                            } else {
1293                                None
1294                            }
1295                        }
1296                        Panel::Devices => {
1297                            if !state.devices.is_empty() {
1298                                Some(Overlay::DeviceDetail(state.device_scroll))
1299                            } else {
1300                                None
1301                            }
1302                        }
1303                    };
1304                    state.overlay = overlay;
1305                }
1306                KeyCode::Tab => {
1307                    state.focus = match state.focus {
1308                        Panel::Clients => Panel::Devices,
1309                        Panel::Devices => Panel::Clients,
1310                    };
1311                }
1312                KeyCode::Char('s') => {
1313                    state.sort = state.sort.next();
1314                }
1315                KeyCode::Char('/') => {
1316                    state.filtering = true;
1317                    state.filter.clear();
1318                }
1319                KeyCode::Up | KeyCode::Char('k') => {
1320                    state.scroll_up();
1321                }
1322                KeyCode::Down | KeyCode::Char('j') => {
1323                    let max_c = state.sorted_clients().len();
1324                    let max_d = state.devices.len();
1325                    state.scroll_down(max_c, max_d);
1326                }
1327                _ => {}
1328            }
1329        }
1330    };
1331
1332    // Restore terminal
1333    disable_raw_mode()?;
1334    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1335    terminal.show_cursor()?;
1336
1337    result
1338}
1339
1340// --- Ports Live TUI ---
1341
1342use crate::api::{DeviceWithPorts, PortEntry};
1343
1344struct PortsState {
1345    device: Option<DeviceWithPorts>,
1346    prev_bytes: HashMap<u32, (u64, u64, Instant)>,
1347    port_rates: HashMap<u32, (f64, f64)>,
1348    scroll: usize,
1349    interval_secs: u64,
1350    last_error: Option<String>,
1351}
1352
1353impl PortsState {
1354    fn new(interval_secs: u64) -> Self {
1355        Self {
1356            device: None,
1357            prev_bytes: HashMap::new(),
1358            port_rates: HashMap::new(),
1359            scroll: 0,
1360            interval_secs,
1361            last_error: None,
1362        }
1363    }
1364
1365    fn update_port_rates(&mut self) {
1366        let now = Instant::now();
1367        let ports = match &self.device {
1368            Some(d) => &d.port_table,
1369            None => return,
1370        };
1371
1372        for port in ports {
1373            let idx = match port.port_idx {
1374                Some(i) => i,
1375                None => continue,
1376            };
1377            let tx = port.tx_bytes.unwrap_or(0);
1378            let rx = port.rx_bytes.unwrap_or(0);
1379
1380            if let Some((prev_tx, prev_rx, prev_time)) = self.prev_bytes.get(&idx) {
1381                let elapsed = now.duration_since(*prev_time).as_secs_f64();
1382                if elapsed > 0.1 {
1383                    let tx_rate = if tx >= *prev_tx {
1384                        (tx - prev_tx) as f64 / elapsed
1385                    } else {
1386                        0.0
1387                    };
1388                    let rx_rate = if rx >= *prev_rx {
1389                        (rx - prev_rx) as f64 / elapsed
1390                    } else {
1391                        0.0
1392                    };
1393                    self.port_rates.insert(idx, (tx_rate, rx_rate));
1394                }
1395            }
1396
1397            self.prev_bytes.insert(idx, (tx, rx, now));
1398        }
1399    }
1400}
1401
1402fn port_link_color(port: &PortEntry) -> Color {
1403    if port.up {
1404        match port.speed {
1405            Some(s) if s >= 2500 => Color::Green,
1406            Some(s) if s >= 1000 => Color::Cyan,
1407            Some(_) => Color::Yellow,
1408            None => Color::White,
1409        }
1410    } else {
1411        DIM_COLOR
1412    }
1413}
1414
1415fn draw_ports(f: &mut ratatui::Frame, state: &PortsState) {
1416    let chunks = Layout::default()
1417        .direction(Direction::Vertical)
1418        .constraints([
1419            Constraint::Min(10),   // port table
1420            Constraint::Length(1), // footer
1421        ])
1422        .split(f.area());
1423
1424    let device_name = state
1425        .device
1426        .as_ref()
1427        .and_then(|d| d.name.as_deref())
1428        .unwrap_or("Device");
1429    let port_count = state
1430        .device
1431        .as_ref()
1432        .map(|d| d.port_table.len())
1433        .unwrap_or(0);
1434
1435    let block = Block::default()
1436        .borders(Borders::ALL)
1437        .border_type(BorderType::Rounded)
1438        .border_style(Style::default().fg(ACCENT_COLOR))
1439        .title(Span::styled(
1440            format!(" {device_name} \u{2502} {port_count} ports "),
1441            Style::default()
1442                .fg(ACCENT_COLOR)
1443                .add_modifier(Modifier::BOLD),
1444        ));
1445
1446    let header = Row::new(vec![
1447        Cell::from("Port").style(
1448            Style::default()
1449                .fg(HEADER_COLOR)
1450                .add_modifier(Modifier::BOLD),
1451        ),
1452        Cell::from("Name").style(
1453            Style::default()
1454                .fg(HEADER_COLOR)
1455                .add_modifier(Modifier::BOLD),
1456        ),
1457        Cell::from("Link").style(
1458            Style::default()
1459                .fg(HEADER_COLOR)
1460                .add_modifier(Modifier::BOLD),
1461        ),
1462        Cell::from("Speed").style(
1463            Style::default()
1464                .fg(HEADER_COLOR)
1465                .add_modifier(Modifier::BOLD),
1466        ),
1467        Cell::from("PoE").style(
1468            Style::default()
1469                .fg(HEADER_COLOR)
1470                .add_modifier(Modifier::BOLD),
1471        ),
1472        Cell::from("TX/s").style(
1473            Style::default()
1474                .fg(HEADER_COLOR)
1475                .add_modifier(Modifier::BOLD),
1476        ),
1477        Cell::from("RX/s").style(
1478            Style::default()
1479                .fg(HEADER_COLOR)
1480                .add_modifier(Modifier::BOLD),
1481        ),
1482        Cell::from("TX Total").style(
1483            Style::default()
1484                .fg(HEADER_COLOR)
1485                .add_modifier(Modifier::BOLD),
1486        ),
1487        Cell::from("RX Total").style(
1488            Style::default()
1489                .fg(HEADER_COLOR)
1490                .add_modifier(Modifier::BOLD),
1491        ),
1492    ])
1493    .height(1);
1494
1495    let inner_height = chunks[0].height.saturating_sub(4) as usize;
1496    let ports = state
1497        .device
1498        .as_ref()
1499        .map(|d| &d.port_table[..])
1500        .unwrap_or(&[]);
1501
1502    let rows: Vec<Row> = ports
1503        .iter()
1504        .skip(state.scroll)
1505        .take(inner_height)
1506        .map(|p| {
1507            let idx = p.port_idx.unwrap_or(0);
1508            let link_color = port_link_color(p);
1509            let (tx_rate, rx_rate) = state.port_rates.get(&idx).copied().unwrap_or((0.0, 0.0));
1510
1511            let link_str = if p.up { "\u{25cf} up" } else { "\u{25cb} down" };
1512
1513            let speed_str = if p.up {
1514                match p.speed {
1515                    Some(s) => {
1516                        let duplex = if p.full_duplex { "FD" } else { "HD" };
1517                        format!("{s} {duplex}")
1518                    }
1519                    None => "up".into(),
1520                }
1521            } else {
1522                "-".into()
1523            };
1524
1525            let poe_str = if p.poe_enable {
1526                match p.poe_power {
1527                    Some(w) if w > 0.0 => format!("{w:.1}W"),
1528                    _ => "on".into(),
1529                }
1530            } else if p.port_poe {
1531                "off".into()
1532            } else {
1533                "-".into()
1534            };
1535
1536            let poe_color = if p.poe_enable && p.poe_power.is_some_and(|w| w > 0.0) {
1537                Color::Yellow
1538            } else {
1539                DIM_COLOR
1540            };
1541
1542            Row::new(vec![
1543                Cell::from(idx.to_string()).style(
1544                    Style::default()
1545                        .fg(Color::White)
1546                        .add_modifier(Modifier::BOLD),
1547                ),
1548                Cell::from(p.name.as_deref().unwrap_or("-").to_string())
1549                    .style(Style::default().fg(Color::White)),
1550                Cell::from(link_str).style(Style::default().fg(link_color)),
1551                Cell::from(speed_str).style(Style::default().fg(link_color)),
1552                Cell::from(poe_str).style(Style::default().fg(poe_color)),
1553                Cell::from(format_rate(tx_rate)).style(Style::default().fg(if tx_rate >= 1024.0 {
1554                    Color::Green
1555                } else {
1556                    DIM_COLOR
1557                })),
1558                Cell::from(format_rate(rx_rate)).style(Style::default().fg(if rx_rate >= 1024.0 {
1559                    Color::Green
1560                } else {
1561                    DIM_COLOR
1562                })),
1563                Cell::from(p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
1564                    .style(Style::default().fg(DIM_COLOR)),
1565                Cell::from(p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
1566                    .style(Style::default().fg(DIM_COLOR)),
1567            ])
1568        })
1569        .collect();
1570
1571    let widths = [
1572        Constraint::Length(5),
1573        Constraint::Min(14),
1574        Constraint::Length(8),
1575        Constraint::Length(10),
1576        Constraint::Length(8),
1577        Constraint::Length(12),
1578        Constraint::Length(12),
1579        Constraint::Length(10),
1580        Constraint::Length(10),
1581    ];
1582
1583    if ports.is_empty() {
1584        let empty = Paragraph::new(Line::from(Span::styled(
1585            "No ports found (not a switch or router)",
1586            Style::default().fg(DIM_COLOR),
1587        )))
1588        .block(block)
1589        .alignment(Alignment::Center);
1590        f.render_widget(empty, chunks[0]);
1591    } else {
1592        let table = Table::new(rows, widths)
1593            .header(header)
1594            .block(block)
1595            .row_highlight_style(Style::default().bg(SELECTED_BG));
1596        f.render_widget(table, chunks[0]);
1597    }
1598
1599    // Footer
1600    let error_span = if let Some(ref err) = state.last_error {
1601        Span::styled(
1602            format!(" \u{26a0} {err} "),
1603            Style::default().fg(OFFLINE_COLOR),
1604        )
1605    } else {
1606        Span::raw("")
1607    };
1608
1609    let footer = Line::from(vec![
1610        Span::styled(
1611            " q",
1612            Style::default()
1613                .fg(ACCENT_COLOR)
1614                .add_modifier(Modifier::BOLD),
1615        ),
1616        Span::styled(" quit  ", Style::default().fg(DIM_COLOR)),
1617        Span::styled(
1618            "\u{2191}\u{2193}",
1619            Style::default()
1620                .fg(ACCENT_COLOR)
1621                .add_modifier(Modifier::BOLD),
1622        ),
1623        Span::styled(" scroll", Style::default().fg(DIM_COLOR)),
1624        error_span,
1625        Span::raw("  "),
1626        Span::styled(
1627            format!("\u{21bb} {}s", state.interval_secs),
1628            Style::default().fg(DIM_COLOR),
1629        ),
1630    ]);
1631    f.render_widget(Paragraph::new(footer), chunks[1]);
1632}
1633
1634pub async fn run_ports(
1635    api: &UnifiClient,
1636    mac: &str,
1637    interval_secs: u64,
1638) -> Result<(), Box<dyn std::error::Error>> {
1639    enable_raw_mode()?;
1640    let mut stdout = io::stdout();
1641    execute!(stdout, EnterAlternateScreen)?;
1642    let backend = CrosstermBackend::new(stdout);
1643    let mut terminal = Terminal::new(backend)?;
1644
1645    let mut state = PortsState::new(interval_secs);
1646    let tick_rate = Duration::from_secs(interval_secs);
1647    let mut last_tick = Instant::now() - tick_rate;
1648
1649    let result = loop {
1650        if last_tick.elapsed() >= tick_rate {
1651            match api.get_device_ports(mac).await {
1652                Ok(device) => {
1653                    state.device = Some(device);
1654                    state.update_port_rates();
1655                    state.last_error = None;
1656                }
1657                Err(e) => {
1658                    state.last_error = Some(e.to_string());
1659                }
1660            }
1661            last_tick = Instant::now();
1662        }
1663
1664        terminal.draw(|f| draw_ports(f, &state))?;
1665
1666        if event::poll(Duration::from_millis(100))?
1667            && let Event::Key(key) = event::read()?
1668        {
1669            if key.kind != KeyEventKind::Press {
1670                continue;
1671            }
1672
1673            match key.code {
1674                KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
1675                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
1676                KeyCode::Up | KeyCode::Char('k') => {
1677                    state.scroll = state.scroll.saturating_sub(1);
1678                }
1679                KeyCode::Down | KeyCode::Char('j') => {
1680                    let max = state
1681                        .device
1682                        .as_ref()
1683                        .map(|d| d.port_table.len())
1684                        .unwrap_or(0);
1685                    if state.scroll + 1 < max {
1686                        state.scroll += 1;
1687                    }
1688                }
1689                _ => {}
1690            }
1691        }
1692    };
1693
1694    disable_raw_mode()?;
1695    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1696    terminal.show_cursor()?;
1697
1698    result
1699}
1700
1701#[cfg(test)]
1702mod tests {
1703    use super::*;
1704
1705    #[test]
1706    fn format_rate_zero() {
1707        assert_eq!(format_rate(0.0), "0 B/s");
1708    }
1709
1710    #[test]
1711    fn format_rate_bytes() {
1712        assert_eq!(format_rate(512.0), "512 B/s");
1713    }
1714
1715    #[test]
1716    fn format_rate_kilobytes() {
1717        assert_eq!(format_rate(10240.0), "10.0 KB/s");
1718    }
1719
1720    #[test]
1721    fn format_rate_megabytes() {
1722        assert_eq!(format_rate(5_242_880.0), "5.0 MB/s");
1723    }
1724
1725    #[test]
1726    fn format_rate_gigabytes() {
1727        assert_eq!(format_rate(1_073_741_824.0), "1.0 GB/s");
1728    }
1729
1730    #[test]
1731    fn ip_sort_key_ordering() {
1732        let mut ips = vec!["10.0.0.2", "10.0.0.10", "10.0.0.1", "192.168.1.1"];
1733        ips.sort_by_key(|ip| ip_sort_key(ip));
1734        assert_eq!(
1735            ips,
1736            vec!["10.0.0.1", "10.0.0.2", "10.0.0.10", "192.168.1.1"]
1737        );
1738    }
1739
1740    #[test]
1741    fn sort_mode_cycles() {
1742        assert_eq!(SortMode::Bandwidth.next(), SortMode::Name);
1743        assert_eq!(SortMode::Name.next(), SortMode::Ip);
1744        assert_eq!(SortMode::Ip.next(), SortMode::Bandwidth);
1745    }
1746
1747    #[test]
1748    fn app_state_scroll_bounds() {
1749        let mut state = AppState::new();
1750        state.scroll_up();
1751        assert_eq!(state.client_scroll, 0);
1752
1753        state.scroll_down(3, 2);
1754        assert_eq!(state.client_scroll, 1);
1755        state.scroll_down(3, 2);
1756        assert_eq!(state.client_scroll, 2);
1757        state.scroll_down(3, 2);
1758        assert_eq!(state.client_scroll, 2); // capped
1759
1760        state.scroll_up();
1761        assert_eq!(state.client_scroll, 1);
1762    }
1763
1764    #[test]
1765    fn device_state_str_values() {
1766        assert_eq!(device_state_str(Some(1)).0, "ONLINE");
1767        assert_eq!(device_state_str(Some(0)).0, "OFFLINE");
1768        assert_eq!(device_state_str(Some(2)).0, "ADOPTING");
1769        assert_eq!(device_state_str(None).0, "UNKNOWN");
1770    }
1771}