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),
63    DeviceDetail(usize),
64    ApPicker {
65        client_idx: usize,
66        ap_cursor: usize,
67    },
68    Confirm {
69        message: String,
70        action: PendingAction,
71    },
72}
73
74enum PendingAction {
75    Client(ClientAction),
76    Device(DeviceAction),
77}
78
79enum ClientAction {
80    Kick(String),                             // MAC
81    Block(String),                            // MAC
82    Unblock(String),                          // MAC
83    LockToAp { mac: String, ap_mac: String }, // Lock client to AP
84    UnlockFromAp(String),                     // Unlock client from AP
85}
86
87enum DeviceAction {
88    Restart(String),      // MAC
89    Upgrade(String),      // MAC
90    Locate(String, bool), // MAC, enable
91}
92
93struct AppState {
94    sysinfo: Option<SysInfo>,
95    host_system: Option<HostSystem>,
96    health: Vec<HealthSubsystem>,
97    clients: Vec<LegacyClient>,
98    devices: Vec<LegacyDevice>,
99    device_names: HashMap<String, String>, // normalized MAC -> device name
100    focus: Panel,
101    sort: SortMode,
102    client_cursor: usize,
103    client_offset: usize,
104    device_scroll: usize,
105    filter: String,
106    filtering: bool,
107    overlay: Option<Overlay>,
108    loading: bool,
109    last_error: Option<String>,
110    status_msg: Option<(String, Instant)>,
111    locating: HashMap<String, bool>,
112}
113
114impl AppState {
115    fn new() -> Self {
116        Self {
117            sysinfo: None,
118            host_system: None,
119            health: Vec::new(),
120            clients: Vec::new(),
121            devices: Vec::new(),
122            device_names: HashMap::new(),
123            focus: Panel::Clients,
124            sort: SortMode::Bandwidth,
125            client_cursor: 0,
126            client_offset: 0,
127            device_scroll: 0,
128            filter: String::new(),
129            filtering: false,
130            overlay: None,
131            loading: true,
132            last_error: None,
133            status_msg: None,
134            locating: HashMap::new(),
135        }
136    }
137
138    fn rebuild_device_names(&mut self) {
139        self.device_names = self
140            .devices
141            .iter()
142            .filter_map(|d| {
143                let mac = crate::api::normalize_mac(d.mac.as_deref()?);
144                let name = d.name.as_deref()?.to_string();
145                Some((mac, name))
146            })
147            .collect();
148    }
149
150    fn resolve_device_name(&self, mac: &str) -> Option<&str> {
151        self.device_names
152            .get(&crate::api::normalize_mac(mac))
153            .map(|s| s.as_str())
154    }
155
156    fn sorted_clients(&self) -> Vec<&LegacyClient> {
157        let mut clients: Vec<&LegacyClient> = self
158            .clients
159            .iter()
160            .filter(|c| {
161                if self.filter.is_empty() {
162                    return true;
163                }
164                let needle = self.filter.to_lowercase();
165                let name = c.display_name().to_lowercase();
166                let ip = c.ip.as_deref().unwrap_or("").to_lowercase();
167                let mac = c.mac.as_deref().unwrap_or("").to_lowercase();
168                name.contains(&needle) || ip.contains(&needle) || mac.contains(&needle)
169            })
170            .collect();
171
172        match self.sort {
173            SortMode::Bandwidth => {
174                clients.sort_by(|a, b| {
175                    let total_a = a.tx_bytes.unwrap_or(0) + a.rx_bytes.unwrap_or(0);
176                    let total_b = b.tx_bytes.unwrap_or(0) + b.rx_bytes.unwrap_or(0);
177                    total_b.cmp(&total_a)
178                });
179            }
180            SortMode::Name => {
181                clients.sort_by_key(|c| c.display_name().to_lowercase());
182            }
183            SortMode::Ip => {
184                clients.sort_by(|a, b| {
185                    let ip_a = a.ip.as_deref().unwrap_or("255.255.255.255");
186                    let ip_b = b.ip.as_deref().unwrap_or("255.255.255.255");
187                    ip_sort_key(ip_a).cmp(&ip_sort_key(ip_b))
188                });
189            }
190        }
191
192        clients
193    }
194
195    fn ap_devices(&self) -> Vec<&LegacyDevice> {
196        self.devices
197            .iter()
198            .filter(|d| d.device_type.as_deref().is_some_and(|t| t == "uap"))
199            .collect()
200    }
201
202    fn cursor_up(&mut self) {
203        match self.focus {
204            Panel::Clients => {
205                self.client_cursor = self.client_cursor.saturating_sub(1);
206            }
207            Panel::Devices => {
208                self.device_scroll = self.device_scroll.saturating_sub(1);
209            }
210        }
211    }
212
213    fn cursor_down(&mut self, max_clients: usize, max_devices: usize) {
214        match self.focus {
215            Panel::Clients => {
216                if self.client_cursor + 1 < max_clients {
217                    self.client_cursor += 1;
218                }
219            }
220            Panel::Devices => {
221                if self.device_scroll + 1 < max_devices {
222                    self.device_scroll += 1;
223                }
224            }
225        }
226    }
227
228    fn page_up(&mut self, page_size: usize) {
229        match self.focus {
230            Panel::Clients => {
231                self.client_cursor = self.client_cursor.saturating_sub(page_size);
232            }
233            Panel::Devices => {
234                self.device_scroll = self.device_scroll.saturating_sub(page_size);
235            }
236        }
237    }
238
239    fn page_down(&mut self, max_clients: usize, max_devices: usize, page_size: usize) {
240        match self.focus {
241            Panel::Clients => {
242                let max = max_clients.saturating_sub(1);
243                self.client_cursor = (self.client_cursor + page_size).min(max);
244            }
245            Panel::Devices => {
246                let max = max_devices.saturating_sub(1);
247                self.device_scroll = (self.device_scroll + page_size).min(max);
248            }
249        }
250    }
251
252    /// Adjust client_offset so that client_cursor is visible within visible_height rows
253    fn ensure_client_visible(&mut self, visible_height: usize) {
254        if visible_height == 0 {
255            return;
256        }
257        if self.client_cursor < self.client_offset {
258            self.client_offset = self.client_cursor;
259        } else if self.client_cursor >= self.client_offset + visible_height {
260            self.client_offset = self.client_cursor - visible_height + 1;
261        }
262    }
263}
264
265fn ip_sort_key(ip: &str) -> Vec<u32> {
266    ip.split('.')
267        .filter_map(|s| s.parse::<u32>().ok())
268        .collect()
269}
270
271fn format_rate(bytes_per_sec: f64) -> String {
272    if bytes_per_sec >= 1_073_741_824.0 {
273        format!("{:.1} GB/s", bytes_per_sec / 1_073_741_824.0)
274    } else if bytes_per_sec >= 1_048_576.0 {
275        format!("{:.1} MB/s", bytes_per_sec / 1_048_576.0)
276    } else if bytes_per_sec >= 1024.0 {
277        format!("{:.1} KB/s", bytes_per_sec / 1024.0)
278    } else if bytes_per_sec >= 1.0 {
279        format!("{:.0} B/s", bytes_per_sec)
280    } else {
281        "0 B/s".into()
282    }
283}
284
285fn signal_bar(dbm: i32) -> &'static str {
286    match dbm {
287        -50..=0 => "▂▄▆█",
288        -60..=-51 => "▂▄▆░",
289        -70..=-61 => "▂▄░░",
290        -80..=-71 => "▂░░░",
291        _ => "░░░░",
292    }
293}
294
295fn signal_color(dbm: i32) -> Color {
296    match dbm {
297        -50..=0 => ONLINE_COLOR,
298        -60..=-51 => ONLINE_COLOR,
299        -70..=-61 => WARN_COLOR,
300        _ => OFFLINE_COLOR,
301    }
302}
303
304fn status_color(status: &str) -> Color {
305    match status {
306        "ok" => ONLINE_COLOR,
307        "unknown" => DIM_COLOR,
308        _ => WARN_COLOR,
309    }
310}
311
312fn device_state_str(state: Option<u32>) -> (&'static str, Color) {
313    match state {
314        Some(1) => ("ONLINE", ONLINE_COLOR),
315        Some(0) => ("OFFLINE", OFFLINE_COLOR),
316        Some(2) => ("ADOPTING", WARN_COLOR),
317        Some(4) => ("UPGRADING", WARN_COLOR),
318        Some(5) => ("PROVISIONING", WARN_COLOR),
319        _ => ("UNKNOWN", DIM_COLOR),
320    }
321}
322
323async fn fetch_data_standalone(
324    http: &reqwest::Client,
325    base_url: &str,
326) -> Result<
327    (
328        Option<SysInfo>,
329        Option<HostSystem>,
330        Vec<HealthSubsystem>,
331        Vec<LegacyClient>,
332        Vec<LegacyDevice>,
333    ),
334    ApiError,
335> {
336    let sysinfo: Option<SysInfo> = legacy_get(http, base_url, "/stat/sysinfo")
337        .await
338        .ok()
339        .and_then(|mut v: Vec<SysInfo>| v.pop());
340
341    let host_system: Option<HostSystem> = async {
342        let url = format!("{base_url}/api/system");
343        let resp = http.get(&url).send().await.ok()?;
344        if !resp.status().is_success() {
345            return None;
346        }
347        resp.json::<HostSystem>().await.ok()
348    }
349    .await;
350
351    let health: Vec<HealthSubsystem> = legacy_get(http, base_url, "/stat/health")
352        .await
353        .unwrap_or_default();
354    let clients: Vec<LegacyClient> = legacy_get(http, base_url, "/stat/sta").await?;
355    let devices: Vec<LegacyDevice> = legacy_get(http, base_url, "/stat/device")
356        .await
357        .unwrap_or_default();
358
359    Ok((sysinfo, host_system, health, clients, devices))
360}
361
362async fn legacy_get<T: serde::de::DeserializeOwned>(
363    http: &reqwest::Client,
364    base_url: &str,
365    path: &str,
366) -> Result<Vec<T>, ApiError> {
367    use crate::api::types::LegacyResponse;
368    let url = format!("{base_url}/proxy/network/api/s/default{path}");
369    let resp = http.get(&url).send().await?;
370    let status = resp.status().as_u16();
371    if !resp.status().is_success() {
372        let body = resp.text().await.unwrap_or_default();
373        return Err(UnifiClient::error_for_status_pub(status, body));
374    }
375    let legacy: LegacyResponse<T> = resp.json().await?;
376    if legacy.meta.rc != "ok" {
377        return Err(ApiError::Api {
378            status: 200,
379            message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
380        });
381    }
382    Ok(legacy.data)
383}
384
385async fn legacy_put(
386    http: &reqwest::Client,
387    base_url: &str,
388    path: &str,
389    body: &serde_json::Value,
390) -> Result<(), String> {
391    let url = format!("{base_url}/proxy/network/api/s/default{path}");
392    let resp = http
393        .put(&url)
394        .json(body)
395        .send()
396        .await
397        .map_err(|e| e.to_string())?;
398    if !resp.status().is_success() {
399        let status = resp.status().as_u16();
400        let body = resp.text().await.unwrap_or_default();
401        return Err(format!("API error ({status}): {body}"));
402    }
403    Ok(())
404}
405
406async fn find_client_id(
407    http: &reqwest::Client,
408    base_url: &str,
409    mac: &str,
410) -> Result<String, String> {
411    let normalized = crate::api::normalize_mac(mac);
412    let clients: Vec<LegacyClient> = legacy_get(http, base_url, "/stat/sta")
413        .await
414        .map_err(|e| e.to_string())?;
415    clients
416        .into_iter()
417        .find(|c| {
418            c.mac
419                .as_deref()
420                .is_some_and(|m| crate::api::normalize_mac(m) == normalized)
421        })
422        .map(|c| c.id)
423        .ok_or_else(|| format!("Client {mac} not found"))
424}
425
426async fn legacy_post_cmd(
427    http: &reqwest::Client,
428    base_url: &str,
429    manager: &str,
430    body: serde_json::Value,
431) -> Result<(), String> {
432    let url = format!("{base_url}/proxy/network/api/s/default/cmd/{manager}");
433    let resp = http
434        .post(&url)
435        .json(&body)
436        .send()
437        .await
438        .map_err(|e| e.to_string())?;
439    if !resp.status().is_success() {
440        let body = resp.text().await.unwrap_or_default();
441        return Err(format!("API error: {body}"));
442    }
443    Ok(())
444}
445
446async fn execute_client_action(
447    http: &reqwest::Client,
448    base_url: &str,
449    action: ClientAction,
450) -> Result<String, String> {
451    match action {
452        ClientAction::Kick(mac) => {
453            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
454            legacy_post_cmd(
455                http,
456                base_url,
457                "stamgr",
458                serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
459            )
460            .await?;
461            Ok(format!("Kicked {formatted}"))
462        }
463        ClientAction::Block(mac) => {
464            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
465            legacy_post_cmd(
466                http,
467                base_url,
468                "stamgr",
469                serde_json::json!({"cmd": "block-sta", "mac": formatted}),
470            )
471            .await?;
472            Ok(format!("Blocked {formatted}"))
473        }
474        ClientAction::Unblock(mac) => {
475            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
476            legacy_post_cmd(
477                http,
478                base_url,
479                "stamgr",
480                serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
481            )
482            .await?;
483            Ok(format!("Unblocked {formatted}"))
484        }
485        ClientAction::LockToAp { mac, ap_mac } => {
486            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
487            let ap_formatted = crate::api::format_mac(&crate::api::normalize_mac(&ap_mac));
488            let client_id = find_client_id(http, base_url, &mac).await?;
489            let payload = serde_json::json!({
490                "mac": formatted,
491                "fixed_ap_enabled": true,
492                "fixed_ap_mac": ap_formatted,
493            });
494            legacy_put(http, base_url, &format!("/rest/user/{client_id}"), &payload).await?;
495            Ok(format!("Locked to AP {ap_formatted}"))
496        }
497        ClientAction::UnlockFromAp(mac) => {
498            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
499            let client_id = find_client_id(http, base_url, &mac).await?;
500            let payload = serde_json::json!({
501                "mac": formatted,
502                "fixed_ap_enabled": false,
503            });
504            legacy_put(http, base_url, &format!("/rest/user/{client_id}"), &payload).await?;
505            Ok("Unlocked from AP".to_string())
506        }
507    }
508}
509
510async fn execute_device_action(
511    http: &reqwest::Client,
512    base_url: &str,
513    action: DeviceAction,
514) -> Result<String, String> {
515    match action {
516        DeviceAction::Restart(mac) => {
517            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
518            legacy_post_cmd(
519                http,
520                base_url,
521                "devmgr",
522                serde_json::json!({"cmd": "restart", "mac": formatted}),
523            )
524            .await?;
525            Ok(format!("Restarting {formatted}"))
526        }
527        DeviceAction::Upgrade(mac) => {
528            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
529            legacy_post_cmd(
530                http,
531                base_url,
532                "devmgr",
533                serde_json::json!({"cmd": "upgrade", "mac": formatted}),
534            )
535            .await?;
536            Ok(format!("Upgrading {formatted}"))
537        }
538        DeviceAction::Locate(mac, enable) => {
539            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
540            let cmd = if enable { "set-locate" } else { "unset-locate" };
541            legacy_post_cmd(
542                http,
543                base_url,
544                "devmgr",
545                serde_json::json!({"cmd": cmd, "mac": formatted}),
546            )
547            .await?;
548            let action_str = if enable {
549                "Locating"
550            } else {
551                "Stopped locating"
552            };
553            Ok(format!("{action_str} {formatted}"))
554        }
555    }
556}
557
558fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
559    let info = state.sysinfo.as_ref();
560    let hostname = info
561        .and_then(|s| s.hostname.as_deref())
562        .unwrap_or("UniFi Controller");
563    let version = info.and_then(|s| s.version.as_deref()).unwrap_or("-");
564    let uptime_str = info
565        .and_then(|s| s.uptime)
566        .map(format_uptime)
567        .unwrap_or_else(|| "-".into());
568
569    let title = format!(" {} v{} │ Up {} ", hostname, version, uptime_str);
570
571    // Build health spans
572    let mut health_spans: Vec<Span> = vec![Span::raw("  ")];
573    for h in &state.health {
574        let color = status_color(h.status.as_deref().unwrap_or("unknown"));
575        let bullet = Span::styled("● ", Style::default().fg(color));
576        let sub = h.subsystem.to_uppercase();
577        let detail = match h.subsystem.as_str() {
578            "wan" => h
579                .wan_ip
580                .as_deref()
581                .map(|ip| format!(" ({ip})"))
582                .unwrap_or_default(),
583            "wlan" => {
584                let ap = h.num_ap.unwrap_or(0);
585                let sta = h.num_sta.unwrap_or(0);
586                format!(" ({ap} AP, {sta} sta)")
587            }
588            "lan" => {
589                let sw = h.num_switches.unwrap_or(0);
590                let sta = h.num_sta.unwrap_or(0);
591                format!(" ({sw} sw, {sta} sta)")
592            }
593            _ => String::new(),
594        };
595        health_spans.push(bullet);
596        health_spans.push(Span::styled(
597            format!("{sub}{detail}"),
598            Style::default().fg(Color::White),
599        ));
600        health_spans.push(Span::raw("  "));
601    }
602
603    if state
604        .host_system
605        .as_ref()
606        .is_some_and(|h| h.update_available())
607    {
608        health_spans.push(Span::styled(
609            "⬆ Update available",
610            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
611        ));
612    }
613
614    let block = Block::default()
615        .borders(Borders::ALL)
616        .border_type(BorderType::Rounded)
617        .border_style(Style::default().fg(HEADER_COLOR))
618        .title(Span::styled(
619            title,
620            Style::default()
621                .fg(HEADER_COLOR)
622                .add_modifier(Modifier::BOLD),
623        ));
624
625    let health_line = Line::from(health_spans);
626    let paragraph = Paragraph::new(health_line).block(block);
627    f.render_widget(paragraph, area);
628}
629
630fn draw_clients(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
631    let clients = state.sorted_clients();
632    let is_focused = state.focus == Panel::Clients;
633
634    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
635
636    let filter_info = if !state.filter.is_empty() {
637        format!(" │ filter: {}", state.filter)
638    } else {
639        String::new()
640    };
641
642    let pos_info = if !clients.is_empty() {
643        format!(" [{}/{}]", state.client_cursor + 1, clients.len())
644    } else {
645        String::new()
646    };
647
648    let title = format!(
649        " Clients ({}){} │ sort: {}{} ",
650        clients.len(),
651        pos_info,
652        state.sort.label(),
653        filter_info,
654    );
655
656    let block = Block::default()
657        .borders(Borders::ALL)
658        .border_type(BorderType::Rounded)
659        .border_style(Style::default().fg(border_color))
660        .title(Span::styled(
661            title,
662            Style::default()
663                .fg(border_color)
664                .add_modifier(Modifier::BOLD),
665        ));
666
667    let header_style = Style::default()
668        .fg(HEADER_COLOR)
669        .add_modifier(Modifier::BOLD);
670
671    let header = Row::new(vec![
672        Cell::from("Name").style(header_style),
673        Cell::from("Connection").style(header_style),
674        Cell::from("Signal").style(header_style),
675        Cell::from("IP").style(header_style),
676        Cell::from("Total").style(header_style),
677    ])
678    .height(1);
679
680    // Calculate visible area (subtract borders + header)
681    let inner_height = area.height.saturating_sub(4) as usize;
682
683    let rows: Vec<Row> = clients
684        .iter()
685        .enumerate()
686        .skip(state.client_offset)
687        .take(inner_height)
688        .map(|(i, c)| {
689            let total_bytes = c.tx_bytes.unwrap_or(0) + c.rx_bytes.unwrap_or(0);
690            let is_idle = total_bytes == 0;
691
692            let type_icon = if c.is_wired { "⌐ " } else { "◦ " };
693
694            // Show full MAC for unnamed clients
695            let display = if c.display_name() == "-" {
696                c.mac
697                    .as_deref()
698                    .map(crate::api::format_mac)
699                    .unwrap_or_else(|| "-".into())
700            } else {
701                c.display_name().to_string()
702            };
703            let name = format!("{type_icon}{display}");
704
705            let name_style = if is_idle {
706                Style::default().fg(DIM_COLOR)
707            } else {
708                Style::default()
709                    .fg(Color::White)
710                    .add_modifier(Modifier::BOLD)
711            };
712
713            let is_selected = is_focused && i == state.client_cursor;
714            let row_style = if is_selected {
715                Style::default().bg(SELECTED_BG)
716            } else {
717                Style::default()
718            };
719
720            let total_style = if is_idle {
721                Style::default().fg(DIM_COLOR)
722            } else {
723                Style::default().fg(Color::White)
724            };
725
726            // Connection info: AP name for wireless, "Wired" for wired
727            // Signal bars in separate column for alignment
728            let (conn_str, conn_color, sig_str, sig_color) = if c.is_wired {
729                ("Wired".to_string(), DIM_COLOR, String::new(), DIM_COLOR)
730            } else {
731                let ap_name = c
732                    .ap_mac
733                    .as_deref()
734                    .and_then(|m| state.resolve_device_name(m));
735                let label = ap_name.unwrap_or(c.ssid.as_deref().unwrap_or("?"));
736                let sig = c
737                    .signal
738                    .map(|s| signal_bar(s).to_string())
739                    .unwrap_or_default();
740                let color = c.signal.map(signal_color).unwrap_or(DIM_COLOR);
741                (label.to_string(), color, sig, color)
742            };
743
744            Row::new(vec![
745                Cell::from(name).style(name_style),
746                Cell::from(conn_str).style(Style::default().fg(conn_color)),
747                Cell::from(sig_str).style(Style::default().fg(sig_color)),
748                Cell::from(c.ip.as_deref().unwrap_or("-").to_string())
749                    .style(Style::default().fg(DIM_COLOR)),
750                Cell::from(format_bytes(total_bytes)).style(total_style),
751            ])
752            .style(row_style)
753        })
754        .collect();
755
756    let widths = [
757        Constraint::Min(20),
758        Constraint::Length(16),
759        Constraint::Length(6),
760        Constraint::Length(16),
761        Constraint::Length(10),
762    ];
763
764    if clients.is_empty() {
765        let msg = if state.filter.is_empty() {
766            "No clients connected"
767        } else {
768            "No clients match filter"
769        };
770        let empty = Paragraph::new(Line::from(Span::styled(
771            msg,
772            Style::default().fg(DIM_COLOR),
773        )))
774        .block(block)
775        .alignment(Alignment::Center);
776        f.render_widget(empty, area);
777    } else {
778        let table = Table::new(rows, widths)
779            .header(header)
780            .block(block)
781            .row_highlight_style(Style::default().bg(SELECTED_BG));
782        f.render_widget(table, area);
783    }
784}
785
786fn draw_devices(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
787    let is_focused = state.focus == Panel::Devices;
788    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
789
790    let dev_pos = if !state.devices.is_empty() {
791        format!(" [{}/{}]", state.device_scroll + 1, state.devices.len())
792    } else {
793        String::new()
794    };
795    let title = format!(" Devices ({}){} ", state.devices.len(), dev_pos);
796    let block = Block::default()
797        .borders(Borders::ALL)
798        .border_type(BorderType::Rounded)
799        .border_style(Style::default().fg(border_color))
800        .title(Span::styled(
801            title,
802            Style::default()
803                .fg(border_color)
804                .add_modifier(Modifier::BOLD),
805        ));
806
807    let header = Row::new(vec![
808        Cell::from("Name").style(
809            Style::default()
810                .fg(HEADER_COLOR)
811                .add_modifier(Modifier::BOLD),
812        ),
813        Cell::from("Model").style(
814            Style::default()
815                .fg(HEADER_COLOR)
816                .add_modifier(Modifier::BOLD),
817        ),
818        Cell::from("IP").style(
819            Style::default()
820                .fg(HEADER_COLOR)
821                .add_modifier(Modifier::BOLD),
822        ),
823        Cell::from("State").style(
824            Style::default()
825                .fg(HEADER_COLOR)
826                .add_modifier(Modifier::BOLD),
827        ),
828        Cell::from("Clients").style(
829            Style::default()
830                .fg(HEADER_COLOR)
831                .add_modifier(Modifier::BOLD),
832        ),
833        Cell::from("Uptime").style(
834            Style::default()
835                .fg(HEADER_COLOR)
836                .add_modifier(Modifier::BOLD),
837        ),
838        Cell::from("Firmware").style(
839            Style::default()
840                .fg(HEADER_COLOR)
841                .add_modifier(Modifier::BOLD),
842        ),
843    ])
844    .height(1);
845
846    let rows: Vec<Row> = state
847        .devices
848        .iter()
849        .enumerate()
850        .map(|(i, d)| {
851            let (state_str, state_color) = device_state_str(d.state);
852
853            let is_selected = is_focused && i == state.device_scroll;
854            let row_style = if is_selected {
855                Style::default().bg(SELECTED_BG)
856            } else {
857                Style::default()
858            };
859
860            Row::new(vec![
861                Cell::from(d.name.as_deref().unwrap_or("-").to_string()).style(
862                    Style::default()
863                        .fg(Color::White)
864                        .add_modifier(Modifier::BOLD),
865                ),
866                Cell::from(d.model.as_deref().unwrap_or("-").to_string())
867                    .style(Style::default().fg(DIM_COLOR)),
868                Cell::from(d.ip.as_deref().unwrap_or("-").to_string())
869                    .style(Style::default().fg(DIM_COLOR)),
870                Cell::from(format!("● {state_str}")).style(Style::default().fg(state_color)),
871                Cell::from(
872                    d.num_sta
873                        .map(|n| n.to_string())
874                        .unwrap_or_else(|| "-".into()),
875                )
876                .style(Style::default().fg(Color::White)),
877                Cell::from(d.uptime.map(format_uptime).unwrap_or_else(|| "-".into()))
878                    .style(Style::default().fg(DIM_COLOR)),
879                Cell::from(d.version.as_deref().unwrap_or("-").to_string())
880                    .style(Style::default().fg(DIM_COLOR)),
881            ])
882            .style(row_style)
883        })
884        .collect();
885
886    let widths = [
887        Constraint::Min(18),
888        Constraint::Length(10),
889        Constraint::Length(16),
890        Constraint::Length(12),
891        Constraint::Length(8),
892        Constraint::Length(16),
893        Constraint::Length(14),
894    ];
895
896    if state.devices.is_empty() {
897        let empty = Paragraph::new(Line::from(Span::styled(
898            "No devices found",
899            Style::default().fg(DIM_COLOR),
900        )))
901        .block(block)
902        .alignment(Alignment::Center);
903        f.render_widget(empty, area);
904    } else {
905        let table = Table::new(rows, widths).header(header).block(block);
906        f.render_widget(table, area);
907    }
908}
909
910fn draw_footer(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
911    let error_span = if let Some(ref err) = state.last_error {
912        Span::styled(format!(" ⚠ {err} "), Style::default().fg(OFFLINE_COLOR))
913    } else {
914        Span::raw("")
915    };
916
917    let key_style = Style::default()
918        .fg(ACCENT_COLOR)
919        .add_modifier(Modifier::BOLD);
920    let dim = Style::default().fg(DIM_COLOR);
921
922    let status_span = if let Some((ref msg, _)) = state.status_msg {
923        Span::styled(format!(" ✓ {msg} "), Style::default().fg(ONLINE_COLOR))
924    } else {
925        Span::raw("")
926    };
927
928    let line = if state.overlay.is_some() {
929        // Overlay hints are shown on the overlay itself
930        Line::from(vec![error_span, status_span])
931    } else if state.filtering {
932        Line::from(vec![
933            Span::styled(" ", Style::default()),
934            Span::styled(
935                format!("filter: {}▌", state.filter),
936                Style::default()
937                    .fg(Color::Yellow)
938                    .add_modifier(Modifier::BOLD),
939            ),
940            Span::styled("  esc", key_style),
941            Span::styled(" clear ", dim),
942            Span::styled("enter", key_style),
943            Span::styled(" apply", dim),
944            error_span,
945        ])
946    } else {
947        Line::from(vec![
948            Span::styled(" q", key_style),
949            Span::styled(" quit ", dim),
950            Span::styled("s", key_style),
951            Span::styled(" sort ", dim),
952            Span::styled("/", key_style),
953            Span::styled(" filter ", dim),
954            Span::styled("enter", key_style),
955            Span::styled(" details ", dim),
956            Span::styled("tab", key_style),
957            Span::styled(" switch panel", dim),
958            error_span,
959            status_span,
960        ])
961    };
962
963    let paragraph = Paragraph::new(line);
964    f.render_widget(paragraph, area);
965}
966
967fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
968    let vertical = Layout::default()
969        .direction(Direction::Vertical)
970        .constraints([
971            Constraint::Min(0),
972            Constraint::Length(height),
973            Constraint::Min(0),
974        ])
975        .split(area);
976    Layout::default()
977        .direction(Direction::Horizontal)
978        .constraints([
979            Constraint::Min(0),
980            Constraint::Length(width),
981            Constraint::Min(0),
982        ])
983        .split(vertical[1])[1]
984}
985
986fn draw_overlay(f: &mut ratatui::Frame, state: &AppState) {
987    let overlay = match &state.overlay {
988        Some(o) => o,
989        None => return,
990    };
991
992    // Handle ApPicker and Confirm separately (different layout)
993    if let Overlay::ApPicker {
994        client_idx,
995        ap_cursor,
996    } = overlay
997    {
998        draw_ap_picker(f, state, *client_idx, *ap_cursor);
999        return;
1000    }
1001    if let Overlay::Confirm { message, .. } = overlay {
1002        draw_confirm(f, message);
1003        return;
1004    }
1005
1006    // Count rows to size the overlay
1007    let row_count = match overlay {
1008        Overlay::ClientDetail(idx) => {
1009            let clients = state.sorted_clients();
1010            let c = match clients.get(*idx) {
1011                Some(c) => c,
1012                None => return,
1013            };
1014            let mut n = 3; // MAC, IP, Type
1015            if c.uptime.is_some() {
1016                n += 1;
1017            }
1018            if c.tx_bytes.is_some() {
1019                n += 1;
1020            }
1021            if c.rx_bytes.is_some() {
1022                n += 1;
1023            }
1024            if !c.is_wired {
1025                if c.signal.is_some() {
1026                    n += 1;
1027                }
1028                if c.ssid.is_some() {
1029                    n += 1;
1030                }
1031                if c.ap_mac.is_some() {
1032                    n += 1;
1033                }
1034                n += 1; // AP Lock row
1035            }
1036            n
1037        }
1038        Overlay::DeviceDetail(idx) => {
1039            let d = match state.devices.get(*idx) {
1040                Some(d) => d,
1041                None => return,
1042            };
1043            let mut n = 4; // Model, MAC, IP, State
1044            if d.version.is_some() {
1045                n += 1;
1046            }
1047            if d.uptime.is_some() {
1048                n += 1;
1049            }
1050            if d.num_sta.is_some() {
1051                n += 1;
1052            }
1053            if d.upgradable {
1054                n += 1;
1055            }
1056            n
1057        }
1058        Overlay::ApPicker { .. } | Overlay::Confirm { .. } => 0,
1059    };
1060
1061    // 2 borders + 1 header row gap + data rows
1062    let height = (row_count as u16 + 3).min(f.area().height.saturating_sub(4));
1063    let width = 44_u16.min(f.area().width.saturating_sub(4));
1064    let area = centered_rect_fixed(width, height, f.area());
1065    f.render_widget(Clear, area);
1066
1067    let hint_key = Style::default()
1068        .fg(ACCENT_COLOR)
1069        .add_modifier(Modifier::BOLD);
1070    let hint_dim = Style::default().fg(DIM_COLOR);
1071
1072    match overlay {
1073        Overlay::ClientDetail(idx) => {
1074            let clients = state.sorted_clients();
1075            let Some(c) = clients.get(*idx) else { return };
1076
1077            let title = format!(" {} ", c.display_name());
1078
1079            let block_label = if c.blocked { "unblock" } else { "block" };
1080            let mut hints = vec![
1081                Span::styled(" esc", hint_key),
1082                Span::styled(" back ", hint_dim),
1083                Span::styled("k", hint_key),
1084                Span::styled(" kick ", hint_dim),
1085                Span::styled("b", hint_key),
1086                Span::styled(format!(" {block_label} "), hint_dim),
1087            ];
1088            if !c.is_wired {
1089                let ap_label = if c.fixed_ap_enabled {
1090                    "unlock AP"
1091                } else {
1092                    "lock to AP"
1093                };
1094                hints.push(Span::styled("a", hint_key));
1095                hints.push(Span::styled(format!(" {ap_label} "), hint_dim));
1096            }
1097
1098            let block = Block::default()
1099                .borders(Borders::ALL)
1100                .border_type(BorderType::Rounded)
1101                .border_style(Style::default().fg(ACCENT_COLOR))
1102                .style(Style::default().bg(Color::Black))
1103                .title(Span::styled(
1104                    title,
1105                    Style::default()
1106                        .fg(ACCENT_COLOR)
1107                        .add_modifier(Modifier::BOLD),
1108                ))
1109                .title_bottom(Line::from(hints));
1110
1111            let mut rows = vec![
1112                detail_row(
1113                    "MAC",
1114                    &c.mac
1115                        .as_deref()
1116                        .map(crate::api::format_mac)
1117                        .unwrap_or_else(|| "-".into()),
1118                ),
1119                detail_row("IP", c.ip.as_deref().unwrap_or("-")),
1120                detail_row("Type", if c.is_wired { "Wired" } else { "Wireless" }),
1121            ];
1122
1123            if let Some(uptime) = c.uptime {
1124                rows.push(detail_row("Uptime", &format_uptime(uptime)));
1125            }
1126            if let Some(tx) = c.tx_bytes {
1127                rows.push(detail_row("TX", &format_bytes(tx)));
1128            }
1129            if let Some(rx) = c.rx_bytes {
1130                rows.push(detail_row("RX", &format_bytes(rx)));
1131            }
1132            if !c.is_wired {
1133                if let Some(signal) = c.signal {
1134                    rows.push(detail_row("Signal", &format!("{signal} dBm")));
1135                }
1136                if let Some(ref ssid) = c.ssid {
1137                    rows.push(detail_row("SSID", ssid));
1138                }
1139                if let Some(ref ap) = c.ap_mac {
1140                    let ap_label = state.resolve_device_name(ap).unwrap_or(ap.as_str());
1141                    rows.push(detail_row("AP", ap_label));
1142                }
1143                // AP Lock status
1144                let lock_value = if c.fixed_ap_enabled {
1145                    let ap_name = c.fixed_ap_mac.as_deref().map(|m| {
1146                        state
1147                            .resolve_device_name(m)
1148                            .map(String::from)
1149                            .unwrap_or_else(|| crate::api::format_mac(m))
1150                    });
1151                    format!("🔒 {}", ap_name.unwrap_or_else(|| "Yes".into()))
1152                } else {
1153                    "Off  (a to lock)".into()
1154                };
1155                rows.push(detail_row("AP Lock", &lock_value));
1156            }
1157
1158            let widths = [Constraint::Length(10), Constraint::Min(20)];
1159            let table = Table::new(rows, widths).block(block);
1160            f.render_widget(table, area);
1161        }
1162        Overlay::DeviceDetail(idx) => {
1163            let Some(d) = state.devices.get(*idx) else {
1164                return;
1165            };
1166
1167            let name = d.name.as_deref().unwrap_or("Device");
1168            let title = format!(" {name} ");
1169
1170            let locate_label = d
1171                .mac
1172                .as_ref()
1173                .map(|mac| {
1174                    let normalized = crate::api::normalize_mac(mac);
1175                    if state.locating.get(&normalized).copied().unwrap_or(false) {
1176                        "stop locate"
1177                    } else {
1178                        "locate"
1179                    }
1180                })
1181                .unwrap_or("locate");
1182
1183            let mut hints = vec![
1184                Span::styled(" esc", hint_key),
1185                Span::styled(" back ", hint_dim),
1186                Span::styled("r", hint_key),
1187                Span::styled(" restart ", hint_dim),
1188            ];
1189            if d.upgradable {
1190                hints.push(Span::styled("u", hint_key));
1191                hints.push(Span::styled(" upgrade ", hint_dim));
1192            }
1193            hints.push(Span::styled("l", hint_key));
1194            hints.push(Span::styled(format!(" {locate_label} "), hint_dim));
1195
1196            let block = Block::default()
1197                .borders(Borders::ALL)
1198                .border_type(BorderType::Rounded)
1199                .border_style(Style::default().fg(ACCENT_COLOR))
1200                .style(Style::default().bg(Color::Black))
1201                .title(Span::styled(
1202                    title,
1203                    Style::default()
1204                        .fg(ACCENT_COLOR)
1205                        .add_modifier(Modifier::BOLD),
1206                ))
1207                .title_bottom(Line::from(hints));
1208
1209            let (state_str, _) = device_state_str(d.state);
1210            let mut rows = vec![
1211                detail_row("Model", d.model.as_deref().unwrap_or("-")),
1212                detail_row(
1213                    "MAC",
1214                    &d.mac
1215                        .as_deref()
1216                        .map(crate::api::format_mac)
1217                        .unwrap_or_else(|| "-".into()),
1218                ),
1219                detail_row("IP", d.ip.as_deref().unwrap_or("-")),
1220                detail_row("State", state_str),
1221            ];
1222
1223            if let Some(ref v) = d.version {
1224                if d.upgradable {
1225                    if let Some(ref new_v) = d.upgrade_to_firmware {
1226                        rows.push(detail_row("Firmware", &format!("{v} → {new_v}")));
1227                    } else {
1228                        rows.push(detail_row("Firmware", &format!("{v} (update available)")));
1229                    }
1230                } else {
1231                    rows.push(detail_row("Firmware", v));
1232                }
1233            }
1234            if d.upgradable && d.version.is_none() {
1235                rows.push(detail_row("Firmware", "Update available"));
1236            }
1237            if let Some(uptime) = d.uptime {
1238                rows.push(detail_row("Uptime", &format_uptime(uptime)));
1239            }
1240            if let Some(num_sta) = d.num_sta {
1241                rows.push(detail_row("Clients", &num_sta.to_string()));
1242            }
1243
1244            let widths = [Constraint::Length(10), Constraint::Min(20)];
1245            let table = Table::new(rows, widths).block(block);
1246            f.render_widget(table, area);
1247        }
1248        Overlay::ApPicker { .. } | Overlay::Confirm { .. } => {}
1249    }
1250}
1251
1252fn detail_row(field: &str, value: &str) -> Row<'static> {
1253    Row::new(vec![
1254        Cell::from(field.to_string()).style(
1255            Style::default()
1256                .fg(HEADER_COLOR)
1257                .add_modifier(Modifier::BOLD),
1258        ),
1259        Cell::from(value.to_string()).style(Style::default().fg(Color::White)),
1260    ])
1261}
1262
1263fn draw_confirm(f: &mut ratatui::Frame, message: &str) {
1264    let width = (message.len() as u16 + 6).min(f.area().width.saturating_sub(4));
1265    let height = 3_u16;
1266    let area = centered_rect_fixed(width, height, f.area());
1267    f.render_widget(Clear, area);
1268
1269    let hint_key = Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD);
1270    let hint_dim = Style::default().fg(DIM_COLOR);
1271    let hints = vec![
1272        Span::styled(" y", hint_key),
1273        Span::styled(" confirm ", hint_dim),
1274        Span::styled(
1275            "n/esc",
1276            Style::default()
1277                .fg(ACCENT_COLOR)
1278                .add_modifier(Modifier::BOLD),
1279        ),
1280        Span::styled(" cancel ", hint_dim),
1281    ];
1282
1283    let block = Block::default()
1284        .borders(Borders::ALL)
1285        .border_type(BorderType::Rounded)
1286        .border_style(Style::default().fg(WARN_COLOR))
1287        .style(Style::default().bg(Color::Black))
1288        .title(Span::styled(
1289            " Confirm ",
1290            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
1291        ))
1292        .title_bottom(Line::from(hints));
1293
1294    let text = Line::from(Span::styled(
1295        message.to_string(),
1296        Style::default().fg(Color::White),
1297    ));
1298    let paragraph = Paragraph::new(text)
1299        .block(block)
1300        .alignment(Alignment::Center);
1301    f.render_widget(paragraph, area);
1302}
1303
1304fn draw_ap_picker(f: &mut ratatui::Frame, state: &AppState, client_idx: usize, ap_cursor: usize) {
1305    let clients = state.sorted_clients();
1306    let client = match clients.get(client_idx) {
1307        Some(c) => c,
1308        None => return,
1309    };
1310
1311    let aps = state.ap_devices();
1312    if aps.is_empty() {
1313        return;
1314    }
1315
1316    // Determine which AP the client is currently connected to
1317    let current_ap_mac = client.ap_mac.as_deref().map(crate::api::normalize_mac);
1318
1319    let client_name = client.display_name();
1320    let title = format!(" Lock {client_name} to AP ");
1321    let row_count = aps.len();
1322    let height = (row_count as u16 + 3).min(f.area().height.saturating_sub(4));
1323    let width = 50_u16.min(f.area().width.saturating_sub(4));
1324    let area = centered_rect_fixed(width, height, f.area());
1325    f.render_widget(Clear, area);
1326
1327    let hint_key = Style::default()
1328        .fg(ACCENT_COLOR)
1329        .add_modifier(Modifier::BOLD);
1330    let hint_dim = Style::default().fg(DIM_COLOR);
1331    let hints = vec![
1332        Span::styled(" ↑↓", hint_key),
1333        Span::styled(" select ", hint_dim),
1334        Span::styled("enter", hint_key),
1335        Span::styled(" lock ", hint_dim),
1336        Span::styled("esc", hint_key),
1337        Span::styled(" back ", hint_dim),
1338    ];
1339
1340    let block = Block::default()
1341        .borders(Borders::ALL)
1342        .border_type(BorderType::Rounded)
1343        .border_style(Style::default().fg(ACCENT_COLOR))
1344        .style(Style::default().bg(Color::Black))
1345        .title(Span::styled(
1346            title,
1347            Style::default()
1348                .fg(ACCENT_COLOR)
1349                .add_modifier(Modifier::BOLD),
1350        ))
1351        .title_bottom(Line::from(hints));
1352
1353    let rows: Vec<Row> =
1354        aps.iter()
1355            .enumerate()
1356            .map(|(i, ap)| {
1357                let name = ap.name.as_deref().unwrap_or("-");
1358                let mac = ap
1359                    .mac
1360                    .as_deref()
1361                    .map(crate::api::format_mac)
1362                    .unwrap_or_else(|| "-".into());
1363                let is_selected = i == ap_cursor;
1364                let is_current = ap.mac.as_deref().is_some_and(|m| {
1365                    current_ap_mac.as_deref() == Some(&crate::api::normalize_mac(m))
1366                });
1367                let style = if is_selected {
1368                    Style::default().bg(SELECTED_BG).fg(Color::White)
1369                } else {
1370                    Style::default().fg(Color::White)
1371                };
1372                let prefix = if is_selected { "▸ " } else { "  " };
1373                let suffix = if is_current { " ◂ connected" } else { "" };
1374                Row::new(vec![
1375                    Cell::from(format!("{prefix}{name}{suffix}")).style(style),
1376                    Cell::from(mac).style(Style::default().fg(DIM_COLOR)),
1377                ])
1378            })
1379            .collect();
1380
1381    let widths = [Constraint::Min(24), Constraint::Length(18)];
1382    let table = Table::new(rows, widths).block(block);
1383    f.render_widget(table, area);
1384}
1385
1386fn draw(f: &mut ratatui::Frame, state: &AppState) {
1387    if state.loading {
1388        let area = f.area();
1389        let block = Block::default()
1390            .borders(Borders::ALL)
1391            .border_type(BorderType::Rounded)
1392            .border_style(Style::default().fg(ACCENT_COLOR));
1393        let text = Paragraph::new(Line::from(Span::styled(
1394            "Connecting to controller…",
1395            Style::default()
1396                .fg(ACCENT_COLOR)
1397                .add_modifier(Modifier::BOLD),
1398        )))
1399        .alignment(Alignment::Center)
1400        .block(block);
1401        let centered = Layout::default()
1402            .direction(Direction::Vertical)
1403            .constraints([
1404                Constraint::Min(0),
1405                Constraint::Length(3),
1406                Constraint::Min(0),
1407            ])
1408            .split(area)[1];
1409        f.render_widget(text, centered);
1410        return;
1411    }
1412
1413    // Devices: 2 borders + 1 header + 1 header gap + data rows, minimum 5
1414    let device_rows = (state.devices.len() + 4).max(5) as u16;
1415    let chunks = Layout::default()
1416        .direction(Direction::Vertical)
1417        .constraints([
1418            Constraint::Length(3),           // header
1419            Constraint::Min(10),             // clients (takes remaining)
1420            Constraint::Length(device_rows), // devices (sized to content)
1421            Constraint::Length(1),           // footer
1422        ])
1423        .split(f.area());
1424
1425    draw_header(f, chunks[0], state);
1426    draw_clients(f, chunks[1], state);
1427    draw_devices(f, chunks[2], state);
1428    draw_footer(f, chunks[3], state);
1429    draw_overlay(f, state);
1430}
1431
1432type FetchResult = Result<
1433    (
1434        Option<SysInfo>,
1435        Option<HostSystem>,
1436        Vec<HealthSubsystem>,
1437        Vec<LegacyClient>,
1438        Vec<LegacyDevice>,
1439    ),
1440    String,
1441>;
1442
1443pub async fn run(api: &UnifiClient, interval_secs: u64) -> Result<(), Box<dyn std::error::Error>> {
1444    // Setup terminal
1445    enable_raw_mode()?;
1446    let mut stdout = io::stdout();
1447    execute!(stdout, EnterAlternateScreen)?;
1448    let backend = CrosstermBackend::new(stdout);
1449    let mut terminal = Terminal::new(backend)?;
1450
1451    let mut state = AppState::new();
1452    let tick_rate = Duration::from_secs(interval_secs);
1453    let mut last_tick = Instant::now() - tick_rate; // Force immediate first fetch
1454
1455    let (tx, mut rx) = tokio::sync::mpsc::channel::<FetchResult>(1);
1456    let (action_tx, mut action_rx) = tokio::sync::mpsc::channel::<Result<String, String>>(4);
1457    let mut fetch_in_progress = false;
1458
1459    let result = loop {
1460        // Kick off background fetch if tick elapsed and no fetch is running
1461        if !fetch_in_progress && last_tick.elapsed() >= tick_rate {
1462            let tx = tx.clone();
1463            let http = api.clone_http();
1464            let base_url = api.base_url().to_string();
1465            fetch_in_progress = true;
1466            state.loading = state.clients.is_empty();
1467            tokio::spawn(async move {
1468                let result = fetch_data_standalone(&http, &base_url).await;
1469                let _ = tx.send(result.map_err(|e| e.to_string())).await;
1470            });
1471        }
1472
1473        // Check for completed fetch (non-blocking)
1474        if let Ok(result) = rx.try_recv() {
1475            fetch_in_progress = false;
1476            state.loading = false;
1477            last_tick = Instant::now();
1478            match result {
1479                Ok((sysinfo, host_system, health, clients, devices)) => {
1480                    state.sysinfo = sysinfo;
1481                    state.host_system = host_system;
1482                    state.health = health;
1483                    state.clients = clients;
1484                    state.devices = devices;
1485                    state.rebuild_device_names();
1486                    state.last_error = None;
1487                }
1488                Err(e) => {
1489                    state.last_error = Some(e);
1490                }
1491            }
1492        }
1493
1494        // Check for completed actions (non-blocking)
1495        if let Ok(result) = action_rx.try_recv() {
1496            match result {
1497                Ok(msg) => {
1498                    state.status_msg = Some((msg, Instant::now()));
1499                    // Force refresh after action
1500                    last_tick = Instant::now() - tick_rate;
1501                }
1502                Err(msg) => {
1503                    state.last_error = Some(msg);
1504                }
1505            }
1506        }
1507
1508        // Clear status message after 3 seconds
1509        if let Some((_, t)) = &state.status_msg
1510            && t.elapsed() >= Duration::from_secs(3)
1511        {
1512            state.status_msg = None;
1513        }
1514
1515        // Adjust viewport so cursor stays visible
1516        if !state.loading {
1517            let term_height = terminal.size()?.height;
1518            let device_rows = (state.devices.len() + 4).max(5) as u16;
1519            // client area = total - header(3) - devices - footer(1), minus borders+header(4)
1520            let client_visible = term_height
1521                .saturating_sub(3 + device_rows + 1)
1522                .saturating_sub(4) as usize;
1523            state.ensure_client_visible(client_visible);
1524        }
1525
1526        // Draw
1527        terminal.draw(|f| draw(f, &state))?;
1528
1529        // Handle events (poll with short timeout for responsiveness)
1530        if event::poll(Duration::from_millis(100))?
1531            && let Event::Key(key) = event::read()?
1532        {
1533            if key.kind != KeyEventKind::Press {
1534                continue;
1535            }
1536
1537            if state.filtering {
1538                match key.code {
1539                    KeyCode::Esc => {
1540                        state.filtering = false;
1541                        state.filter.clear();
1542                    }
1543                    KeyCode::Enter => {
1544                        state.filtering = false;
1545                    }
1546                    KeyCode::Backspace => {
1547                        state.filter.pop();
1548                    }
1549                    KeyCode::Char(c) => {
1550                        state.filter.push(c);
1551                        state.client_cursor = 0;
1552                    }
1553                    _ => {}
1554                }
1555                continue;
1556            }
1557
1558            // Overlay is open: Esc closes it, q quits, action keys
1559            if state.overlay.is_some() {
1560                // ApPicker has its own key handling
1561                if let Some(Overlay::ApPicker {
1562                    client_idx,
1563                    ap_cursor,
1564                }) = &state.overlay
1565                {
1566                    let client_idx = *client_idx;
1567                    let ap_cursor = *ap_cursor;
1568                    match key.code {
1569                        KeyCode::Esc => {
1570                            state.overlay = Some(Overlay::ClientDetail(client_idx));
1571                        }
1572                        KeyCode::Char('q') => break Ok(()),
1573                        KeyCode::Up | KeyCode::Char('k') => {
1574                            let new_cursor = ap_cursor.saturating_sub(1);
1575                            state.overlay = Some(Overlay::ApPicker {
1576                                client_idx,
1577                                ap_cursor: new_cursor,
1578                            });
1579                        }
1580                        KeyCode::Down | KeyCode::Char('j') => {
1581                            let max = state.ap_devices().len().saturating_sub(1);
1582                            let new_cursor = (ap_cursor + 1).min(max);
1583                            state.overlay = Some(Overlay::ApPicker {
1584                                client_idx,
1585                                ap_cursor: new_cursor,
1586                            });
1587                        }
1588                        KeyCode::Enter => {
1589                            let clients = state.sorted_clients();
1590                            let aps = state.ap_devices();
1591                            if let Some(c) = clients.get(client_idx)
1592                                && let Some(ref mac) = c.mac
1593                                && let Some(ap) = aps.get(ap_cursor)
1594                                && let Some(ref ap_mac) = ap.mac
1595                            {
1596                                let action = ClientAction::LockToAp {
1597                                    mac: mac.clone(),
1598                                    ap_mac: ap_mac.clone(),
1599                                };
1600                                let http = api.clone_http();
1601                                let base_url = api.base_url().to_string();
1602                                let action_tx = action_tx.clone();
1603                                tokio::spawn(async move {
1604                                    let result =
1605                                        execute_client_action(&http, &base_url, action).await;
1606                                    let _ = action_tx.send(result).await;
1607                                });
1608                                state.overlay = None;
1609                            }
1610                        }
1611                        _ => {}
1612                    }
1613                    continue;
1614                }
1615
1616                // Confirm dialog handling
1617                if matches!(&state.overlay, Some(Overlay::Confirm { .. })) {
1618                    match key.code {
1619                        KeyCode::Char('y') | KeyCode::Char('Y') => {
1620                            // Take ownership by replacing overlay
1621                            let overlay = state.overlay.take();
1622                            if let Some(Overlay::Confirm { action, .. }) = overlay {
1623                                let http = api.clone_http();
1624                                let base_url = api.base_url().to_string();
1625                                let action_tx = action_tx.clone();
1626                                match action {
1627                                    PendingAction::Client(ca) => {
1628                                        tokio::spawn(async move {
1629                                            let result =
1630                                                execute_client_action(&http, &base_url, ca).await;
1631                                            let _ = action_tx.send(result).await;
1632                                        });
1633                                    }
1634                                    PendingAction::Device(da) => {
1635                                        tokio::spawn(async move {
1636                                            let result =
1637                                                execute_device_action(&http, &base_url, da).await;
1638                                            let _ = action_tx.send(result).await;
1639                                        });
1640                                    }
1641                                }
1642                            }
1643                        }
1644                        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1645                            state.overlay = None;
1646                        }
1647                        KeyCode::Char('q') => break Ok(()),
1648                        _ => {}
1649                    }
1650                    continue;
1651                }
1652
1653                match key.code {
1654                    KeyCode::Esc => {
1655                        state.overlay = None;
1656                    }
1657                    KeyCode::Char('q') => break Ok(()),
1658                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1659                        break Ok(());
1660                    }
1661                    KeyCode::Char('k') | KeyCode::Char('b') | KeyCode::Char('a') => {
1662                        if let Some(Overlay::ClientDetail(idx)) = &state.overlay {
1663                            let clients = state.sorted_clients();
1664                            if let Some(c) = clients.get(*idx)
1665                                && let Some(ref mac) = c.mac
1666                            {
1667                                let name = c.display_name().to_string();
1668                                match key.code {
1669                                    KeyCode::Char('a') => {
1670                                        if !c.is_wired {
1671                                            if c.fixed_ap_enabled {
1672                                                let action =
1673                                                    ClientAction::UnlockFromAp(mac.clone());
1674                                                state.overlay = Some(Overlay::Confirm {
1675                                                    message: format!("Unlock {name} from AP?"),
1676                                                    action: PendingAction::Client(action),
1677                                                });
1678                                            } else {
1679                                                let idx = *idx;
1680                                                state.overlay = Some(Overlay::ApPicker {
1681                                                    client_idx: idx,
1682                                                    ap_cursor: 0,
1683                                                });
1684                                            }
1685                                        }
1686                                    }
1687                                    KeyCode::Char('k') => {
1688                                        let action = ClientAction::Kick(mac.clone());
1689                                        state.overlay = Some(Overlay::Confirm {
1690                                            message: format!("Kick {name}?"),
1691                                            action: PendingAction::Client(action),
1692                                        });
1693                                    }
1694                                    KeyCode::Char('b') => {
1695                                        let (action, verb) = if c.blocked {
1696                                            (ClientAction::Unblock(mac.clone()), "Unblock")
1697                                        } else {
1698                                            (ClientAction::Block(mac.clone()), "Block")
1699                                        };
1700                                        state.overlay = Some(Overlay::Confirm {
1701                                            message: format!("{verb} {name}?"),
1702                                            action: PendingAction::Client(action),
1703                                        });
1704                                    }
1705                                    _ => {}
1706                                }
1707                            }
1708                        }
1709                    }
1710                    KeyCode::Char('r') | KeyCode::Char('u') | KeyCode::Char('l') => {
1711                        if let Some(Overlay::DeviceDetail(idx)) = &state.overlay
1712                            && let Some(d) = state.devices.get(*idx)
1713                            && let Some(ref mac) = d.mac
1714                        {
1715                            let name = d.name.as_deref().unwrap_or("device").to_string();
1716                            match key.code {
1717                                KeyCode::Char('r') => {
1718                                    let action = DeviceAction::Restart(mac.clone());
1719                                    state.overlay = Some(Overlay::Confirm {
1720                                        message: format!("Restart {name}?"),
1721                                        action: PendingAction::Device(action),
1722                                    });
1723                                }
1724                                KeyCode::Char('u') if d.upgradable => {
1725                                    let action = DeviceAction::Upgrade(mac.clone());
1726                                    state.overlay = Some(Overlay::Confirm {
1727                                        message: format!("Upgrade firmware on {name}?"),
1728                                        action: PendingAction::Device(action),
1729                                    });
1730                                }
1731                                KeyCode::Char('l') => {
1732                                    // Locate is safe/reversible, no confirmation needed
1733                                    let normalized = crate::api::normalize_mac(mac);
1734                                    let currently_locating =
1735                                        state.locating.get(&normalized).copied().unwrap_or(false);
1736                                    state.locating.insert(normalized, !currently_locating);
1737                                    let action =
1738                                        DeviceAction::Locate(mac.clone(), !currently_locating);
1739                                    let http = api.clone_http();
1740                                    let base_url = api.base_url().to_string();
1741                                    let action_tx = action_tx.clone();
1742                                    tokio::spawn(async move {
1743                                        let result =
1744                                            execute_device_action(&http, &base_url, action).await;
1745                                        let _ = action_tx.send(result).await;
1746                                    });
1747                                }
1748                                _ => {}
1749                            }
1750                        }
1751                    }
1752                    _ => {}
1753                }
1754                continue;
1755            }
1756
1757            match key.code {
1758                KeyCode::Char('q') => break Ok(()),
1759                KeyCode::Esc => break Ok(()),
1760                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
1761                KeyCode::Enter => {
1762                    let overlay = match state.focus {
1763                        Panel::Clients => {
1764                            let clients = state.sorted_clients();
1765                            if !clients.is_empty() {
1766                                Some(Overlay::ClientDetail(state.client_cursor))
1767                            } else {
1768                                None
1769                            }
1770                        }
1771                        Panel::Devices => {
1772                            if !state.devices.is_empty() {
1773                                Some(Overlay::DeviceDetail(state.device_scroll))
1774                            } else {
1775                                None
1776                            }
1777                        }
1778                    };
1779                    state.overlay = overlay;
1780                }
1781                KeyCode::Tab => {
1782                    state.focus = match state.focus {
1783                        Panel::Clients => Panel::Devices,
1784                        Panel::Devices => Panel::Clients,
1785                    };
1786                }
1787                KeyCode::Char('s') => {
1788                    state.sort = state.sort.next();
1789                }
1790                KeyCode::Char('/') => {
1791                    state.filtering = true;
1792                    state.filter.clear();
1793                }
1794                KeyCode::Up | KeyCode::Char('k') => {
1795                    state.cursor_up();
1796                }
1797                KeyCode::Down | KeyCode::Char('j') => {
1798                    let max_c = state.sorted_clients().len();
1799                    let max_d = state.devices.len();
1800                    state.cursor_down(max_c, max_d);
1801                }
1802                KeyCode::PageUp => {
1803                    state.page_up(10);
1804                }
1805                KeyCode::PageDown => {
1806                    let max_c = state.sorted_clients().len();
1807                    let max_d = state.devices.len();
1808                    state.page_down(max_c, max_d, 10);
1809                }
1810                KeyCode::Home => match state.focus {
1811                    Panel::Clients => state.client_cursor = 0,
1812                    Panel::Devices => state.device_scroll = 0,
1813                },
1814                KeyCode::End => match state.focus {
1815                    Panel::Clients => {
1816                        let max = state.sorted_clients().len().saturating_sub(1);
1817                        state.client_cursor = max;
1818                    }
1819                    Panel::Devices => {
1820                        let max = state.devices.len().saturating_sub(1);
1821                        state.device_scroll = max;
1822                    }
1823                },
1824                _ => {}
1825            }
1826        }
1827    };
1828
1829    // Restore terminal
1830    disable_raw_mode()?;
1831    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1832    terminal.show_cursor()?;
1833
1834    result
1835}
1836
1837// --- Ports Live TUI ---
1838
1839use crate::api::{DeviceWithPorts, PortEntry};
1840
1841struct PortsState {
1842    device: Option<DeviceWithPorts>,
1843    prev_bytes: HashMap<u32, (u64, u64, Instant)>,
1844    port_rates: HashMap<u32, (f64, f64)>,
1845    scroll: usize,
1846    interval_secs: u64,
1847    last_error: Option<String>,
1848}
1849
1850impl PortsState {
1851    fn new(interval_secs: u64) -> Self {
1852        Self {
1853            device: None,
1854            prev_bytes: HashMap::new(),
1855            port_rates: HashMap::new(),
1856            scroll: 0,
1857            interval_secs,
1858            last_error: None,
1859        }
1860    }
1861
1862    fn update_port_rates(&mut self) {
1863        let now = Instant::now();
1864        let ports = match &self.device {
1865            Some(d) => &d.port_table,
1866            None => return,
1867        };
1868
1869        for port in ports {
1870            let idx = match port.port_idx {
1871                Some(i) => i,
1872                None => continue,
1873            };
1874            let tx = port.tx_bytes.unwrap_or(0);
1875            let rx = port.rx_bytes.unwrap_or(0);
1876
1877            if let Some((prev_tx, prev_rx, prev_time)) = self.prev_bytes.get(&idx) {
1878                let elapsed = now.duration_since(*prev_time).as_secs_f64();
1879                if elapsed > 0.1 {
1880                    let tx_rate = if tx >= *prev_tx {
1881                        (tx - prev_tx) as f64 / elapsed
1882                    } else {
1883                        0.0
1884                    };
1885                    let rx_rate = if rx >= *prev_rx {
1886                        (rx - prev_rx) as f64 / elapsed
1887                    } else {
1888                        0.0
1889                    };
1890                    self.port_rates.insert(idx, (tx_rate, rx_rate));
1891                }
1892            }
1893
1894            self.prev_bytes.insert(idx, (tx, rx, now));
1895        }
1896    }
1897}
1898
1899fn port_link_color(port: &PortEntry) -> Color {
1900    if port.up {
1901        match port.speed {
1902            Some(s) if s >= 2500 => Color::Green,
1903            Some(s) if s >= 1000 => Color::Cyan,
1904            Some(_) => Color::Yellow,
1905            None => Color::White,
1906        }
1907    } else {
1908        DIM_COLOR
1909    }
1910}
1911
1912fn draw_ports(f: &mut ratatui::Frame, state: &PortsState) {
1913    let chunks = Layout::default()
1914        .direction(Direction::Vertical)
1915        .constraints([
1916            Constraint::Min(10),   // port table
1917            Constraint::Length(1), // footer
1918        ])
1919        .split(f.area());
1920
1921    let device_name = state
1922        .device
1923        .as_ref()
1924        .and_then(|d| d.name.as_deref())
1925        .unwrap_or("Device");
1926    let port_count = state
1927        .device
1928        .as_ref()
1929        .map(|d| d.port_table.len())
1930        .unwrap_or(0);
1931
1932    let block = Block::default()
1933        .borders(Borders::ALL)
1934        .border_type(BorderType::Rounded)
1935        .border_style(Style::default().fg(ACCENT_COLOR))
1936        .title(Span::styled(
1937            format!(" {device_name} \u{2502} {port_count} ports "),
1938            Style::default()
1939                .fg(ACCENT_COLOR)
1940                .add_modifier(Modifier::BOLD),
1941        ));
1942
1943    let header = Row::new(vec![
1944        Cell::from("Port").style(
1945            Style::default()
1946                .fg(HEADER_COLOR)
1947                .add_modifier(Modifier::BOLD),
1948        ),
1949        Cell::from("Name").style(
1950            Style::default()
1951                .fg(HEADER_COLOR)
1952                .add_modifier(Modifier::BOLD),
1953        ),
1954        Cell::from("Link").style(
1955            Style::default()
1956                .fg(HEADER_COLOR)
1957                .add_modifier(Modifier::BOLD),
1958        ),
1959        Cell::from("Speed").style(
1960            Style::default()
1961                .fg(HEADER_COLOR)
1962                .add_modifier(Modifier::BOLD),
1963        ),
1964        Cell::from("PoE").style(
1965            Style::default()
1966                .fg(HEADER_COLOR)
1967                .add_modifier(Modifier::BOLD),
1968        ),
1969        Cell::from("TX/s").style(
1970            Style::default()
1971                .fg(HEADER_COLOR)
1972                .add_modifier(Modifier::BOLD),
1973        ),
1974        Cell::from("RX/s").style(
1975            Style::default()
1976                .fg(HEADER_COLOR)
1977                .add_modifier(Modifier::BOLD),
1978        ),
1979        Cell::from("TX Total").style(
1980            Style::default()
1981                .fg(HEADER_COLOR)
1982                .add_modifier(Modifier::BOLD),
1983        ),
1984        Cell::from("RX Total").style(
1985            Style::default()
1986                .fg(HEADER_COLOR)
1987                .add_modifier(Modifier::BOLD),
1988        ),
1989    ])
1990    .height(1);
1991
1992    let inner_height = chunks[0].height.saturating_sub(4) as usize;
1993    let ports = state
1994        .device
1995        .as_ref()
1996        .map(|d| &d.port_table[..])
1997        .unwrap_or(&[]);
1998
1999    let rows: Vec<Row> = ports
2000        .iter()
2001        .skip(state.scroll)
2002        .take(inner_height)
2003        .map(|p| {
2004            let idx = p.port_idx.unwrap_or(0);
2005            let link_color = port_link_color(p);
2006            let (tx_rate, rx_rate) = state.port_rates.get(&idx).copied().unwrap_or((0.0, 0.0));
2007
2008            let link_str = if p.up { "\u{25cf} up" } else { "\u{25cb} down" };
2009
2010            let speed_str = if p.up {
2011                match p.speed {
2012                    Some(s) => {
2013                        let duplex = if p.full_duplex { "FD" } else { "HD" };
2014                        format!("{s} {duplex}")
2015                    }
2016                    None => "up".into(),
2017                }
2018            } else {
2019                "-".into()
2020            };
2021
2022            let poe_str = if p.poe_enable {
2023                match p.poe_power {
2024                    Some(w) if w > 0.0 => format!("{w:.1}W"),
2025                    _ => "on".into(),
2026                }
2027            } else if p.port_poe {
2028                "off".into()
2029            } else {
2030                "-".into()
2031            };
2032
2033            let poe_color = if p.poe_enable && p.poe_power.is_some_and(|w| w > 0.0) {
2034                Color::Yellow
2035            } else {
2036                DIM_COLOR
2037            };
2038
2039            Row::new(vec![
2040                Cell::from(idx.to_string()).style(
2041                    Style::default()
2042                        .fg(Color::White)
2043                        .add_modifier(Modifier::BOLD),
2044                ),
2045                Cell::from(p.name.as_deref().unwrap_or("-").to_string())
2046                    .style(Style::default().fg(Color::White)),
2047                Cell::from(link_str).style(Style::default().fg(link_color)),
2048                Cell::from(speed_str).style(Style::default().fg(link_color)),
2049                Cell::from(poe_str).style(Style::default().fg(poe_color)),
2050                Cell::from(format_rate(tx_rate)).style(Style::default().fg(if tx_rate >= 1024.0 {
2051                    Color::Green
2052                } else {
2053                    DIM_COLOR
2054                })),
2055                Cell::from(format_rate(rx_rate)).style(Style::default().fg(if rx_rate >= 1024.0 {
2056                    Color::Green
2057                } else {
2058                    DIM_COLOR
2059                })),
2060                Cell::from(p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
2061                    .style(Style::default().fg(DIM_COLOR)),
2062                Cell::from(p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
2063                    .style(Style::default().fg(DIM_COLOR)),
2064            ])
2065        })
2066        .collect();
2067
2068    let widths = [
2069        Constraint::Length(5),
2070        Constraint::Min(14),
2071        Constraint::Length(8),
2072        Constraint::Length(10),
2073        Constraint::Length(8),
2074        Constraint::Length(12),
2075        Constraint::Length(12),
2076        Constraint::Length(10),
2077        Constraint::Length(10),
2078    ];
2079
2080    if ports.is_empty() {
2081        let empty = Paragraph::new(Line::from(Span::styled(
2082            "No ports found (not a switch or router)",
2083            Style::default().fg(DIM_COLOR),
2084        )))
2085        .block(block)
2086        .alignment(Alignment::Center);
2087        f.render_widget(empty, chunks[0]);
2088    } else {
2089        let table = Table::new(rows, widths)
2090            .header(header)
2091            .block(block)
2092            .row_highlight_style(Style::default().bg(SELECTED_BG));
2093        f.render_widget(table, chunks[0]);
2094    }
2095
2096    // Footer
2097    let error_span = if let Some(ref err) = state.last_error {
2098        Span::styled(
2099            format!(" \u{26a0} {err} "),
2100            Style::default().fg(OFFLINE_COLOR),
2101        )
2102    } else {
2103        Span::raw("")
2104    };
2105
2106    let footer = Line::from(vec![
2107        Span::styled(
2108            " q",
2109            Style::default()
2110                .fg(ACCENT_COLOR)
2111                .add_modifier(Modifier::BOLD),
2112        ),
2113        Span::styled(" quit  ", Style::default().fg(DIM_COLOR)),
2114        Span::styled(
2115            "\u{2191}\u{2193}",
2116            Style::default()
2117                .fg(ACCENT_COLOR)
2118                .add_modifier(Modifier::BOLD),
2119        ),
2120        Span::styled(" scroll", Style::default().fg(DIM_COLOR)),
2121        error_span,
2122        Span::raw("  "),
2123        Span::styled(
2124            format!("\u{21bb} {}s", state.interval_secs),
2125            Style::default().fg(DIM_COLOR),
2126        ),
2127    ]);
2128    f.render_widget(Paragraph::new(footer), chunks[1]);
2129}
2130
2131pub async fn run_ports(
2132    api: &UnifiClient,
2133    mac: &str,
2134    interval_secs: u64,
2135) -> Result<(), Box<dyn std::error::Error>> {
2136    enable_raw_mode()?;
2137    let mut stdout = io::stdout();
2138    execute!(stdout, EnterAlternateScreen)?;
2139    let backend = CrosstermBackend::new(stdout);
2140    let mut terminal = Terminal::new(backend)?;
2141
2142    let mut state = PortsState::new(interval_secs);
2143    let tick_rate = Duration::from_secs(interval_secs);
2144    let mut last_tick = Instant::now() - tick_rate;
2145
2146    let result = loop {
2147        if last_tick.elapsed() >= tick_rate {
2148            match api.get_device_ports(mac).await {
2149                Ok(device) => {
2150                    state.device = Some(device);
2151                    state.update_port_rates();
2152                    state.last_error = None;
2153                }
2154                Err(e) => {
2155                    state.last_error = Some(e.to_string());
2156                }
2157            }
2158            last_tick = Instant::now();
2159        }
2160
2161        terminal.draw(|f| draw_ports(f, &state))?;
2162
2163        if event::poll(Duration::from_millis(100))?
2164            && let Event::Key(key) = event::read()?
2165        {
2166            if key.kind != KeyEventKind::Press {
2167                continue;
2168            }
2169
2170            match key.code {
2171                KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
2172                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
2173                KeyCode::Up | KeyCode::Char('k') => {
2174                    state.scroll = state.scroll.saturating_sub(1);
2175                }
2176                KeyCode::Down | KeyCode::Char('j') => {
2177                    let max = state
2178                        .device
2179                        .as_ref()
2180                        .map(|d| d.port_table.len())
2181                        .unwrap_or(0);
2182                    if state.scroll + 1 < max {
2183                        state.scroll += 1;
2184                    }
2185                }
2186                _ => {}
2187            }
2188        }
2189    };
2190
2191    disable_raw_mode()?;
2192    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
2193    terminal.show_cursor()?;
2194
2195    result
2196}
2197
2198#[cfg(test)]
2199mod tests {
2200    use super::*;
2201
2202    #[test]
2203    fn format_rate_zero() {
2204        assert_eq!(format_rate(0.0), "0 B/s");
2205    }
2206
2207    #[test]
2208    fn format_rate_bytes() {
2209        assert_eq!(format_rate(512.0), "512 B/s");
2210    }
2211
2212    #[test]
2213    fn format_rate_kilobytes() {
2214        assert_eq!(format_rate(10240.0), "10.0 KB/s");
2215    }
2216
2217    #[test]
2218    fn format_rate_megabytes() {
2219        assert_eq!(format_rate(5_242_880.0), "5.0 MB/s");
2220    }
2221
2222    #[test]
2223    fn format_rate_gigabytes() {
2224        assert_eq!(format_rate(1_073_741_824.0), "1.0 GB/s");
2225    }
2226
2227    #[test]
2228    fn ip_sort_key_ordering() {
2229        let mut ips = vec!["10.0.0.2", "10.0.0.10", "10.0.0.1", "192.168.1.1"];
2230        ips.sort_by_key(|ip| ip_sort_key(ip));
2231        assert_eq!(
2232            ips,
2233            vec!["10.0.0.1", "10.0.0.2", "10.0.0.10", "192.168.1.1"]
2234        );
2235    }
2236
2237    #[test]
2238    fn sort_mode_cycles() {
2239        assert_eq!(SortMode::Bandwidth.next(), SortMode::Name);
2240        assert_eq!(SortMode::Name.next(), SortMode::Ip);
2241        assert_eq!(SortMode::Ip.next(), SortMode::Bandwidth);
2242    }
2243
2244    #[test]
2245    fn app_state_scroll_bounds() {
2246        let mut state = AppState::new();
2247        state.cursor_up();
2248        assert_eq!(state.client_cursor, 0);
2249
2250        state.cursor_down(3, 2);
2251        assert_eq!(state.client_cursor, 1);
2252        state.cursor_down(3, 2);
2253        assert_eq!(state.client_cursor, 2);
2254        state.cursor_down(3, 2);
2255        assert_eq!(state.client_cursor, 2); // capped
2256
2257        state.cursor_up();
2258        assert_eq!(state.client_cursor, 1);
2259    }
2260
2261    #[test]
2262    fn device_state_str_values() {
2263        assert_eq!(device_state_str(Some(1)).0, "ONLINE");
2264        assert_eq!(device_state_str(Some(0)).0, "OFFLINE");
2265        assert_eq!(device_state_str(Some(2)).0, "ADOPTING");
2266        assert_eq!(device_state_str(None).0, "UNKNOWN");
2267    }
2268}