/ web / src / pages / panels / network_panel.rs
network_panel.rs
  1  use super::{Td, Th};
  2  use crate::api::{WifiNetwork, WirelessStatusData};
  3  use crate::hooks::sleep_ms;
  4  use crate::services::{DeviceService, WifiService};
  5  use dioxus::prelude::*;
  6  use lucide_dioxus::{LoaderCircle, Radar, Wifi};
  7  use ui::components::button::{Button, ButtonVariant};
  8  use ui::components::input::Input;
  9  use ui::components::label::Label;
 10  use ui::components::toast::use_toast;
 11  
 12  #[component]
 13  pub fn NetworkPanel(
 14      device_url: Signal<String>,
 15      wireless: Signal<Option<WirelessStatusData>>,
 16      networks: Signal<Vec<WifiNetwork>>,
 17      scanning: Signal<bool>,
 18      connecting: Signal<bool>,
 19      ssid_input: Signal<String>,
 20      password_input: Signal<String>,
 21  ) -> Element {
 22      let toasts = use_toast();
 23      let mut selected_index = use_signal(|| None::<usize>);
 24      let ssid_input_label = use_signal(|| Some("network-ssid-input".to_string()));
 25      let password_input_label = use_signal(|| Some("network-password-input".to_string()));
 26  
 27      let wireless_data = wireless.read();
 28  
 29      rsx! {
 30          section { id: "network-section", class: "flex flex-col h-full",
 31              div { class: "mb-3",
 32                  Button {
 33                      class: "gold-button-outline text-sm w-full justify-center".to_string(),
 34                      variant: ButtonVariant::Outline,
 35                      disabled: *scanning.read(),
 36                      loading: *scanning.read(),
 37                      on_click: move |_| {
 38                          scanning.set(true);
 39                          let url = device_url.read().clone();
 40                          spawn(async move {
 41                              match WifiService::scan(&url).await {
 42                                  Ok(response) => {
 43                                      let count = response.data.networks.len();
 44                                      networks.set(response.data.networks);
 45                                      toasts.success(format!("Found {count} network(s)"), None);
 46                                  }
 47                                  Err(error) => toasts.error(format!("Scan failed: {error}"), None),
 48                              }
 49                              scanning.set(false);
 50                          });
 51                      },
 52                      if !*scanning.read() {
 53                          Radar { class: "w-4 h-4" }
 54                      }
 55                      if *scanning.read() { "Scanning..." } else { "Scan" }
 56                  }
 57              }
 58  
 59              // WiFi connect form
 60              form {
 61                  class: "mt-2 flex flex-col lg:flex-row lg:items-end gap-2 mb-3",
 62                  onsubmit: move |form_event| {
 63                      form_event.prevent_default();
 64                      let ssid = ssid_input.read().clone();
 65                      let password = password_input.read().clone();
 66                      if ssid.is_empty() { return; }
 67                      connecting.set(true);
 68                      let url = device_url.read().clone();
 69                      spawn(async move {
 70                          match WifiService::connect(&url, &ssid, &password).await {
 71                              Ok(_) => {
 72                                  toasts.success(format!("Connecting to {ssid}..."), None);
 73                                  sleep_ms(3000).await;
 74                                  if let Ok(response) = WifiService::get_status(&url).await {
 75                                      wireless.set(Some(response.data));
 76                                  }
 77                              }
 78                              Err(error) => toasts.error(format!("Connect failed: {error}"), None),
 79                          }
 80                          connecting.set(false);
 81                      });
 82                  },
 83                  div { class: "min-w-0 flex-1",
 84                      Label {
 85                          for_id: ssid_input_label,
 86                          class: Some("text-xs uppercase tracking-wider text-muted-foreground mb-2".to_string()),
 87                          "SSID"
 88                      }
 89                      Input {
 90                          id: Some("network-ssid-input".to_string()),
 91                          class: Some("gold-input w-full px-3 py-2 text-sm".to_string()),
 92                          input_type: "text".to_string(),
 93                          aria_label: Some("SSID".to_string()),
 94                          placeholder: "SSID".to_string(),
 95                          value: ssid_input.read().clone(),
 96                          on_input: Some(Callback::new(move |event: FormEvent| ssid_input.set(event.value()))),
 97                      }
 98                  }
 99                  div { class: "min-w-0 flex-1",
100                      Label {
101                          for_id: password_input_label,
102                          class: Some("text-xs uppercase tracking-wider text-muted-foreground mb-2".to_string()),
103                          "Password"
104                      }
105                      Input {
106                          id: Some("network-password-input".to_string()),
107                          class: Some("gold-input w-full px-3 py-2 text-sm".to_string()),
108                          input_type: "password".to_string(),
109                          aria_label: Some("Password".to_string()),
110                          placeholder: "Password (blank for open)".to_string(),
111                          value: password_input.read().clone(),
112                          on_input: Some(Callback::new(move |event: FormEvent| password_input.set(event.value()))),
113                      }
114                  }
115                  div { class: "lg:w-auto lg:flex-none",
116                      Button {
117                          class: "gold-button-outline text-sm whitespace-nowrap".to_string(),
118                          variant: ButtonVariant::Outline,
119                          button_type: "submit".to_string(),
120                          disabled: ssid_input.read().is_empty() || *connecting.read(),
121                          loading: *connecting.read(),
122                          if !*connecting.read() {
123                              Wifi { class: "w-4 h-4" }
124                          }
125                          if *connecting.read() { "Connecting..." } else { "Connect" }
126                      }
127                  }
128              }
129  
130              // Scan results table
131              div {
132                  class: "overflow-auto flex-1 border border-border rounded-lg outline-none",
133                  tabindex: "0",
134                  onkeydown: move |e: KeyboardEvent| {
135                      let count = networks.read().len();
136                      if count == 0 { return; }
137                      match e.key() {
138                          Key::ArrowDown => {
139                              e.prevent_default();
140                              let next = match *selected_index.read() {
141                                  Some(i) => (i + 1) % count,
142                                  None => 0,
143                              };
144                              selected_index.set(Some(next));
145                          }
146                          Key::ArrowUp => {
147                              e.prevent_default();
148                              let next = match *selected_index.read() {
149                                  Some(0) | None => count - 1,
150                                  Some(i) => i - 1,
151                              };
152                              selected_index.set(Some(next));
153                          }
154                          Key::Enter => {
155                              if let Some(i) = *selected_index.read() {
156                                  if let Some(network) = networks.read().get(i) {
157                                      ssid_input.set(network.ssid.clone());
158                                  }
159                              }
160                          }
161                          Key::Escape => {
162                              selected_index.set(None);
163                          }
164                          _ => {}
165                      }
166                  },
167                  table { class: "w-full border-collapse min-w-[420px]",
168                      thead {
169                          tr {
170                              Th { "SSID" }
171                              Th { "RSSI" }
172                              Th { "CHANNEL" }
173                              Th { "SECURITY" }
174                          }
175                      }
176                      tbody {
177                          if networks.read().is_empty() {
178                              tr {
179                                  td { colspan: "4", class: "text-muted-foreground text-sm px-3 py-2 border-b border-border",
180                                      "Run a scan to list nearby WiFi networks."
181                                  }
182                              }
183                          }
184                          for (network_index, network) in networks.read().iter().enumerate() {
185                              {
186                                  let is_connected_network = wireless_data.as_ref()
187                                      .is_some_and(|wireless_info| wireless_info.sta_ssid == network.ssid && !network.ssid.is_empty());
188                                  let is_selected = *selected_index.read() == Some(network_index);
189                                  let row_class = if is_connected_network {
190                                      "border-b border-border bg-emerald-500/10"
191                                  } else if is_selected {
192                                      "border-b border-border bg-primary/10"
193                                  } else {
194                                      "border-b border-border hover:bg-muted/30 transition-colors"
195                                  };
196                                  let ssid_display = if network.ssid.is_empty() { "(hidden)".to_string() } else { network.ssid.clone() };
197                                  let ssid_for_click = network.ssid.clone();
198                                  let ssid_class = if is_connected_network {
199                                      "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-emerald-400 font-semibold"
200                                  } else if network.ssid.is_empty() {
201                                      "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-muted-foreground italic"
202                                  } else {
203                                      "border-0 bg-transparent p-0 cursor-pointer transition-colors hover:text-accent text-primary underline"
204                                  };
205  
206                                  rsx! {
207                                      tr { key: "{network_index}-{ssid_display}", class: "{row_class}",
208                                          td { class: "px-3 py-2 text-sm",
209                                              Button {
210                                                  class: ssid_class.to_string(),
211                                                  variant: ButtonVariant::Ghost,
212                                                  on_click: move |_| ssid_input.set(ssid_for_click.clone()),
213                                                  "{ssid_display}"
214                                              }
215                                              if is_connected_network {
216                                                  span { class: "ml-2 text-xs bg-emerald-500/20 text-emerald-400 rounded px-1.5 py-0.5",
217                                                      "{wireless_data.as_ref().map(|wireless_info| wireless_info.sta_ipv4.as_str()).unwrap_or(\"\")}"
218                                                  }
219                                              }
220                                          }
221                                          Td { class: "font-mono", "{network.rssi}" }
222                                          Td { class: "font-mono", "{network.channel}" }
223                                          Td { class: "text-muted-foreground", "{network.encryption}" }
224                                      }
225                                  }
226                              }
227                          }
228                      }
229                  }
230              }
231          }
232      }
233  }