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, KeyEvent, 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
93/// Outcome of handling a key press. The pure `handle_key` reports side effects
94/// here rather than performing them, so the event loop owns quitting and task
95/// spawning while the input logic stays testable without a terminal.
96enum InputOutcome {
97    Continue,
98    Quit,
99    Spawn(PendingAction),
100}
101
102struct AppState {
103    sysinfo: Option<SysInfo>,
104    host_system: Option<HostSystem>,
105    health: Vec<HealthSubsystem>,
106    clients: Vec<LegacyClient>,
107    devices: Vec<LegacyDevice>,
108    device_names: HashMap<String, String>, // normalized MAC -> device name
109    focus: Panel,
110    sort: SortMode,
111    client_cursor: usize,
112    client_offset: usize,
113    device_scroll: usize,
114    filter: String,
115    filtering: bool,
116    overlay: Option<Overlay>,
117    loading: bool,
118    last_error: Option<String>,
119    status_msg: Option<(String, Instant)>,
120    locating: HashMap<String, bool>,
121}
122
123impl AppState {
124    fn new() -> Self {
125        Self {
126            sysinfo: None,
127            host_system: None,
128            health: Vec::new(),
129            clients: Vec::new(),
130            devices: Vec::new(),
131            device_names: HashMap::new(),
132            focus: Panel::Clients,
133            sort: SortMode::Bandwidth,
134            client_cursor: 0,
135            client_offset: 0,
136            device_scroll: 0,
137            filter: String::new(),
138            filtering: false,
139            overlay: None,
140            loading: true,
141            last_error: None,
142            status_msg: None,
143            locating: HashMap::new(),
144        }
145    }
146
147    fn rebuild_device_names(&mut self) {
148        self.device_names = self
149            .devices
150            .iter()
151            .filter_map(|d| {
152                let mac = crate::api::normalize_mac(d.mac.as_deref()?);
153                let name = d.name.as_deref()?.to_string();
154                Some((mac, name))
155            })
156            .collect();
157    }
158
159    fn resolve_device_name(&self, mac: &str) -> Option<&str> {
160        self.device_names
161            .get(&crate::api::normalize_mac(mac))
162            .map(|s| s.as_str())
163    }
164
165    fn sorted_clients(&self) -> Vec<&LegacyClient> {
166        let mut clients: Vec<&LegacyClient> = self
167            .clients
168            .iter()
169            .filter(|c| {
170                if self.filter.is_empty() {
171                    return true;
172                }
173                let needle = self.filter.to_lowercase();
174                let name = c.display_name().to_lowercase();
175                let ip = c.ip.as_deref().unwrap_or("").to_lowercase();
176                let mac = c.mac.as_deref().unwrap_or("").to_lowercase();
177                name.contains(&needle) || ip.contains(&needle) || mac.contains(&needle)
178            })
179            .collect();
180
181        match self.sort {
182            SortMode::Bandwidth => {
183                clients.sort_by(|a, b| {
184                    let total_a = a.tx_bytes.unwrap_or(0) + a.rx_bytes.unwrap_or(0);
185                    let total_b = b.tx_bytes.unwrap_or(0) + b.rx_bytes.unwrap_or(0);
186                    total_b.cmp(&total_a)
187                });
188            }
189            SortMode::Name => {
190                clients.sort_by_key(|c| c.display_name().to_lowercase());
191            }
192            SortMode::Ip => {
193                clients.sort_by(|a, b| {
194                    let ip_a = a.ip.as_deref().unwrap_or("255.255.255.255");
195                    let ip_b = b.ip.as_deref().unwrap_or("255.255.255.255");
196                    ip_sort_key(ip_a).cmp(&ip_sort_key(ip_b))
197                });
198            }
199        }
200
201        clients
202    }
203
204    fn ap_devices(&self) -> Vec<&LegacyDevice> {
205        self.devices
206            .iter()
207            .filter(|d| d.device_type.as_deref().is_some_and(|t| t == "uap"))
208            .collect()
209    }
210
211    fn cursor_up(&mut self) {
212        match self.focus {
213            Panel::Clients => {
214                self.client_cursor = self.client_cursor.saturating_sub(1);
215            }
216            Panel::Devices => {
217                self.device_scroll = self.device_scroll.saturating_sub(1);
218            }
219        }
220    }
221
222    fn cursor_down(&mut self, max_clients: usize, max_devices: usize) {
223        match self.focus {
224            Panel::Clients => {
225                if self.client_cursor + 1 < max_clients {
226                    self.client_cursor += 1;
227                }
228            }
229            Panel::Devices => {
230                if self.device_scroll + 1 < max_devices {
231                    self.device_scroll += 1;
232                }
233            }
234        }
235    }
236
237    fn page_up(&mut self, page_size: usize) {
238        match self.focus {
239            Panel::Clients => {
240                self.client_cursor = self.client_cursor.saturating_sub(page_size);
241            }
242            Panel::Devices => {
243                self.device_scroll = self.device_scroll.saturating_sub(page_size);
244            }
245        }
246    }
247
248    fn page_down(&mut self, max_clients: usize, max_devices: usize, page_size: usize) {
249        match self.focus {
250            Panel::Clients => {
251                let max = max_clients.saturating_sub(1);
252                self.client_cursor = (self.client_cursor + page_size).min(max);
253            }
254            Panel::Devices => {
255                let max = max_devices.saturating_sub(1);
256                self.device_scroll = (self.device_scroll + page_size).min(max);
257            }
258        }
259    }
260
261    /// Adjust client_offset so that client_cursor is visible within visible_height rows
262    fn ensure_client_visible(&mut self, visible_height: usize) {
263        if visible_height == 0 {
264            return;
265        }
266        if self.client_cursor < self.client_offset {
267            self.client_offset = self.client_cursor;
268        } else if self.client_cursor >= self.client_offset + visible_height {
269            self.client_offset = self.client_cursor - visible_height + 1;
270        }
271    }
272
273    /// Apply a key press to the state and report what the event loop should do.
274    /// This is the pure half of the TUI: it mutates state and describes side
275    /// effects (quit, spawn an async action) without performing them, so the
276    /// full input behavior can be exercised in tests without a terminal.
277    fn handle_key(&mut self, key: KeyEvent) -> InputOutcome {
278        if self.filtering {
279            match key.code {
280                KeyCode::Esc => {
281                    self.filtering = false;
282                    self.filter.clear();
283                }
284                KeyCode::Enter => {
285                    self.filtering = false;
286                }
287                KeyCode::Backspace => {
288                    self.filter.pop();
289                }
290                KeyCode::Char(c) => {
291                    self.filter.push(c);
292                    self.client_cursor = 0;
293                }
294                _ => {}
295            }
296            return InputOutcome::Continue;
297        }
298
299        if self.overlay.is_some() {
300            // ApPicker has its own navigation and selection handling.
301            if let Some(Overlay::ApPicker {
302                client_idx,
303                ap_cursor,
304            }) = &self.overlay
305            {
306                let client_idx = *client_idx;
307                let ap_cursor = *ap_cursor;
308                match key.code {
309                    KeyCode::Esc => {
310                        self.overlay = Some(Overlay::ClientDetail(client_idx));
311                    }
312                    KeyCode::Char('q') => return InputOutcome::Quit,
313                    KeyCode::Up | KeyCode::Char('k') => {
314                        self.overlay = Some(Overlay::ApPicker {
315                            client_idx,
316                            ap_cursor: ap_cursor.saturating_sub(1),
317                        });
318                    }
319                    KeyCode::Down | KeyCode::Char('j') => {
320                        let max = self.ap_devices().len().saturating_sub(1);
321                        self.overlay = Some(Overlay::ApPicker {
322                            client_idx,
323                            ap_cursor: (ap_cursor + 1).min(max),
324                        });
325                    }
326                    KeyCode::Enter => {
327                        let macs = {
328                            let clients = self.sorted_clients();
329                            let aps = self.ap_devices();
330                            clients
331                                .get(client_idx)
332                                .and_then(|c| c.mac.clone())
333                                .zip(aps.get(ap_cursor).and_then(|ap| ap.mac.clone()))
334                        };
335                        if let Some((mac, ap_mac)) = macs {
336                            self.overlay = None;
337                            return InputOutcome::Spawn(PendingAction::Client(
338                                ClientAction::LockToAp { mac, ap_mac },
339                            ));
340                        }
341                    }
342                    _ => {}
343                }
344                return InputOutcome::Continue;
345            }
346
347            // Confirm dialog: y/Y runs the pending action, n/N/Esc cancels.
348            if matches!(&self.overlay, Some(Overlay::Confirm { .. })) {
349                match key.code {
350                    KeyCode::Char('y') | KeyCode::Char('Y') => {
351                        if let Some(Overlay::Confirm { action, .. }) = self.overlay.take() {
352                            return InputOutcome::Spawn(action);
353                        }
354                    }
355                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
356                        self.overlay = None;
357                    }
358                    KeyCode::Char('q') => return InputOutcome::Quit,
359                    _ => {}
360                }
361                return InputOutcome::Continue;
362            }
363
364            match key.code {
365                KeyCode::Esc => {
366                    self.overlay = None;
367                }
368                KeyCode::Char('q') => return InputOutcome::Quit,
369                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
370                    return InputOutcome::Quit;
371                }
372                KeyCode::Char('k') | KeyCode::Char('b') | KeyCode::Char('a') => {
373                    if let Some(Overlay::ClientDetail(idx)) = &self.overlay {
374                        let idx = *idx;
375                        let info = self.sorted_clients().get(idx).and_then(|c| {
376                            c.mac.clone().map(|mac| {
377                                (
378                                    mac,
379                                    c.display_name().to_string(),
380                                    c.is_wired,
381                                    c.fixed_ap_enabled,
382                                    c.blocked,
383                                )
384                            })
385                        });
386                        if let Some((mac, name, is_wired, fixed_ap_enabled, blocked)) = info {
387                            match key.code {
388                                KeyCode::Char('a') if !is_wired => {
389                                    if fixed_ap_enabled {
390                                        self.overlay = Some(Overlay::Confirm {
391                                            message: format!("Unlock {name} from AP?"),
392                                            action: PendingAction::Client(
393                                                ClientAction::UnlockFromAp(mac),
394                                            ),
395                                        });
396                                    } else {
397                                        self.overlay = Some(Overlay::ApPicker {
398                                            client_idx: idx,
399                                            ap_cursor: 0,
400                                        });
401                                    }
402                                }
403                                KeyCode::Char('k') => {
404                                    self.overlay = Some(Overlay::Confirm {
405                                        message: format!("Kick {name}?"),
406                                        action: PendingAction::Client(ClientAction::Kick(mac)),
407                                    });
408                                }
409                                KeyCode::Char('b') => {
410                                    let (action, verb) = if blocked {
411                                        (ClientAction::Unblock(mac), "Unblock")
412                                    } else {
413                                        (ClientAction::Block(mac), "Block")
414                                    };
415                                    self.overlay = Some(Overlay::Confirm {
416                                        message: format!("{verb} {name}?"),
417                                        action: PendingAction::Client(action),
418                                    });
419                                }
420                                _ => {}
421                            }
422                        }
423                    }
424                }
425                KeyCode::Char('r') | KeyCode::Char('u') | KeyCode::Char('l') => {
426                    if let Some(Overlay::DeviceDetail(idx)) = &self.overlay {
427                        let idx = *idx;
428                        let info = self.devices.get(idx).and_then(|d| {
429                            d.mac.clone().map(|mac| {
430                                (
431                                    mac,
432                                    d.name.as_deref().unwrap_or("device").to_string(),
433                                    d.upgradable,
434                                )
435                            })
436                        });
437                        if let Some((mac, name, upgradable)) = info {
438                            match key.code {
439                                KeyCode::Char('r') => {
440                                    self.overlay = Some(Overlay::Confirm {
441                                        message: format!("Restart {name}?"),
442                                        action: PendingAction::Device(DeviceAction::Restart(mac)),
443                                    });
444                                }
445                                KeyCode::Char('u') if upgradable => {
446                                    self.overlay = Some(Overlay::Confirm {
447                                        message: format!("Upgrade firmware on {name}?"),
448                                        action: PendingAction::Device(DeviceAction::Upgrade(mac)),
449                                    });
450                                }
451                                KeyCode::Char('l') => {
452                                    // Locate is safe/reversible, so no confirmation is needed.
453                                    let normalized = crate::api::normalize_mac(&mac);
454                                    let currently_locating =
455                                        self.locating.get(&normalized).copied().unwrap_or(false);
456                                    self.locating.insert(normalized, !currently_locating);
457                                    return InputOutcome::Spawn(PendingAction::Device(
458                                        DeviceAction::Locate(mac, !currently_locating),
459                                    ));
460                                }
461                                _ => {}
462                            }
463                        }
464                    }
465                }
466                _ => {}
467            }
468            return InputOutcome::Continue;
469        }
470
471        match key.code {
472            KeyCode::Char('q') => return InputOutcome::Quit,
473            KeyCode::Esc => return InputOutcome::Quit,
474            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
475                return InputOutcome::Quit;
476            }
477            KeyCode::Enter => {
478                self.overlay = match self.focus {
479                    Panel::Clients => {
480                        if self.sorted_clients().is_empty() {
481                            None
482                        } else {
483                            Some(Overlay::ClientDetail(self.client_cursor))
484                        }
485                    }
486                    Panel::Devices => {
487                        if self.devices.is_empty() {
488                            None
489                        } else {
490                            Some(Overlay::DeviceDetail(self.device_scroll))
491                        }
492                    }
493                };
494            }
495            KeyCode::Tab => {
496                self.focus = match self.focus {
497                    Panel::Clients => Panel::Devices,
498                    Panel::Devices => Panel::Clients,
499                };
500            }
501            KeyCode::Char('s') => {
502                self.sort = self.sort.next();
503            }
504            KeyCode::Char('/') => {
505                self.filtering = true;
506                self.filter.clear();
507            }
508            KeyCode::Up | KeyCode::Char('k') => {
509                self.cursor_up();
510            }
511            KeyCode::Down | KeyCode::Char('j') => {
512                let max_c = self.sorted_clients().len();
513                let max_d = self.devices.len();
514                self.cursor_down(max_c, max_d);
515            }
516            KeyCode::PageUp => {
517                self.page_up(10);
518            }
519            KeyCode::PageDown => {
520                let max_c = self.sorted_clients().len();
521                let max_d = self.devices.len();
522                self.page_down(max_c, max_d, 10);
523            }
524            KeyCode::Home => match self.focus {
525                Panel::Clients => self.client_cursor = 0,
526                Panel::Devices => self.device_scroll = 0,
527            },
528            KeyCode::End => match self.focus {
529                Panel::Clients => {
530                    self.client_cursor = self.sorted_clients().len().saturating_sub(1);
531                }
532                Panel::Devices => {
533                    self.device_scroll = self.devices.len().saturating_sub(1);
534                }
535            },
536            _ => {}
537        }
538        InputOutcome::Continue
539    }
540}
541
542fn ip_sort_key(ip: &str) -> Vec<u32> {
543    ip.split('.')
544        .filter_map(|s| s.parse::<u32>().ok())
545        .collect()
546}
547
548fn format_rate(bytes_per_sec: f64) -> String {
549    if bytes_per_sec >= 1_073_741_824.0 {
550        format!("{:.1} GB/s", bytes_per_sec / 1_073_741_824.0)
551    } else if bytes_per_sec >= 1_048_576.0 {
552        format!("{:.1} MB/s", bytes_per_sec / 1_048_576.0)
553    } else if bytes_per_sec >= 1024.0 {
554        format!("{:.1} KB/s", bytes_per_sec / 1024.0)
555    } else if bytes_per_sec >= 1.0 {
556        format!("{:.0} B/s", bytes_per_sec)
557    } else {
558        "0 B/s".into()
559    }
560}
561
562fn signal_bar(dbm: i32) -> &'static str {
563    match dbm {
564        -50..=0 => "▂▄▆█",
565        -60..=-51 => "▂▄▆░",
566        -70..=-61 => "▂▄░░",
567        -80..=-71 => "▂░░░",
568        _ => "░░░░",
569    }
570}
571
572fn signal_color(dbm: i32) -> Color {
573    match dbm {
574        -50..=0 => ONLINE_COLOR,
575        -60..=-51 => ONLINE_COLOR,
576        -70..=-61 => WARN_COLOR,
577        _ => OFFLINE_COLOR,
578    }
579}
580
581fn status_color(status: &str) -> Color {
582    match status {
583        "ok" => ONLINE_COLOR,
584        "unknown" => DIM_COLOR,
585        _ => WARN_COLOR,
586    }
587}
588
589fn device_state_str(state: Option<u32>) -> (&'static str, Color) {
590    match state {
591        Some(1) => ("ONLINE", ONLINE_COLOR),
592        Some(0) => ("OFFLINE", OFFLINE_COLOR),
593        Some(2) => ("ADOPTING", WARN_COLOR),
594        Some(4) => ("UPGRADING", WARN_COLOR),
595        Some(5) => ("PROVISIONING", WARN_COLOR),
596        _ => ("UNKNOWN", DIM_COLOR),
597    }
598}
599
600async fn fetch_data_standalone(
601    http: &reqwest::Client,
602    base_url: &str,
603) -> Result<
604    (
605        Option<SysInfo>,
606        Option<HostSystem>,
607        Vec<HealthSubsystem>,
608        Vec<LegacyClient>,
609        Vec<LegacyDevice>,
610    ),
611    ApiError,
612> {
613    let sysinfo: Option<SysInfo> = legacy_get(http, base_url, "/stat/sysinfo")
614        .await
615        .ok()
616        .and_then(|mut v: Vec<SysInfo>| v.pop());
617
618    let host_system: Option<HostSystem> = async {
619        let url = format!("{base_url}/api/system");
620        let resp = http.get(&url).send().await.ok()?;
621        if !resp.status().is_success() {
622            return None;
623        }
624        resp.json::<HostSystem>().await.ok()
625    }
626    .await;
627
628    let health: Vec<HealthSubsystem> = legacy_get(http, base_url, "/stat/health")
629        .await
630        .unwrap_or_default();
631    let clients: Vec<LegacyClient> = legacy_get(http, base_url, "/stat/sta").await?;
632    let devices: Vec<LegacyDevice> = legacy_get(http, base_url, "/stat/device")
633        .await
634        .unwrap_or_default();
635
636    Ok((sysinfo, host_system, health, clients, devices))
637}
638
639async fn legacy_get<T: serde::de::DeserializeOwned>(
640    http: &reqwest::Client,
641    base_url: &str,
642    path: &str,
643) -> Result<Vec<T>, ApiError> {
644    use crate::api::types::LegacyResponse;
645    let url = format!("{base_url}/proxy/network/api/s/default{path}");
646    let resp = http.get(&url).send().await?;
647    let status = resp.status().as_u16();
648    if !resp.status().is_success() {
649        let body = resp.text().await.unwrap_or_default();
650        return Err(crate::api::error_for_status(status, body));
651    }
652    let legacy: LegacyResponse<T> = resp.json().await?;
653    if legacy.meta.rc != "ok" {
654        return Err(ApiError::Api {
655            status: 200,
656            message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
657        });
658    }
659    Ok(legacy.data)
660}
661
662async fn legacy_put(
663    http: &reqwest::Client,
664    base_url: &str,
665    path: &str,
666    body: &serde_json::Value,
667) -> Result<(), String> {
668    let url = format!("{base_url}/proxy/network/api/s/default{path}");
669    let resp = http
670        .put(&url)
671        .json(body)
672        .send()
673        .await
674        .map_err(|e| e.to_string())?;
675    if !resp.status().is_success() {
676        let status = resp.status().as_u16();
677        let body = resp.text().await.unwrap_or_default();
678        return Err(format!("API error ({status}): {body}"));
679    }
680    Ok(())
681}
682
683async fn find_client_id(
684    http: &reqwest::Client,
685    base_url: &str,
686    mac: &str,
687) -> Result<String, String> {
688    let normalized = crate::api::normalize_mac(mac);
689    let clients: Vec<LegacyClient> = legacy_get(http, base_url, "/stat/sta")
690        .await
691        .map_err(|e| e.to_string())?;
692    clients
693        .into_iter()
694        .find(|c| {
695            c.mac
696                .as_deref()
697                .is_some_and(|m| crate::api::normalize_mac(m) == normalized)
698        })
699        .map(|c| c.id)
700        .ok_or_else(|| format!("Client {mac} not found"))
701}
702
703async fn legacy_post_cmd(
704    http: &reqwest::Client,
705    base_url: &str,
706    manager: &str,
707    body: serde_json::Value,
708) -> Result<(), String> {
709    let url = format!("{base_url}/proxy/network/api/s/default/cmd/{manager}");
710    let resp = http
711        .post(&url)
712        .json(&body)
713        .send()
714        .await
715        .map_err(|e| e.to_string())?;
716    if !resp.status().is_success() {
717        let body = resp.text().await.unwrap_or_default();
718        return Err(format!("API error: {body}"));
719    }
720    Ok(())
721}
722
723async fn execute_client_action(
724    http: &reqwest::Client,
725    base_url: &str,
726    action: ClientAction,
727) -> Result<String, String> {
728    match action {
729        ClientAction::Kick(mac) => {
730            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
731            legacy_post_cmd(
732                http,
733                base_url,
734                "stamgr",
735                serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
736            )
737            .await?;
738            Ok(format!("Kicked {formatted}"))
739        }
740        ClientAction::Block(mac) => {
741            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
742            legacy_post_cmd(
743                http,
744                base_url,
745                "stamgr",
746                serde_json::json!({"cmd": "block-sta", "mac": formatted}),
747            )
748            .await?;
749            Ok(format!("Blocked {formatted}"))
750        }
751        ClientAction::Unblock(mac) => {
752            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
753            legacy_post_cmd(
754                http,
755                base_url,
756                "stamgr",
757                serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
758            )
759            .await?;
760            Ok(format!("Unblocked {formatted}"))
761        }
762        ClientAction::LockToAp { mac, ap_mac } => {
763            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
764            let ap_formatted = crate::api::format_mac(&crate::api::normalize_mac(&ap_mac));
765            let client_id = find_client_id(http, base_url, &mac).await?;
766            let payload = serde_json::json!({
767                "mac": formatted,
768                "fixed_ap_enabled": true,
769                "fixed_ap_mac": ap_formatted,
770            });
771            legacy_put(http, base_url, &format!("/rest/user/{client_id}"), &payload).await?;
772            Ok(format!("Locked to AP {ap_formatted}"))
773        }
774        ClientAction::UnlockFromAp(mac) => {
775            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
776            let client_id = find_client_id(http, base_url, &mac).await?;
777            let payload = serde_json::json!({
778                "mac": formatted,
779                "fixed_ap_enabled": false,
780            });
781            legacy_put(http, base_url, &format!("/rest/user/{client_id}"), &payload).await?;
782            Ok("Unlocked from AP".to_string())
783        }
784    }
785}
786
787async fn execute_device_action(
788    http: &reqwest::Client,
789    base_url: &str,
790    action: DeviceAction,
791) -> Result<String, String> {
792    match action {
793        DeviceAction::Restart(mac) => {
794            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
795            legacy_post_cmd(
796                http,
797                base_url,
798                "devmgr",
799                serde_json::json!({"cmd": "restart", "mac": formatted}),
800            )
801            .await?;
802            Ok(format!("Restarting {formatted}"))
803        }
804        DeviceAction::Upgrade(mac) => {
805            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
806            legacy_post_cmd(
807                http,
808                base_url,
809                "devmgr",
810                serde_json::json!({"cmd": "upgrade", "mac": formatted}),
811            )
812            .await?;
813            Ok(format!("Upgrading {formatted}"))
814        }
815        DeviceAction::Locate(mac, enable) => {
816            let formatted = crate::api::format_mac(&crate::api::normalize_mac(&mac));
817            let cmd = if enable { "set-locate" } else { "unset-locate" };
818            legacy_post_cmd(
819                http,
820                base_url,
821                "devmgr",
822                serde_json::json!({"cmd": cmd, "mac": formatted}),
823            )
824            .await?;
825            let action_str = if enable {
826                "Locating"
827            } else {
828                "Stopped locating"
829            };
830            Ok(format!("{action_str} {formatted}"))
831        }
832    }
833}
834
835fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
836    let info = state.sysinfo.as_ref();
837    let hostname = info
838        .and_then(|s| s.hostname.as_deref())
839        .unwrap_or("UniFi Controller");
840    let version = info.and_then(|s| s.version.as_deref()).unwrap_or("-");
841    let uptime_str = info
842        .and_then(|s| s.uptime)
843        .map(format_uptime)
844        .unwrap_or_else(|| "-".into());
845
846    let title = format!(" {} v{} │ Up {} ", hostname, version, uptime_str);
847
848    // Build health spans
849    let mut health_spans: Vec<Span> = vec![Span::raw("  ")];
850    for h in &state.health {
851        let color = status_color(h.status.as_deref().unwrap_or("unknown"));
852        let bullet = Span::styled("● ", Style::default().fg(color));
853        let sub = h.subsystem.to_uppercase();
854        let detail = match h.subsystem.as_str() {
855            "wan" => h
856                .wan_ip
857                .as_deref()
858                .map(|ip| format!(" ({ip})"))
859                .unwrap_or_default(),
860            "wlan" => {
861                let ap = h.num_ap.unwrap_or(0);
862                let sta = h.num_sta.unwrap_or(0);
863                format!(" ({ap} AP, {sta} sta)")
864            }
865            "lan" => {
866                let sw = h.num_switches.unwrap_or(0);
867                let sta = h.num_sta.unwrap_or(0);
868                format!(" ({sw} sw, {sta} sta)")
869            }
870            _ => String::new(),
871        };
872        health_spans.push(bullet);
873        health_spans.push(Span::styled(
874            format!("{sub}{detail}"),
875            Style::default().fg(Color::White),
876        ));
877        health_spans.push(Span::raw("  "));
878    }
879
880    if state
881        .host_system
882        .as_ref()
883        .is_some_and(|h| h.update_available())
884    {
885        health_spans.push(Span::styled(
886            "⬆ Update available",
887            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
888        ));
889    }
890
891    let block = Block::default()
892        .borders(Borders::ALL)
893        .border_type(BorderType::Rounded)
894        .border_style(Style::default().fg(HEADER_COLOR))
895        .title(Span::styled(
896            title,
897            Style::default()
898                .fg(HEADER_COLOR)
899                .add_modifier(Modifier::BOLD),
900        ));
901
902    let health_line = Line::from(health_spans);
903    let paragraph = Paragraph::new(health_line).block(block);
904    f.render_widget(paragraph, area);
905}
906
907fn draw_clients(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
908    let clients = state.sorted_clients();
909    let is_focused = state.focus == Panel::Clients;
910
911    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
912
913    let filter_info = if !state.filter.is_empty() {
914        format!(" │ filter: {}", state.filter)
915    } else {
916        String::new()
917    };
918
919    let pos_info = if !clients.is_empty() {
920        format!(" [{}/{}]", state.client_cursor + 1, clients.len())
921    } else {
922        String::new()
923    };
924
925    let title = format!(
926        " Clients ({}){} │ sort: {}{} ",
927        clients.len(),
928        pos_info,
929        state.sort.label(),
930        filter_info,
931    );
932
933    let block = Block::default()
934        .borders(Borders::ALL)
935        .border_type(BorderType::Rounded)
936        .border_style(Style::default().fg(border_color))
937        .title(Span::styled(
938            title,
939            Style::default()
940                .fg(border_color)
941                .add_modifier(Modifier::BOLD),
942        ));
943
944    let header_style = Style::default()
945        .fg(HEADER_COLOR)
946        .add_modifier(Modifier::BOLD);
947
948    let header = Row::new(vec![
949        Cell::from("Name").style(header_style),
950        Cell::from("Connection").style(header_style),
951        Cell::from("Signal").style(header_style),
952        Cell::from("IP").style(header_style),
953        Cell::from("Total").style(header_style),
954    ])
955    .height(1);
956
957    // Calculate visible area (subtract borders + header)
958    let inner_height = area.height.saturating_sub(4) as usize;
959
960    let rows: Vec<Row> = clients
961        .iter()
962        .enumerate()
963        .skip(state.client_offset)
964        .take(inner_height)
965        .map(|(i, c)| {
966            let total_bytes = c.tx_bytes.unwrap_or(0) + c.rx_bytes.unwrap_or(0);
967            let is_idle = total_bytes == 0;
968
969            let type_icon = if c.is_wired { "⌐ " } else { "◦ " };
970
971            // Show full MAC for unnamed clients
972            let display = if c.display_name() == "-" {
973                c.mac
974                    .as_deref()
975                    .map(crate::api::format_mac)
976                    .unwrap_or_else(|| "-".into())
977            } else {
978                c.display_name().to_string()
979            };
980            let name = format!("{type_icon}{display}");
981
982            let name_style = if is_idle {
983                Style::default().fg(DIM_COLOR)
984            } else {
985                Style::default()
986                    .fg(Color::White)
987                    .add_modifier(Modifier::BOLD)
988            };
989
990            let is_selected = is_focused && i == state.client_cursor;
991            let row_style = if is_selected {
992                Style::default().bg(SELECTED_BG)
993            } else {
994                Style::default()
995            };
996
997            let total_style = if is_idle {
998                Style::default().fg(DIM_COLOR)
999            } else {
1000                Style::default().fg(Color::White)
1001            };
1002
1003            // Connection info: AP name for wireless, "Wired" for wired
1004            // Signal bars in separate column for alignment
1005            let (conn_str, conn_color, sig_str, sig_color) = if c.is_wired {
1006                ("Wired".to_string(), DIM_COLOR, String::new(), DIM_COLOR)
1007            } else {
1008                let ap_name = c
1009                    .ap_mac
1010                    .as_deref()
1011                    .and_then(|m| state.resolve_device_name(m));
1012                let label = ap_name.unwrap_or(c.ssid.as_deref().unwrap_or("?"));
1013                let sig = c
1014                    .signal
1015                    .map(|s| signal_bar(s).to_string())
1016                    .unwrap_or_default();
1017                let color = c.signal.map(signal_color).unwrap_or(DIM_COLOR);
1018                (label.to_string(), color, sig, color)
1019            };
1020
1021            Row::new(vec![
1022                Cell::from(name).style(name_style),
1023                Cell::from(conn_str).style(Style::default().fg(conn_color)),
1024                Cell::from(sig_str).style(Style::default().fg(sig_color)),
1025                Cell::from(c.ip.as_deref().unwrap_or("-").to_string())
1026                    .style(Style::default().fg(DIM_COLOR)),
1027                Cell::from(format_bytes(total_bytes)).style(total_style),
1028            ])
1029            .style(row_style)
1030        })
1031        .collect();
1032
1033    let widths = [
1034        Constraint::Min(20),
1035        Constraint::Length(16),
1036        Constraint::Length(6),
1037        Constraint::Length(16),
1038        Constraint::Length(10),
1039    ];
1040
1041    if clients.is_empty() {
1042        let msg = if state.filter.is_empty() {
1043            "No clients connected"
1044        } else {
1045            "No clients match filter"
1046        };
1047        let empty = Paragraph::new(Line::from(Span::styled(
1048            msg,
1049            Style::default().fg(DIM_COLOR),
1050        )))
1051        .block(block)
1052        .alignment(Alignment::Center);
1053        f.render_widget(empty, area);
1054    } else {
1055        let table = Table::new(rows, widths)
1056            .header(header)
1057            .block(block)
1058            .row_highlight_style(Style::default().bg(SELECTED_BG));
1059        f.render_widget(table, area);
1060    }
1061}
1062
1063fn draw_devices(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
1064    let is_focused = state.focus == Panel::Devices;
1065    let border_color = if is_focused { ACCENT_COLOR } else { DIM_COLOR };
1066
1067    let dev_pos = if !state.devices.is_empty() {
1068        format!(" [{}/{}]", state.device_scroll + 1, state.devices.len())
1069    } else {
1070        String::new()
1071    };
1072    let title = format!(" Devices ({}){} ", state.devices.len(), dev_pos);
1073    let block = Block::default()
1074        .borders(Borders::ALL)
1075        .border_type(BorderType::Rounded)
1076        .border_style(Style::default().fg(border_color))
1077        .title(Span::styled(
1078            title,
1079            Style::default()
1080                .fg(border_color)
1081                .add_modifier(Modifier::BOLD),
1082        ));
1083
1084    let header = Row::new(vec![
1085        Cell::from("Name").style(
1086            Style::default()
1087                .fg(HEADER_COLOR)
1088                .add_modifier(Modifier::BOLD),
1089        ),
1090        Cell::from("Model").style(
1091            Style::default()
1092                .fg(HEADER_COLOR)
1093                .add_modifier(Modifier::BOLD),
1094        ),
1095        Cell::from("IP").style(
1096            Style::default()
1097                .fg(HEADER_COLOR)
1098                .add_modifier(Modifier::BOLD),
1099        ),
1100        Cell::from("State").style(
1101            Style::default()
1102                .fg(HEADER_COLOR)
1103                .add_modifier(Modifier::BOLD),
1104        ),
1105        Cell::from("Clients").style(
1106            Style::default()
1107                .fg(HEADER_COLOR)
1108                .add_modifier(Modifier::BOLD),
1109        ),
1110        Cell::from("Uptime").style(
1111            Style::default()
1112                .fg(HEADER_COLOR)
1113                .add_modifier(Modifier::BOLD),
1114        ),
1115        Cell::from("Firmware").style(
1116            Style::default()
1117                .fg(HEADER_COLOR)
1118                .add_modifier(Modifier::BOLD),
1119        ),
1120    ])
1121    .height(1);
1122
1123    let rows: Vec<Row> = state
1124        .devices
1125        .iter()
1126        .enumerate()
1127        .map(|(i, d)| {
1128            let (state_str, state_color) = device_state_str(d.state);
1129
1130            let is_selected = is_focused && i == state.device_scroll;
1131            let row_style = if is_selected {
1132                Style::default().bg(SELECTED_BG)
1133            } else {
1134                Style::default()
1135            };
1136
1137            Row::new(vec![
1138                Cell::from(d.name.as_deref().unwrap_or("-").to_string()).style(
1139                    Style::default()
1140                        .fg(Color::White)
1141                        .add_modifier(Modifier::BOLD),
1142                ),
1143                Cell::from(d.model.as_deref().unwrap_or("-").to_string())
1144                    .style(Style::default().fg(DIM_COLOR)),
1145                Cell::from(d.ip.as_deref().unwrap_or("-").to_string())
1146                    .style(Style::default().fg(DIM_COLOR)),
1147                Cell::from(format!("● {state_str}")).style(Style::default().fg(state_color)),
1148                Cell::from(
1149                    d.num_sta
1150                        .map(|n| n.to_string())
1151                        .unwrap_or_else(|| "-".into()),
1152                )
1153                .style(Style::default().fg(Color::White)),
1154                Cell::from(d.uptime.map(format_uptime).unwrap_or_else(|| "-".into()))
1155                    .style(Style::default().fg(DIM_COLOR)),
1156                Cell::from(d.version.as_deref().unwrap_or("-").to_string())
1157                    .style(Style::default().fg(DIM_COLOR)),
1158            ])
1159            .style(row_style)
1160        })
1161        .collect();
1162
1163    let widths = [
1164        Constraint::Min(18),
1165        Constraint::Length(10),
1166        Constraint::Length(16),
1167        Constraint::Length(12),
1168        Constraint::Length(8),
1169        Constraint::Length(16),
1170        Constraint::Length(14),
1171    ];
1172
1173    if state.devices.is_empty() {
1174        let empty = Paragraph::new(Line::from(Span::styled(
1175            "No devices found",
1176            Style::default().fg(DIM_COLOR),
1177        )))
1178        .block(block)
1179        .alignment(Alignment::Center);
1180        f.render_widget(empty, area);
1181    } else {
1182        let table = Table::new(rows, widths).header(header).block(block);
1183        f.render_widget(table, area);
1184    }
1185}
1186
1187fn draw_footer(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
1188    let error_span = if let Some(ref err) = state.last_error {
1189        Span::styled(format!(" ⚠ {err} "), Style::default().fg(OFFLINE_COLOR))
1190    } else {
1191        Span::raw("")
1192    };
1193
1194    let key_style = Style::default()
1195        .fg(ACCENT_COLOR)
1196        .add_modifier(Modifier::BOLD);
1197    let dim = Style::default().fg(DIM_COLOR);
1198
1199    let status_span = if let Some((ref msg, _)) = state.status_msg {
1200        Span::styled(format!(" ✓ {msg} "), Style::default().fg(ONLINE_COLOR))
1201    } else {
1202        Span::raw("")
1203    };
1204
1205    let line = if state.overlay.is_some() {
1206        // Overlay hints are shown on the overlay itself
1207        Line::from(vec![error_span, status_span])
1208    } else if state.filtering {
1209        Line::from(vec![
1210            Span::styled(" ", Style::default()),
1211            Span::styled(
1212                format!("filter: {}▌", state.filter),
1213                Style::default()
1214                    .fg(Color::Yellow)
1215                    .add_modifier(Modifier::BOLD),
1216            ),
1217            Span::styled("  esc", key_style),
1218            Span::styled(" clear ", dim),
1219            Span::styled("enter", key_style),
1220            Span::styled(" apply", dim),
1221            error_span,
1222        ])
1223    } else {
1224        Line::from(vec![
1225            Span::styled(" q", key_style),
1226            Span::styled(" quit ", dim),
1227            Span::styled("s", key_style),
1228            Span::styled(" sort ", dim),
1229            Span::styled("/", key_style),
1230            Span::styled(" filter ", dim),
1231            Span::styled("enter", key_style),
1232            Span::styled(" details ", dim),
1233            Span::styled("tab", key_style),
1234            Span::styled(" switch panel", dim),
1235            error_span,
1236            status_span,
1237        ])
1238    };
1239
1240    let paragraph = Paragraph::new(line);
1241    f.render_widget(paragraph, area);
1242}
1243
1244fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
1245    let vertical = Layout::default()
1246        .direction(Direction::Vertical)
1247        .constraints([
1248            Constraint::Min(0),
1249            Constraint::Length(height),
1250            Constraint::Min(0),
1251        ])
1252        .split(area);
1253    Layout::default()
1254        .direction(Direction::Horizontal)
1255        .constraints([
1256            Constraint::Min(0),
1257            Constraint::Length(width),
1258            Constraint::Min(0),
1259        ])
1260        .split(vertical[1])[1]
1261}
1262
1263fn draw_overlay(f: &mut ratatui::Frame, state: &AppState) {
1264    let overlay = match &state.overlay {
1265        Some(o) => o,
1266        None => return,
1267    };
1268
1269    // Handle ApPicker and Confirm separately (different layout)
1270    if let Overlay::ApPicker {
1271        client_idx,
1272        ap_cursor,
1273    } = overlay
1274    {
1275        draw_ap_picker(f, state, *client_idx, *ap_cursor);
1276        return;
1277    }
1278    if let Overlay::Confirm { message, .. } = overlay {
1279        draw_confirm(f, message);
1280        return;
1281    }
1282
1283    // Count rows to size the overlay
1284    let row_count = match overlay {
1285        Overlay::ClientDetail(idx) => {
1286            let clients = state.sorted_clients();
1287            let c = match clients.get(*idx) {
1288                Some(c) => c,
1289                None => return,
1290            };
1291            let mut n = 3; // MAC, IP, Type
1292            if c.uptime.is_some() {
1293                n += 1;
1294            }
1295            if c.tx_bytes.is_some() {
1296                n += 1;
1297            }
1298            if c.rx_bytes.is_some() {
1299                n += 1;
1300            }
1301            if !c.is_wired {
1302                if c.signal.is_some() {
1303                    n += 1;
1304                }
1305                if c.ssid.is_some() {
1306                    n += 1;
1307                }
1308                if c.ap_mac.is_some() {
1309                    n += 1;
1310                }
1311                n += 1; // AP Lock row
1312            }
1313            n
1314        }
1315        Overlay::DeviceDetail(idx) => {
1316            let d = match state.devices.get(*idx) {
1317                Some(d) => d,
1318                None => return,
1319            };
1320            let mut n = 4; // Model, MAC, IP, State
1321            if d.version.is_some() {
1322                n += 1;
1323            }
1324            if d.uptime.is_some() {
1325                n += 1;
1326            }
1327            if d.num_sta.is_some() {
1328                n += 1;
1329            }
1330            if d.upgradable {
1331                n += 1;
1332            }
1333            n
1334        }
1335        Overlay::ApPicker { .. } | Overlay::Confirm { .. } => 0,
1336    };
1337
1338    // 2 borders + 1 header row gap + data rows
1339    let height = (row_count as u16 + 3).min(f.area().height.saturating_sub(4));
1340    let width = 44_u16.min(f.area().width.saturating_sub(4));
1341    let area = centered_rect_fixed(width, height, f.area());
1342    f.render_widget(Clear, area);
1343
1344    let hint_key = Style::default()
1345        .fg(ACCENT_COLOR)
1346        .add_modifier(Modifier::BOLD);
1347    let hint_dim = Style::default().fg(DIM_COLOR);
1348
1349    match overlay {
1350        Overlay::ClientDetail(idx) => {
1351            let clients = state.sorted_clients();
1352            let Some(c) = clients.get(*idx) else { return };
1353
1354            let title = format!(" {} ", c.display_name());
1355
1356            let block_label = if c.blocked { "unblock" } else { "block" };
1357            let mut hints = vec![
1358                Span::styled(" esc", hint_key),
1359                Span::styled(" back ", hint_dim),
1360                Span::styled("k", hint_key),
1361                Span::styled(" kick ", hint_dim),
1362                Span::styled("b", hint_key),
1363                Span::styled(format!(" {block_label} "), hint_dim),
1364            ];
1365            if !c.is_wired {
1366                let ap_label = if c.fixed_ap_enabled {
1367                    "unlock AP"
1368                } else {
1369                    "lock to AP"
1370                };
1371                hints.push(Span::styled("a", hint_key));
1372                hints.push(Span::styled(format!(" {ap_label} "), hint_dim));
1373            }
1374
1375            let block = Block::default()
1376                .borders(Borders::ALL)
1377                .border_type(BorderType::Rounded)
1378                .border_style(Style::default().fg(ACCENT_COLOR))
1379                .style(Style::default().bg(Color::Black))
1380                .title(Span::styled(
1381                    title,
1382                    Style::default()
1383                        .fg(ACCENT_COLOR)
1384                        .add_modifier(Modifier::BOLD),
1385                ))
1386                .title_bottom(Line::from(hints));
1387
1388            let mut rows = vec![
1389                detail_row(
1390                    "MAC",
1391                    &c.mac
1392                        .as_deref()
1393                        .map(crate::api::format_mac)
1394                        .unwrap_or_else(|| "-".into()),
1395                ),
1396                detail_row("IP", c.ip.as_deref().unwrap_or("-")),
1397                detail_row("Type", if c.is_wired { "Wired" } else { "Wireless" }),
1398            ];
1399
1400            if let Some(uptime) = c.uptime {
1401                rows.push(detail_row("Uptime", &format_uptime(uptime)));
1402            }
1403            if let Some(tx) = c.tx_bytes {
1404                rows.push(detail_row("TX", &format_bytes(tx)));
1405            }
1406            if let Some(rx) = c.rx_bytes {
1407                rows.push(detail_row("RX", &format_bytes(rx)));
1408            }
1409            if !c.is_wired {
1410                if let Some(signal) = c.signal {
1411                    rows.push(detail_row("Signal", &format!("{signal} dBm")));
1412                }
1413                if let Some(ref ssid) = c.ssid {
1414                    rows.push(detail_row("SSID", ssid));
1415                }
1416                if let Some(ref ap) = c.ap_mac {
1417                    let ap_label = state.resolve_device_name(ap).unwrap_or(ap.as_str());
1418                    rows.push(detail_row("AP", ap_label));
1419                }
1420                // AP Lock status
1421                let lock_value = if c.fixed_ap_enabled {
1422                    let ap_name = c.fixed_ap_mac.as_deref().map(|m| {
1423                        state
1424                            .resolve_device_name(m)
1425                            .map(String::from)
1426                            .unwrap_or_else(|| crate::api::format_mac(m))
1427                    });
1428                    format!("🔒 {}", ap_name.unwrap_or_else(|| "Yes".into()))
1429                } else {
1430                    "Off  (a to lock)".into()
1431                };
1432                rows.push(detail_row("AP Lock", &lock_value));
1433            }
1434
1435            let widths = [Constraint::Length(10), Constraint::Min(20)];
1436            let table = Table::new(rows, widths).block(block);
1437            f.render_widget(table, area);
1438        }
1439        Overlay::DeviceDetail(idx) => {
1440            let Some(d) = state.devices.get(*idx) else {
1441                return;
1442            };
1443
1444            let name = d.name.as_deref().unwrap_or("Device");
1445            let title = format!(" {name} ");
1446
1447            let locate_label = d
1448                .mac
1449                .as_ref()
1450                .map(|mac| {
1451                    let normalized = crate::api::normalize_mac(mac);
1452                    if state.locating.get(&normalized).copied().unwrap_or(false) {
1453                        "stop locate"
1454                    } else {
1455                        "locate"
1456                    }
1457                })
1458                .unwrap_or("locate");
1459
1460            let mut hints = vec![
1461                Span::styled(" esc", hint_key),
1462                Span::styled(" back ", hint_dim),
1463                Span::styled("r", hint_key),
1464                Span::styled(" restart ", hint_dim),
1465            ];
1466            if d.upgradable {
1467                hints.push(Span::styled("u", hint_key));
1468                hints.push(Span::styled(" upgrade ", hint_dim));
1469            }
1470            hints.push(Span::styled("l", hint_key));
1471            hints.push(Span::styled(format!(" {locate_label} "), hint_dim));
1472
1473            let block = Block::default()
1474                .borders(Borders::ALL)
1475                .border_type(BorderType::Rounded)
1476                .border_style(Style::default().fg(ACCENT_COLOR))
1477                .style(Style::default().bg(Color::Black))
1478                .title(Span::styled(
1479                    title,
1480                    Style::default()
1481                        .fg(ACCENT_COLOR)
1482                        .add_modifier(Modifier::BOLD),
1483                ))
1484                .title_bottom(Line::from(hints));
1485
1486            let (state_str, _) = device_state_str(d.state);
1487            let mut rows = vec![
1488                detail_row("Model", d.model.as_deref().unwrap_or("-")),
1489                detail_row(
1490                    "MAC",
1491                    &d.mac
1492                        .as_deref()
1493                        .map(crate::api::format_mac)
1494                        .unwrap_or_else(|| "-".into()),
1495                ),
1496                detail_row("IP", d.ip.as_deref().unwrap_or("-")),
1497                detail_row("State", state_str),
1498            ];
1499
1500            if let Some(ref v) = d.version {
1501                if d.upgradable {
1502                    if let Some(ref new_v) = d.upgrade_to_firmware {
1503                        rows.push(detail_row("Firmware", &format!("{v} → {new_v}")));
1504                    } else {
1505                        rows.push(detail_row("Firmware", &format!("{v} (update available)")));
1506                    }
1507                } else {
1508                    rows.push(detail_row("Firmware", v));
1509                }
1510            }
1511            if d.upgradable && d.version.is_none() {
1512                rows.push(detail_row("Firmware", "Update available"));
1513            }
1514            if let Some(uptime) = d.uptime {
1515                rows.push(detail_row("Uptime", &format_uptime(uptime)));
1516            }
1517            if let Some(num_sta) = d.num_sta {
1518                rows.push(detail_row("Clients", &num_sta.to_string()));
1519            }
1520
1521            let widths = [Constraint::Length(10), Constraint::Min(20)];
1522            let table = Table::new(rows, widths).block(block);
1523            f.render_widget(table, area);
1524        }
1525        Overlay::ApPicker { .. } | Overlay::Confirm { .. } => {}
1526    }
1527}
1528
1529fn detail_row(field: &str, value: &str) -> Row<'static> {
1530    Row::new(vec![
1531        Cell::from(field.to_string()).style(
1532            Style::default()
1533                .fg(HEADER_COLOR)
1534                .add_modifier(Modifier::BOLD),
1535        ),
1536        Cell::from(value.to_string()).style(Style::default().fg(Color::White)),
1537    ])
1538}
1539
1540fn draw_confirm(f: &mut ratatui::Frame, message: &str) {
1541    let width = (message.len() as u16 + 6).min(f.area().width.saturating_sub(4));
1542    let height = 3_u16;
1543    let area = centered_rect_fixed(width, height, f.area());
1544    f.render_widget(Clear, area);
1545
1546    let hint_key = Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD);
1547    let hint_dim = Style::default().fg(DIM_COLOR);
1548    let hints = vec![
1549        Span::styled(" y", hint_key),
1550        Span::styled(" confirm ", hint_dim),
1551        Span::styled(
1552            "n/esc",
1553            Style::default()
1554                .fg(ACCENT_COLOR)
1555                .add_modifier(Modifier::BOLD),
1556        ),
1557        Span::styled(" cancel ", hint_dim),
1558    ];
1559
1560    let block = Block::default()
1561        .borders(Borders::ALL)
1562        .border_type(BorderType::Rounded)
1563        .border_style(Style::default().fg(WARN_COLOR))
1564        .style(Style::default().bg(Color::Black))
1565        .title(Span::styled(
1566            " Confirm ",
1567            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
1568        ))
1569        .title_bottom(Line::from(hints));
1570
1571    let text = Line::from(Span::styled(
1572        message.to_string(),
1573        Style::default().fg(Color::White),
1574    ));
1575    let paragraph = Paragraph::new(text)
1576        .block(block)
1577        .alignment(Alignment::Center);
1578    f.render_widget(paragraph, area);
1579}
1580
1581fn draw_ap_picker(f: &mut ratatui::Frame, state: &AppState, client_idx: usize, ap_cursor: usize) {
1582    let clients = state.sorted_clients();
1583    let client = match clients.get(client_idx) {
1584        Some(c) => c,
1585        None => return,
1586    };
1587
1588    let aps = state.ap_devices();
1589    if aps.is_empty() {
1590        return;
1591    }
1592
1593    // Determine which AP the client is currently connected to
1594    let current_ap_mac = client.ap_mac.as_deref().map(crate::api::normalize_mac);
1595
1596    let client_name = client.display_name();
1597    let title = format!(" Lock {client_name} to AP ");
1598    let row_count = aps.len();
1599    let height = (row_count as u16 + 3).min(f.area().height.saturating_sub(4));
1600    let width = 50_u16.min(f.area().width.saturating_sub(4));
1601    let area = centered_rect_fixed(width, height, f.area());
1602    f.render_widget(Clear, area);
1603
1604    let hint_key = Style::default()
1605        .fg(ACCENT_COLOR)
1606        .add_modifier(Modifier::BOLD);
1607    let hint_dim = Style::default().fg(DIM_COLOR);
1608    let hints = vec![
1609        Span::styled(" ↑↓", hint_key),
1610        Span::styled(" select ", hint_dim),
1611        Span::styled("enter", hint_key),
1612        Span::styled(" lock ", hint_dim),
1613        Span::styled("esc", hint_key),
1614        Span::styled(" back ", hint_dim),
1615    ];
1616
1617    let block = Block::default()
1618        .borders(Borders::ALL)
1619        .border_type(BorderType::Rounded)
1620        .border_style(Style::default().fg(ACCENT_COLOR))
1621        .style(Style::default().bg(Color::Black))
1622        .title(Span::styled(
1623            title,
1624            Style::default()
1625                .fg(ACCENT_COLOR)
1626                .add_modifier(Modifier::BOLD),
1627        ))
1628        .title_bottom(Line::from(hints));
1629
1630    let rows: Vec<Row> =
1631        aps.iter()
1632            .enumerate()
1633            .map(|(i, ap)| {
1634                let name = ap.name.as_deref().unwrap_or("-");
1635                let mac = ap
1636                    .mac
1637                    .as_deref()
1638                    .map(crate::api::format_mac)
1639                    .unwrap_or_else(|| "-".into());
1640                let is_selected = i == ap_cursor;
1641                let is_current = ap.mac.as_deref().is_some_and(|m| {
1642                    current_ap_mac.as_deref() == Some(&crate::api::normalize_mac(m))
1643                });
1644                let style = if is_selected {
1645                    Style::default().bg(SELECTED_BG).fg(Color::White)
1646                } else {
1647                    Style::default().fg(Color::White)
1648                };
1649                let prefix = if is_selected { "▸ " } else { "  " };
1650                let suffix = if is_current { " ◂ connected" } else { "" };
1651                Row::new(vec![
1652                    Cell::from(format!("{prefix}{name}{suffix}")).style(style),
1653                    Cell::from(mac).style(Style::default().fg(DIM_COLOR)),
1654                ])
1655            })
1656            .collect();
1657
1658    let widths = [Constraint::Min(24), Constraint::Length(18)];
1659    let table = Table::new(rows, widths).block(block);
1660    f.render_widget(table, area);
1661}
1662
1663fn draw(f: &mut ratatui::Frame, state: &AppState) {
1664    if state.loading {
1665        let area = f.area();
1666        let block = Block::default()
1667            .borders(Borders::ALL)
1668            .border_type(BorderType::Rounded)
1669            .border_style(Style::default().fg(ACCENT_COLOR));
1670        let text = Paragraph::new(Line::from(Span::styled(
1671            "Connecting to controller…",
1672            Style::default()
1673                .fg(ACCENT_COLOR)
1674                .add_modifier(Modifier::BOLD),
1675        )))
1676        .alignment(Alignment::Center)
1677        .block(block);
1678        let centered = Layout::default()
1679            .direction(Direction::Vertical)
1680            .constraints([
1681                Constraint::Min(0),
1682                Constraint::Length(3),
1683                Constraint::Min(0),
1684            ])
1685            .split(area)[1];
1686        f.render_widget(text, centered);
1687        return;
1688    }
1689
1690    // Devices: 2 borders + 1 header + 1 header gap + data rows, minimum 5
1691    let device_rows = (state.devices.len() + 4).max(5) as u16;
1692    let chunks = Layout::default()
1693        .direction(Direction::Vertical)
1694        .constraints([
1695            Constraint::Length(3),           // header
1696            Constraint::Min(10),             // clients (takes remaining)
1697            Constraint::Length(device_rows), // devices (sized to content)
1698            Constraint::Length(1),           // footer
1699        ])
1700        .split(f.area());
1701
1702    draw_header(f, chunks[0], state);
1703    draw_clients(f, chunks[1], state);
1704    draw_devices(f, chunks[2], state);
1705    draw_footer(f, chunks[3], state);
1706    draw_overlay(f, state);
1707}
1708
1709type FetchResult = Result<
1710    (
1711        Option<SysInfo>,
1712        Option<HostSystem>,
1713        Vec<HealthSubsystem>,
1714        Vec<LegacyClient>,
1715        Vec<LegacyDevice>,
1716    ),
1717    String,
1718>;
1719
1720pub async fn run(api: &UnifiClient, interval_secs: u64) -> Result<(), Box<dyn std::error::Error>> {
1721    // Setup terminal
1722    enable_raw_mode()?;
1723    let mut stdout = io::stdout();
1724    execute!(stdout, EnterAlternateScreen)?;
1725    let backend = CrosstermBackend::new(stdout);
1726    let mut terminal = Terminal::new(backend)?;
1727
1728    let mut state = AppState::new();
1729    let tick_rate = Duration::from_secs(interval_secs);
1730    let mut last_tick = Instant::now() - tick_rate; // Force immediate first fetch
1731
1732    let (tx, mut rx) = tokio::sync::mpsc::channel::<FetchResult>(1);
1733    let (action_tx, mut action_rx) = tokio::sync::mpsc::channel::<Result<String, String>>(4);
1734    let mut fetch_in_progress = false;
1735
1736    let result = loop {
1737        // Kick off background fetch if tick elapsed and no fetch is running
1738        if !fetch_in_progress && last_tick.elapsed() >= tick_rate {
1739            let tx = tx.clone();
1740            let http = api.clone_http();
1741            let base_url = api.base_url().to_string();
1742            fetch_in_progress = true;
1743            state.loading = state.clients.is_empty();
1744            tokio::spawn(async move {
1745                let result = fetch_data_standalone(&http, &base_url).await;
1746                let _ = tx.send(result.map_err(|e| e.to_string())).await;
1747            });
1748        }
1749
1750        // Check for completed fetch (non-blocking)
1751        if let Ok(result) = rx.try_recv() {
1752            fetch_in_progress = false;
1753            state.loading = false;
1754            last_tick = Instant::now();
1755            match result {
1756                Ok((sysinfo, host_system, health, clients, devices)) => {
1757                    state.sysinfo = sysinfo;
1758                    state.host_system = host_system;
1759                    state.health = health;
1760                    state.clients = clients;
1761                    state.devices = devices;
1762                    state.rebuild_device_names();
1763                    state.last_error = None;
1764                }
1765                Err(e) => {
1766                    state.last_error = Some(e);
1767                }
1768            }
1769        }
1770
1771        // Check for completed actions (non-blocking)
1772        if let Ok(result) = action_rx.try_recv() {
1773            match result {
1774                Ok(msg) => {
1775                    state.status_msg = Some((msg, Instant::now()));
1776                    // Force refresh after action
1777                    last_tick = Instant::now() - tick_rate;
1778                }
1779                Err(msg) => {
1780                    state.last_error = Some(msg);
1781                }
1782            }
1783        }
1784
1785        // Clear status message after 3 seconds
1786        if let Some((_, t)) = &state.status_msg
1787            && t.elapsed() >= Duration::from_secs(3)
1788        {
1789            state.status_msg = None;
1790        }
1791
1792        // Adjust viewport so cursor stays visible
1793        if !state.loading {
1794            let term_height = terminal.size()?.height;
1795            let device_rows = (state.devices.len() + 4).max(5) as u16;
1796            // client area = total - header(3) - devices - footer(1), minus borders+header(4)
1797            let client_visible = term_height
1798                .saturating_sub(3 + device_rows + 1)
1799                .saturating_sub(4) as usize;
1800            state.ensure_client_visible(client_visible);
1801        }
1802
1803        // Draw
1804        terminal.draw(|f| draw(f, &state))?;
1805
1806        // Handle events (poll with short timeout for responsiveness)
1807        if event::poll(Duration::from_millis(100))?
1808            && let Event::Key(key) = event::read()?
1809        {
1810            if key.kind != KeyEventKind::Press {
1811                continue;
1812            }
1813
1814            match state.handle_key(key) {
1815                InputOutcome::Continue => {}
1816                InputOutcome::Quit => break Ok(()),
1817                InputOutcome::Spawn(action) => {
1818                    let http = api.clone_http();
1819                    let base_url = api.base_url().to_string();
1820                    let action_tx = action_tx.clone();
1821                    match action {
1822                        PendingAction::Client(ca) => {
1823                            tokio::spawn(async move {
1824                                let result = execute_client_action(&http, &base_url, ca).await;
1825                                let _ = action_tx.send(result).await;
1826                            });
1827                        }
1828                        PendingAction::Device(da) => {
1829                            tokio::spawn(async move {
1830                                let result = execute_device_action(&http, &base_url, da).await;
1831                                let _ = action_tx.send(result).await;
1832                            });
1833                        }
1834                    }
1835                }
1836            }
1837        }
1838    };
1839
1840    // Restore terminal
1841    disable_raw_mode()?;
1842    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1843    terminal.show_cursor()?;
1844
1845    result
1846}
1847
1848// --- Ports Live TUI ---
1849
1850use crate::api::{DeviceWithPorts, PortEntry};
1851
1852struct PortsState {
1853    device: Option<DeviceWithPorts>,
1854    prev_bytes: HashMap<u32, (u64, u64, Instant)>,
1855    port_rates: HashMap<u32, (f64, f64)>,
1856    scroll: usize,
1857    interval_secs: u64,
1858    last_error: Option<String>,
1859}
1860
1861impl PortsState {
1862    fn new(interval_secs: u64) -> Self {
1863        Self {
1864            device: None,
1865            prev_bytes: HashMap::new(),
1866            port_rates: HashMap::new(),
1867            scroll: 0,
1868            interval_secs,
1869            last_error: None,
1870        }
1871    }
1872
1873    fn update_port_rates(&mut self) {
1874        let now = Instant::now();
1875        let ports = match &self.device {
1876            Some(d) => &d.port_table,
1877            None => return,
1878        };
1879
1880        for port in ports {
1881            let idx = match port.port_idx {
1882                Some(i) => i,
1883                None => continue,
1884            };
1885            let tx = port.tx_bytes.unwrap_or(0);
1886            let rx = port.rx_bytes.unwrap_or(0);
1887
1888            if let Some((prev_tx, prev_rx, prev_time)) = self.prev_bytes.get(&idx) {
1889                let elapsed = now.duration_since(*prev_time).as_secs_f64();
1890                if elapsed > 0.1 {
1891                    let tx_rate = if tx >= *prev_tx {
1892                        (tx - prev_tx) as f64 / elapsed
1893                    } else {
1894                        0.0
1895                    };
1896                    let rx_rate = if rx >= *prev_rx {
1897                        (rx - prev_rx) as f64 / elapsed
1898                    } else {
1899                        0.0
1900                    };
1901                    self.port_rates.insert(idx, (tx_rate, rx_rate));
1902                }
1903            }
1904
1905            self.prev_bytes.insert(idx, (tx, rx, now));
1906        }
1907    }
1908}
1909
1910fn port_link_color(port: &PortEntry) -> Color {
1911    if port.up {
1912        match port.speed {
1913            Some(s) if s >= 2500 => Color::Green,
1914            Some(s) if s >= 1000 => Color::Cyan,
1915            Some(_) => Color::Yellow,
1916            None => Color::White,
1917        }
1918    } else {
1919        DIM_COLOR
1920    }
1921}
1922
1923fn draw_ports(f: &mut ratatui::Frame, state: &PortsState) {
1924    let chunks = Layout::default()
1925        .direction(Direction::Vertical)
1926        .constraints([
1927            Constraint::Min(10),   // port table
1928            Constraint::Length(1), // footer
1929        ])
1930        .split(f.area());
1931
1932    let device_name = state
1933        .device
1934        .as_ref()
1935        .and_then(|d| d.name.as_deref())
1936        .unwrap_or("Device");
1937    let port_count = state
1938        .device
1939        .as_ref()
1940        .map(|d| d.port_table.len())
1941        .unwrap_or(0);
1942
1943    let block = Block::default()
1944        .borders(Borders::ALL)
1945        .border_type(BorderType::Rounded)
1946        .border_style(Style::default().fg(ACCENT_COLOR))
1947        .title(Span::styled(
1948            format!(" {device_name} \u{2502} {port_count} ports "),
1949            Style::default()
1950                .fg(ACCENT_COLOR)
1951                .add_modifier(Modifier::BOLD),
1952        ));
1953
1954    let header = Row::new(vec![
1955        Cell::from("Port").style(
1956            Style::default()
1957                .fg(HEADER_COLOR)
1958                .add_modifier(Modifier::BOLD),
1959        ),
1960        Cell::from("Name").style(
1961            Style::default()
1962                .fg(HEADER_COLOR)
1963                .add_modifier(Modifier::BOLD),
1964        ),
1965        Cell::from("Link").style(
1966            Style::default()
1967                .fg(HEADER_COLOR)
1968                .add_modifier(Modifier::BOLD),
1969        ),
1970        Cell::from("Speed").style(
1971            Style::default()
1972                .fg(HEADER_COLOR)
1973                .add_modifier(Modifier::BOLD),
1974        ),
1975        Cell::from("PoE").style(
1976            Style::default()
1977                .fg(HEADER_COLOR)
1978                .add_modifier(Modifier::BOLD),
1979        ),
1980        Cell::from("TX/s").style(
1981            Style::default()
1982                .fg(HEADER_COLOR)
1983                .add_modifier(Modifier::BOLD),
1984        ),
1985        Cell::from("RX/s").style(
1986            Style::default()
1987                .fg(HEADER_COLOR)
1988                .add_modifier(Modifier::BOLD),
1989        ),
1990        Cell::from("TX Total").style(
1991            Style::default()
1992                .fg(HEADER_COLOR)
1993                .add_modifier(Modifier::BOLD),
1994        ),
1995        Cell::from("RX Total").style(
1996            Style::default()
1997                .fg(HEADER_COLOR)
1998                .add_modifier(Modifier::BOLD),
1999        ),
2000    ])
2001    .height(1);
2002
2003    let inner_height = chunks[0].height.saturating_sub(4) as usize;
2004    let ports = state
2005        .device
2006        .as_ref()
2007        .map(|d| &d.port_table[..])
2008        .unwrap_or(&[]);
2009
2010    let rows: Vec<Row> = ports
2011        .iter()
2012        .skip(state.scroll)
2013        .take(inner_height)
2014        .map(|p| {
2015            let idx = p.port_idx.unwrap_or(0);
2016            let link_color = port_link_color(p);
2017            let (tx_rate, rx_rate) = state.port_rates.get(&idx).copied().unwrap_or((0.0, 0.0));
2018
2019            let link_str = if p.up { "\u{25cf} up" } else { "\u{25cb} down" };
2020
2021            let speed_str = if p.up {
2022                match p.speed {
2023                    Some(s) => {
2024                        let duplex = if p.full_duplex { "FD" } else { "HD" };
2025                        format!("{s} {duplex}")
2026                    }
2027                    None => "up".into(),
2028                }
2029            } else {
2030                "-".into()
2031            };
2032
2033            let poe_str = if p.poe_enable {
2034                match p.poe_power {
2035                    Some(w) if w > 0.0 => format!("{w:.1}W"),
2036                    _ => "on".into(),
2037                }
2038            } else if p.port_poe {
2039                "off".into()
2040            } else {
2041                "-".into()
2042            };
2043
2044            let poe_color = if p.poe_enable && p.poe_power.is_some_and(|w| w > 0.0) {
2045                Color::Yellow
2046            } else {
2047                DIM_COLOR
2048            };
2049
2050            Row::new(vec![
2051                Cell::from(idx.to_string()).style(
2052                    Style::default()
2053                        .fg(Color::White)
2054                        .add_modifier(Modifier::BOLD),
2055                ),
2056                Cell::from(p.name.as_deref().unwrap_or("-").to_string())
2057                    .style(Style::default().fg(Color::White)),
2058                Cell::from(link_str).style(Style::default().fg(link_color)),
2059                Cell::from(speed_str).style(Style::default().fg(link_color)),
2060                Cell::from(poe_str).style(Style::default().fg(poe_color)),
2061                Cell::from(format_rate(tx_rate)).style(Style::default().fg(if tx_rate >= 1024.0 {
2062                    Color::Green
2063                } else {
2064                    DIM_COLOR
2065                })),
2066                Cell::from(format_rate(rx_rate)).style(Style::default().fg(if rx_rate >= 1024.0 {
2067                    Color::Green
2068                } else {
2069                    DIM_COLOR
2070                })),
2071                Cell::from(p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
2072                    .style(Style::default().fg(DIM_COLOR)),
2073                Cell::from(p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()))
2074                    .style(Style::default().fg(DIM_COLOR)),
2075            ])
2076        })
2077        .collect();
2078
2079    let widths = [
2080        Constraint::Length(5),
2081        Constraint::Min(14),
2082        Constraint::Length(8),
2083        Constraint::Length(10),
2084        Constraint::Length(8),
2085        Constraint::Length(12),
2086        Constraint::Length(12),
2087        Constraint::Length(10),
2088        Constraint::Length(10),
2089    ];
2090
2091    if ports.is_empty() {
2092        let empty = Paragraph::new(Line::from(Span::styled(
2093            "No ports found (not a switch or router)",
2094            Style::default().fg(DIM_COLOR),
2095        )))
2096        .block(block)
2097        .alignment(Alignment::Center);
2098        f.render_widget(empty, chunks[0]);
2099    } else {
2100        let table = Table::new(rows, widths)
2101            .header(header)
2102            .block(block)
2103            .row_highlight_style(Style::default().bg(SELECTED_BG));
2104        f.render_widget(table, chunks[0]);
2105    }
2106
2107    // Footer
2108    let error_span = if let Some(ref err) = state.last_error {
2109        Span::styled(
2110            format!(" \u{26a0} {err} "),
2111            Style::default().fg(OFFLINE_COLOR),
2112        )
2113    } else {
2114        Span::raw("")
2115    };
2116
2117    let footer = Line::from(vec![
2118        Span::styled(
2119            " q",
2120            Style::default()
2121                .fg(ACCENT_COLOR)
2122                .add_modifier(Modifier::BOLD),
2123        ),
2124        Span::styled(" quit  ", Style::default().fg(DIM_COLOR)),
2125        Span::styled(
2126            "\u{2191}\u{2193}",
2127            Style::default()
2128                .fg(ACCENT_COLOR)
2129                .add_modifier(Modifier::BOLD),
2130        ),
2131        Span::styled(" scroll", Style::default().fg(DIM_COLOR)),
2132        error_span,
2133        Span::raw("  "),
2134        Span::styled(
2135            format!("\u{21bb} {}s", state.interval_secs),
2136            Style::default().fg(DIM_COLOR),
2137        ),
2138    ]);
2139    f.render_widget(Paragraph::new(footer), chunks[1]);
2140}
2141
2142pub async fn run_ports(
2143    api: &UnifiClient,
2144    mac: &str,
2145    interval_secs: u64,
2146) -> Result<(), Box<dyn std::error::Error>> {
2147    enable_raw_mode()?;
2148    let mut stdout = io::stdout();
2149    execute!(stdout, EnterAlternateScreen)?;
2150    let backend = CrosstermBackend::new(stdout);
2151    let mut terminal = Terminal::new(backend)?;
2152
2153    let mut state = PortsState::new(interval_secs);
2154    let tick_rate = Duration::from_secs(interval_secs);
2155    let mut last_tick = Instant::now() - tick_rate;
2156
2157    let result = loop {
2158        if last_tick.elapsed() >= tick_rate {
2159            match api.get_device_ports(mac).await {
2160                Ok(device) => {
2161                    state.device = Some(device);
2162                    state.update_port_rates();
2163                    state.last_error = None;
2164                }
2165                Err(e) => {
2166                    state.last_error = Some(e.to_string());
2167                }
2168            }
2169            last_tick = Instant::now();
2170        }
2171
2172        terminal.draw(|f| draw_ports(f, &state))?;
2173
2174        if event::poll(Duration::from_millis(100))?
2175            && let Event::Key(key) = event::read()?
2176        {
2177            if key.kind != KeyEventKind::Press {
2178                continue;
2179            }
2180
2181            match key.code {
2182                KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
2183                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()),
2184                KeyCode::Up | KeyCode::Char('k') => {
2185                    state.scroll = state.scroll.saturating_sub(1);
2186                }
2187                KeyCode::Down | KeyCode::Char('j') => {
2188                    let max = state
2189                        .device
2190                        .as_ref()
2191                        .map(|d| d.port_table.len())
2192                        .unwrap_or(0);
2193                    if state.scroll + 1 < max {
2194                        state.scroll += 1;
2195                    }
2196                }
2197                _ => {}
2198            }
2199        }
2200    };
2201
2202    disable_raw_mode()?;
2203    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
2204    terminal.show_cursor()?;
2205
2206    result
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212
2213    #[test]
2214    fn format_rate_zero() {
2215        assert_eq!(format_rate(0.0), "0 B/s");
2216    }
2217
2218    #[test]
2219    fn format_rate_bytes() {
2220        assert_eq!(format_rate(512.0), "512 B/s");
2221    }
2222
2223    #[test]
2224    fn format_rate_kilobytes() {
2225        assert_eq!(format_rate(10240.0), "10.0 KB/s");
2226    }
2227
2228    #[test]
2229    fn format_rate_megabytes() {
2230        assert_eq!(format_rate(5_242_880.0), "5.0 MB/s");
2231    }
2232
2233    #[test]
2234    fn format_rate_gigabytes() {
2235        assert_eq!(format_rate(1_073_741_824.0), "1.0 GB/s");
2236    }
2237
2238    #[test]
2239    fn ip_sort_key_ordering() {
2240        let mut ips = vec!["10.0.0.2", "10.0.0.10", "10.0.0.1", "192.168.1.1"];
2241        ips.sort_by_key(|ip| ip_sort_key(ip));
2242        assert_eq!(
2243            ips,
2244            vec!["10.0.0.1", "10.0.0.2", "10.0.0.10", "192.168.1.1"]
2245        );
2246    }
2247
2248    #[test]
2249    fn sort_mode_cycles() {
2250        assert_eq!(SortMode::Bandwidth.next(), SortMode::Name);
2251        assert_eq!(SortMode::Name.next(), SortMode::Ip);
2252        assert_eq!(SortMode::Ip.next(), SortMode::Bandwidth);
2253    }
2254
2255    #[test]
2256    fn app_state_scroll_bounds() {
2257        let mut state = AppState::new();
2258        state.cursor_up();
2259        assert_eq!(state.client_cursor, 0);
2260
2261        state.cursor_down(3, 2);
2262        assert_eq!(state.client_cursor, 1);
2263        state.cursor_down(3, 2);
2264        assert_eq!(state.client_cursor, 2);
2265        state.cursor_down(3, 2);
2266        assert_eq!(state.client_cursor, 2); // capped
2267
2268        state.cursor_up();
2269        assert_eq!(state.client_cursor, 1);
2270    }
2271
2272    #[test]
2273    fn device_state_str_values() {
2274        assert_eq!(device_state_str(Some(1)).0, "ONLINE");
2275        assert_eq!(device_state_str(Some(0)).0, "OFFLINE");
2276        assert_eq!(device_state_str(Some(2)).0, "ADOPTING");
2277        assert_eq!(device_state_str(None).0, "UNKNOWN");
2278    }
2279
2280    // --- Panel focus ---
2281
2282    #[test]
2283    fn default_focus_is_clients() {
2284        let state = AppState::new();
2285        assert_eq!(state.focus, Panel::Clients);
2286    }
2287
2288    #[test]
2289    fn tab_toggles_focus() {
2290        let mut state = AppState::new();
2291        assert_eq!(state.focus, Panel::Clients);
2292        state.focus = Panel::Devices;
2293        assert_eq!(state.focus, Panel::Devices);
2294        state.focus = Panel::Clients;
2295        assert_eq!(state.focus, Panel::Clients);
2296    }
2297
2298    // --- Device cursor ---
2299
2300    #[test]
2301    fn device_scroll_bounds() {
2302        let mut state = AppState::new();
2303        state.focus = Panel::Devices;
2304        state.cursor_up();
2305        assert_eq!(state.device_scroll, 0);
2306
2307        state.cursor_down(0, 3);
2308        assert_eq!(state.device_scroll, 1);
2309        state.cursor_down(0, 3);
2310        assert_eq!(state.device_scroll, 2);
2311        state.cursor_down(0, 3);
2312        assert_eq!(state.device_scroll, 2); // capped
2313
2314        state.cursor_up();
2315        assert_eq!(state.device_scroll, 1);
2316    }
2317
2318    // --- Page up/down ---
2319
2320    #[test]
2321    fn page_down_clients() {
2322        let mut state = AppState::new();
2323        state.page_down(100, 10, 20);
2324        assert_eq!(state.client_cursor, 20);
2325        state.page_down(100, 10, 20);
2326        assert_eq!(state.client_cursor, 40);
2327    }
2328
2329    #[test]
2330    fn page_up_clients() {
2331        let mut state = AppState::new();
2332        state.client_cursor = 30;
2333        state.page_up(20);
2334        assert_eq!(state.client_cursor, 10);
2335        state.page_up(20);
2336        assert_eq!(state.client_cursor, 0);
2337    }
2338
2339    #[test]
2340    fn page_down_capped_at_max() {
2341        let mut state = AppState::new();
2342        state.page_down(5, 10, 20);
2343        assert_eq!(state.client_cursor, 4); // 5 items, max index is 4
2344    }
2345
2346    #[test]
2347    fn page_down_devices() {
2348        let mut state = AppState::new();
2349        state.focus = Panel::Devices;
2350        state.page_down(100, 10, 5);
2351        assert_eq!(state.device_scroll, 5);
2352    }
2353
2354    #[test]
2355    fn page_up_devices() {
2356        let mut state = AppState::new();
2357        state.focus = Panel::Devices;
2358        state.device_scroll = 8;
2359        state.page_up(5);
2360        assert_eq!(state.device_scroll, 3);
2361    }
2362
2363    // --- Viewport scrolling ---
2364
2365    #[test]
2366    fn ensure_client_visible_scrolls_down() {
2367        let mut state = AppState::new();
2368        state.client_cursor = 25;
2369        state.client_offset = 0;
2370        state.ensure_client_visible(10);
2371        assert_eq!(state.client_offset, 16); // cursor 25 - height 10 + 1
2372    }
2373
2374    #[test]
2375    fn ensure_client_visible_scrolls_up() {
2376        let mut state = AppState::new();
2377        state.client_cursor = 3;
2378        state.client_offset = 10;
2379        state.ensure_client_visible(10);
2380        assert_eq!(state.client_offset, 3);
2381    }
2382
2383    #[test]
2384    fn ensure_client_visible_no_scroll_needed() {
2385        let mut state = AppState::new();
2386        state.client_cursor = 5;
2387        state.client_offset = 0;
2388        state.ensure_client_visible(10);
2389        assert_eq!(state.client_offset, 0);
2390    }
2391
2392    #[test]
2393    fn ensure_client_visible_zero_height() {
2394        let mut state = AppState::new();
2395        state.client_cursor = 5;
2396        state.client_offset = 0;
2397        state.ensure_client_visible(0);
2398        assert_eq!(state.client_offset, 0); // no change
2399    }
2400
2401    // --- Filter ---
2402
2403    #[test]
2404    fn filter_starts_empty() {
2405        let state = AppState::new();
2406        assert!(state.filter.is_empty());
2407        assert!(!state.filtering);
2408    }
2409
2410    #[test]
2411    fn filter_mode_toggle() {
2412        let mut state = AppState::new();
2413        state.filtering = true;
2414        state.filter = "test".to_string();
2415        assert!(state.filtering);
2416        assert_eq!(state.filter, "test");
2417        state.filtering = false;
2418        assert!(!state.filtering);
2419    }
2420
2421    // --- Overlay ---
2422
2423    #[test]
2424    fn overlay_starts_none() {
2425        let state = AppState::new();
2426        assert!(state.overlay.is_none());
2427    }
2428
2429    #[test]
2430    fn overlay_client_detail() {
2431        let mut state = AppState::new();
2432        state.overlay = Some(Overlay::ClientDetail(5));
2433        assert!(matches!(state.overlay, Some(Overlay::ClientDetail(5))));
2434    }
2435
2436    #[test]
2437    fn overlay_device_detail() {
2438        let mut state = AppState::new();
2439        state.overlay = Some(Overlay::DeviceDetail(2));
2440        assert!(matches!(state.overlay, Some(Overlay::DeviceDetail(2))));
2441    }
2442
2443    #[test]
2444    fn overlay_confirm() {
2445        let mut state = AppState::new();
2446        state.overlay = Some(Overlay::Confirm {
2447            message: "Kick client?".to_string(),
2448            action: PendingAction::Client(ClientAction::Kick("aa:bb:cc:dd:ee:ff".to_string())),
2449        });
2450        assert!(matches!(state.overlay, Some(Overlay::Confirm { .. })));
2451    }
2452
2453    #[test]
2454    fn overlay_ap_picker() {
2455        let mut state = AppState::new();
2456        state.overlay = Some(Overlay::ApPicker {
2457            client_idx: 3,
2458            ap_cursor: 0,
2459        });
2460        assert!(matches!(state.overlay, Some(Overlay::ApPicker { .. })));
2461    }
2462
2463    // --- Device name resolution ---
2464
2465    #[test]
2466    fn rebuild_device_names_maps_mac_to_name() {
2467        let mut state = AppState::new();
2468        state.devices = vec![
2469            serde_json::from_str(r#"{"mac": "aa:bb:cc:dd:ee:ff", "name": "Switch"}"#).unwrap(),
2470            serde_json::from_str(r#"{"mac": "11:22:33:44:55:66", "name": "AP-LR"}"#).unwrap(),
2471        ];
2472        state.rebuild_device_names();
2473        assert_eq!(
2474            state.resolve_device_name("AA:BB:CC:DD:EE:FF"),
2475            Some("Switch")
2476        );
2477        assert_eq!(
2478            state.resolve_device_name("11:22:33:44:55:66"),
2479            Some("AP-LR")
2480        );
2481        assert_eq!(state.resolve_device_name("00:00:00:00:00:00"), None);
2482    }
2483
2484    #[test]
2485    fn rebuild_device_names_skips_nameless() {
2486        let mut state = AppState::new();
2487        state.devices = vec![serde_json::from_str(r#"{"mac": "aa:bb:cc:dd:ee:ff"}"#).unwrap()];
2488        state.rebuild_device_names();
2489        assert_eq!(state.resolve_device_name("aa:bb:cc:dd:ee:ff"), None);
2490    }
2491
2492    // --- Sorted clients ---
2493
2494    fn make_client(id: &str, name: &str, ip: &str, tx: u64, rx: u64) -> LegacyClient {
2495        serde_json::from_str(&format!(
2496            r#"{{"_id": "{id}", "name": "{name}", "ip": "{ip}", "tx_bytes": {tx}, "rx_bytes": {rx}}}"#
2497        ))
2498        .unwrap()
2499    }
2500
2501    #[test]
2502    fn sorted_clients_bandwidth_default() {
2503        let mut state = AppState::new();
2504        state.clients = vec![
2505            make_client("1", "Low", "10.0.0.1", 100, 100),
2506            make_client("2", "High", "10.0.0.2", 10000, 10000),
2507            make_client("3", "Mid", "10.0.0.3", 1000, 1000),
2508        ];
2509        let sorted = state.sorted_clients();
2510        assert_eq!(sorted[0].display_name(), "High");
2511        assert_eq!(sorted[1].display_name(), "Mid");
2512        assert_eq!(sorted[2].display_name(), "Low");
2513    }
2514
2515    #[test]
2516    fn sorted_clients_by_name() {
2517        let mut state = AppState::new();
2518        state.sort = SortMode::Name;
2519        state.clients = vec![
2520            make_client("1", "Charlie", "10.0.0.1", 0, 0),
2521            make_client("2", "Alice", "10.0.0.2", 0, 0),
2522            make_client("3", "Bob", "10.0.0.3", 0, 0),
2523        ];
2524        let sorted = state.sorted_clients();
2525        assert_eq!(sorted[0].display_name(), "Alice");
2526        assert_eq!(sorted[1].display_name(), "Bob");
2527        assert_eq!(sorted[2].display_name(), "Charlie");
2528    }
2529
2530    #[test]
2531    fn sorted_clients_by_ip() {
2532        let mut state = AppState::new();
2533        state.sort = SortMode::Ip;
2534        state.clients = vec![
2535            make_client("1", "A", "10.0.0.10", 0, 0),
2536            make_client("2", "B", "10.0.0.2", 0, 0),
2537            make_client("3", "C", "10.0.0.1", 0, 0),
2538        ];
2539        let sorted = state.sorted_clients();
2540        assert_eq!(sorted[0].display_name(), "C"); // 10.0.0.1
2541        assert_eq!(sorted[1].display_name(), "B"); // 10.0.0.2
2542        assert_eq!(sorted[2].display_name(), "A"); // 10.0.0.10
2543    }
2544
2545    #[test]
2546    fn sorted_clients_filter_by_name() {
2547        let mut state = AppState::new();
2548        state.filter = "ali".to_string();
2549        state.clients = vec![
2550            make_client("1", "Alice", "10.0.0.1", 0, 0),
2551            make_client("2", "Bob", "10.0.0.2", 0, 0),
2552        ];
2553        let sorted = state.sorted_clients();
2554        assert_eq!(sorted.len(), 1);
2555        assert_eq!(sorted[0].display_name(), "Alice");
2556    }
2557
2558    #[test]
2559    fn sorted_clients_filter_by_ip() {
2560        let mut state = AppState::new();
2561        state.filter = "10.0.0.2".to_string();
2562        state.clients = vec![
2563            make_client("1", "Alice", "10.0.0.1", 0, 0),
2564            make_client("2", "Bob", "10.0.0.2", 0, 0),
2565        ];
2566        let sorted = state.sorted_clients();
2567        assert_eq!(sorted.len(), 1);
2568        assert_eq!(sorted[0].display_name(), "Bob");
2569    }
2570
2571    #[test]
2572    fn sorted_clients_filter_by_mac() {
2573        let mut state = AppState::new();
2574        state.filter = "aa:bb".to_string();
2575        state.clients = vec![
2576            serde_json::from_str(r#"{"_id": "1", "name": "Match", "mac": "aa:bb:cc:dd:ee:ff"}"#)
2577                .unwrap(),
2578            serde_json::from_str(r#"{"_id": "2", "name": "NoMatch", "mac": "11:22:33:44:55:66"}"#)
2579                .unwrap(),
2580        ];
2581        let sorted = state.sorted_clients();
2582        assert_eq!(sorted.len(), 1);
2583        assert_eq!(sorted[0].display_name(), "Match");
2584    }
2585
2586    #[test]
2587    fn sorted_clients_empty_filter_returns_all() {
2588        let mut state = AppState::new();
2589        state.filter = String::new();
2590        state.clients = vec![
2591            make_client("1", "A", "10.0.0.1", 0, 0),
2592            make_client("2", "B", "10.0.0.2", 0, 0),
2593        ];
2594        assert_eq!(state.sorted_clients().len(), 2);
2595    }
2596
2597    // --- AP devices filter ---
2598
2599    #[test]
2600    fn ap_devices_filters_by_type() {
2601        let mut state = AppState::new();
2602        state.devices = vec![
2603            serde_json::from_str(r#"{"mac": "aa:bb:cc:dd:ee:ff", "type": "uap", "name": "AP"}"#)
2604                .unwrap(),
2605            serde_json::from_str(
2606                r#"{"mac": "11:22:33:44:55:66", "type": "usw", "name": "Switch"}"#,
2607            )
2608            .unwrap(),
2609        ];
2610        let aps = state.ap_devices();
2611        assert_eq!(aps.len(), 1);
2612        assert_eq!(aps[0].name.as_deref(), Some("AP"));
2613    }
2614
2615    // --- Loading state ---
2616
2617    #[test]
2618    fn initial_state_is_loading() {
2619        let state = AppState::new();
2620        assert!(state.loading);
2621        assert!(state.last_error.is_none());
2622        assert!(state.status_msg.is_none());
2623    }
2624
2625    // --- SortMode label ---
2626
2627    #[test]
2628    fn sort_mode_labels() {
2629        assert_eq!(SortMode::Bandwidth.label(), "total ↓");
2630        assert_eq!(SortMode::Name.label(), "name ↓");
2631        assert_eq!(SortMode::Ip.label(), "ip ↓");
2632    }
2633
2634    // --- ip_sort_key edge cases ---
2635
2636    #[test]
2637    fn ip_sort_key_empty() {
2638        assert_eq!(ip_sort_key(""), Vec::<u32>::new());
2639    }
2640
2641    #[test]
2642    fn ip_sort_key_non_ip() {
2643        assert_eq!(ip_sort_key("not-an-ip"), Vec::<u32>::new());
2644    }
2645
2646    #[test]
2647    fn ip_sort_key_partial() {
2648        assert_eq!(ip_sort_key("10.0"), vec![10, 0]);
2649    }
2650
2651    // --- Input handling and rendering ---
2652
2653    fn press(code: KeyCode) -> KeyEvent {
2654        KeyEvent::new(code, KeyModifiers::NONE)
2655    }
2656
2657    fn client_with_mac(id: &str, name: &str, mac: &str, total: u64) -> LegacyClient {
2658        serde_json::from_str(&format!(
2659            r#"{{"_id":"{id}","name":"{name}","mac":"{mac}","tx_bytes":{total},"rx_bytes":0}}"#
2660        ))
2661        .unwrap()
2662    }
2663
2664    fn make_device(mac: &str, name: &str, dtype: &str) -> LegacyDevice {
2665        serde_json::from_str(&format!(
2666            r#"{{"mac":"{mac}","name":"{name}","type":"{dtype}","state":1}}"#
2667        ))
2668        .unwrap()
2669    }
2670
2671    /// A populated, non-loading dashboard. Clients carry MACs so action keys
2672    /// (kick/block/lock) produce real pending actions. Bandwidth order is
2673    /// Laptop, Phone, Tablet.
2674    fn dashboard() -> AppState {
2675        let mut state = AppState::new();
2676        state.loading = false;
2677        state.clients = vec![
2678            client_with_mac("1", "Laptop", "aa:bb:cc:00:00:01", 10_000),
2679            client_with_mac("2", "Phone", "aa:bb:cc:00:00:02", 200),
2680            client_with_mac("3", "Tablet", "aa:bb:cc:00:00:03", 100),
2681        ];
2682        state.devices = vec![
2683            make_device("dd:ee:ff:00:00:01", "AP-Office", "uap"),
2684            make_device("dd:ee:ff:00:00:02", "Switch-01", "usw"),
2685        ];
2686        state
2687    }
2688
2689    /// Render the dashboard into an in-memory buffer and return its text.
2690    fn render(state: &AppState, width: u16, height: u16) -> String {
2691        use ratatui::backend::TestBackend;
2692        let backend = TestBackend::new(width, height);
2693        let mut terminal = Terminal::new(backend).unwrap();
2694        terminal.draw(|f| draw(f, state)).unwrap();
2695        let buffer = terminal.backend().buffer();
2696        let mut out = String::new();
2697        for y in 0..height {
2698            for x in 0..width {
2699                out.push_str(buffer[(x, y)].symbol());
2700            }
2701            out.push('\n');
2702        }
2703        out
2704    }
2705
2706    #[test]
2707    fn render_shows_loading_screen() {
2708        let state = AppState::new();
2709        let text = render(&state, 80, 24);
2710        assert!(text.contains("Connecting to controller"), "{text}");
2711    }
2712
2713    #[test]
2714    fn render_dashboard_shows_clients_and_devices() {
2715        let text = render(&dashboard(), 120, 30);
2716        assert!(text.contains("Clients (3)"), "{text}");
2717        assert!(text.contains("Laptop"), "{text}");
2718        assert!(text.contains("Phone"), "{text}");
2719        assert!(text.contains("AP-Office"), "{text}");
2720    }
2721
2722    #[test]
2723    fn render_confirm_overlay_shows_message() {
2724        let mut state = dashboard();
2725        state.overlay = Some(Overlay::Confirm {
2726            message: "Kick Laptop?".into(),
2727            action: PendingAction::Client(ClientAction::Kick("aa:bb:cc:00:00:01".into())),
2728        });
2729        let text = render(&state, 120, 30);
2730        assert!(text.contains("Confirm"), "{text}");
2731        assert!(text.contains("Kick Laptop?"), "{text}");
2732    }
2733
2734    #[test]
2735    fn render_every_overlay_without_panicking() {
2736        let mut state = dashboard();
2737        state
2738            .devices
2739            .push(make_device("dd:ee:ff:00:00:03", "AP-Garage", "uap"));
2740        for overlay in [
2741            Overlay::ClientDetail(0),
2742            Overlay::DeviceDetail(0),
2743            Overlay::ApPicker {
2744                client_idx: 0,
2745                ap_cursor: 1,
2746            },
2747            Overlay::Confirm {
2748                message: "Restart AP-Office?".into(),
2749                action: PendingAction::Device(DeviceAction::Restart("dd:ee:ff:00:00:01".into())),
2750            },
2751        ] {
2752            state.overlay = Some(overlay);
2753            // The assertion is simply that rendering does not panic.
2754            let _ = render(&state, 120, 30);
2755        }
2756    }
2757
2758    #[test]
2759    fn handle_key_quit_keys() {
2760        let mut state = dashboard();
2761        assert!(matches!(
2762            state.handle_key(press(KeyCode::Char('q'))),
2763            InputOutcome::Quit
2764        ));
2765        assert!(matches!(
2766            state.handle_key(press(KeyCode::Esc)),
2767            InputOutcome::Quit
2768        ));
2769        assert!(matches!(
2770            state.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
2771            InputOutcome::Quit
2772        ));
2773    }
2774
2775    #[test]
2776    fn handle_key_navigation_moves_and_clamps_cursor() {
2777        let mut state = dashboard();
2778        assert_eq!(state.client_cursor, 0);
2779        state.handle_key(press(KeyCode::Down));
2780        assert_eq!(state.client_cursor, 1);
2781        state.handle_key(press(KeyCode::Char('j')));
2782        assert_eq!(state.client_cursor, 2);
2783        state.handle_key(press(KeyCode::Down)); // already at last of three
2784        assert_eq!(state.client_cursor, 2);
2785        state.handle_key(press(KeyCode::Up));
2786        assert_eq!(state.client_cursor, 1);
2787    }
2788
2789    #[test]
2790    fn handle_key_tab_toggles_focus() {
2791        let mut state = dashboard();
2792        assert_eq!(state.focus, Panel::Clients);
2793        state.handle_key(press(KeyCode::Tab));
2794        assert_eq!(state.focus, Panel::Devices);
2795        state.handle_key(press(KeyCode::Tab));
2796        assert_eq!(state.focus, Panel::Clients);
2797    }
2798
2799    #[test]
2800    fn handle_key_s_cycles_sort() {
2801        let mut state = dashboard();
2802        assert_eq!(state.sort, SortMode::Bandwidth);
2803        state.handle_key(press(KeyCode::Char('s')));
2804        assert_eq!(state.sort, SortMode::Name);
2805    }
2806
2807    #[test]
2808    fn handle_key_filter_typing_appends_and_resets_cursor() {
2809        let mut state = dashboard();
2810        state.client_cursor = 2;
2811        state.handle_key(press(KeyCode::Char('/')));
2812        assert!(state.filtering);
2813        state.handle_key(press(KeyCode::Char('a')));
2814        assert_eq!(state.filter, "a");
2815        assert_eq!(state.client_cursor, 0);
2816        state.handle_key(press(KeyCode::Char('b')));
2817        state.handle_key(press(KeyCode::Backspace));
2818        assert_eq!(state.filter, "a");
2819        state.handle_key(press(KeyCode::Enter));
2820        assert!(!state.filtering);
2821        assert_eq!(state.filter, "a", "Enter keeps the filter");
2822    }
2823
2824    #[test]
2825    fn handle_key_filter_esc_clears() {
2826        let mut state = dashboard();
2827        state.handle_key(press(KeyCode::Char('/')));
2828        state.handle_key(press(KeyCode::Char('x')));
2829        assert_eq!(state.filter, "x");
2830        state.handle_key(press(KeyCode::Esc));
2831        assert!(!state.filtering);
2832        assert_eq!(state.filter, "");
2833    }
2834
2835    #[test]
2836    fn handle_key_enter_opens_and_esc_closes_client_detail() {
2837        let mut state = dashboard();
2838        state.client_cursor = 1;
2839        state.handle_key(press(KeyCode::Enter));
2840        assert!(matches!(state.overlay, Some(Overlay::ClientDetail(1))));
2841        state.handle_key(press(KeyCode::Esc));
2842        assert!(state.overlay.is_none());
2843    }
2844
2845    #[test]
2846    fn handle_key_enter_opens_device_detail_when_focused() {
2847        let mut state = dashboard();
2848        state.focus = Panel::Devices;
2849        state.device_scroll = 1;
2850        state.handle_key(press(KeyCode::Enter));
2851        assert!(matches!(state.overlay, Some(Overlay::DeviceDetail(1))));
2852    }
2853
2854    #[test]
2855    fn handle_key_enter_noop_when_empty() {
2856        let mut state = AppState::new();
2857        state.loading = false;
2858        state.handle_key(press(KeyCode::Enter));
2859        assert!(state.overlay.is_none());
2860    }
2861
2862    #[test]
2863    fn client_detail_kick_opens_confirm() {
2864        let mut state = dashboard();
2865        state.overlay = Some(Overlay::ClientDetail(0)); // Laptop
2866        let outcome = state.handle_key(press(KeyCode::Char('k')));
2867        assert!(matches!(outcome, InputOutcome::Continue));
2868        match &state.overlay {
2869            Some(Overlay::Confirm { message, action }) => {
2870                assert_eq!(message, "Kick Laptop?");
2871                assert!(matches!(
2872                    action,
2873                    PendingAction::Client(ClientAction::Kick(_))
2874                ));
2875            }
2876            _ => panic!("expected a confirm overlay"),
2877        }
2878    }
2879
2880    #[test]
2881    fn client_detail_block_opens_confirm() {
2882        let mut state = dashboard();
2883        state.overlay = Some(Overlay::ClientDetail(1)); // Phone, not blocked
2884        state.handle_key(press(KeyCode::Char('b')));
2885        match &state.overlay {
2886            Some(Overlay::Confirm { message, action }) => {
2887                assert_eq!(message, "Block Phone?");
2888                assert!(matches!(
2889                    action,
2890                    PendingAction::Client(ClientAction::Block(_))
2891                ));
2892            }
2893            _ => panic!("expected a confirm overlay"),
2894        }
2895    }
2896
2897    #[test]
2898    fn confirm_yes_spawns_action_and_clears_overlay() {
2899        let mut state = dashboard();
2900        state.overlay = Some(Overlay::Confirm {
2901            message: "Kick Laptop?".into(),
2902            action: PendingAction::Client(ClientAction::Kick("aa:bb:cc:00:00:01".into())),
2903        });
2904        let outcome = state.handle_key(press(KeyCode::Char('y')));
2905        assert!(matches!(
2906            outcome,
2907            InputOutcome::Spawn(PendingAction::Client(ClientAction::Kick(_)))
2908        ));
2909        assert!(state.overlay.is_none());
2910    }
2911
2912    #[test]
2913    fn confirm_no_cancels_without_spawning() {
2914        let mut state = dashboard();
2915        state.overlay = Some(Overlay::Confirm {
2916            message: "Kick Laptop?".into(),
2917            action: PendingAction::Client(ClientAction::Kick("m".into())),
2918        });
2919        let outcome = state.handle_key(press(KeyCode::Char('n')));
2920        assert!(matches!(outcome, InputOutcome::Continue));
2921        assert!(state.overlay.is_none());
2922    }
2923
2924    #[test]
2925    fn device_detail_restart_opens_confirm() {
2926        let mut state = dashboard();
2927        state.overlay = Some(Overlay::DeviceDetail(0)); // AP-Office
2928        state.handle_key(press(KeyCode::Char('r')));
2929        match &state.overlay {
2930            Some(Overlay::Confirm { message, action }) => {
2931                assert_eq!(message, "Restart AP-Office?");
2932                assert!(matches!(
2933                    action,
2934                    PendingAction::Device(DeviceAction::Restart(_))
2935                ));
2936            }
2937            _ => panic!("expected a confirm overlay"),
2938        }
2939    }
2940
2941    #[test]
2942    fn device_detail_locate_spawns_and_toggles_locating() {
2943        let mut state = dashboard();
2944        state.overlay = Some(Overlay::DeviceDetail(0));
2945        let outcome = state.handle_key(press(KeyCode::Char('l')));
2946        assert!(matches!(
2947            outcome,
2948            InputOutcome::Spawn(PendingAction::Device(DeviceAction::Locate(_, true)))
2949        ));
2950        // Locate leaves the detail overlay open and records the new state.
2951        assert!(matches!(state.overlay, Some(Overlay::DeviceDetail(0))));
2952        let norm = crate::api::normalize_mac("dd:ee:ff:00:00:01");
2953        assert_eq!(state.locating.get(&norm), Some(&true));
2954    }
2955
2956    #[test]
2957    fn ap_picker_navigates_and_selects() {
2958        let mut state = dashboard();
2959        state
2960            .devices
2961            .push(make_device("dd:ee:ff:00:00:03", "AP-Garage", "uap"));
2962        state.overlay = Some(Overlay::ApPicker {
2963            client_idx: 0,
2964            ap_cursor: 0,
2965        });
2966        state.handle_key(press(KeyCode::Down));
2967        assert!(matches!(
2968            state.overlay,
2969            Some(Overlay::ApPicker { ap_cursor: 1, .. })
2970        ));
2971        let outcome = state.handle_key(press(KeyCode::Enter));
2972        assert!(matches!(
2973            outcome,
2974            InputOutcome::Spawn(PendingAction::Client(ClientAction::LockToAp { .. }))
2975        ));
2976        assert!(state.overlay.is_none());
2977    }
2978
2979    #[test]
2980    fn ap_picker_esc_returns_to_client_detail() {
2981        let mut state = dashboard();
2982        state.overlay = Some(Overlay::ApPicker {
2983            client_idx: 2,
2984            ap_cursor: 0,
2985        });
2986        state.handle_key(press(KeyCode::Esc));
2987        assert!(matches!(state.overlay, Some(Overlay::ClientDetail(2))));
2988    }
2989}