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