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