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), Block(String), Unblock(String), LockToAp { mac: String, ap_mac: String }, UnlockFromAp(String), }
86
87enum DeviceAction {
88 Restart(String), Upgrade(String), Locate(String, bool), }
92
93enum 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>, 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 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 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 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 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 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 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 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 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 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 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 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 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; 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; }
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; 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 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 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 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 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), Constraint::Min(10), Constraint::Length(device_rows), Constraint::Length(1), ])
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 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; 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 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 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 if let Ok(result) = action_rx.try_recv() {
1773 match result {
1774 Ok(msg) => {
1775 state.status_msg = Some((msg, Instant::now()));
1776 last_tick = Instant::now() - tick_rate;
1778 }
1779 Err(msg) => {
1780 state.last_error = Some(msg);
1781 }
1782 }
1783 }
1784
1785 if let Some((_, t)) = &state.status_msg
1787 && t.elapsed() >= Duration::from_secs(3)
1788 {
1789 state.status_msg = None;
1790 }
1791
1792 if !state.loading {
1794 let term_height = terminal.size()?.height;
1795 let device_rows = (state.devices.len() + 4).max(5) as u16;
1796 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 terminal.draw(|f| draw(f, &state))?;
1805
1806 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 disable_raw_mode()?;
1842 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1843 terminal.show_cursor()?;
1844
1845 result
1846}
1847
1848use 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), Constraint::Length(1), ])
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 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); 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 #[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 #[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); state.cursor_up();
2315 assert_eq!(state.device_scroll, 1);
2316 }
2317
2318 #[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); }
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 #[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); }
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); }
2400
2401 #[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 #[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 #[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 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"); assert_eq!(sorted[1].display_name(), "B"); assert_eq!(sorted[2].display_name(), "A"); }
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 #[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 #[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 #[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 #[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 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 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 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 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)); 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)); 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)); 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)); 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 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}