feat(openwrt): WAN/WISP setup from the UI with WiFi network scan
New RPC methods: - openwrt.scan-wifi: triggers iwinfo scan on the router radio, returns networks sorted by signal strength - openwrt.configure-wan: creates UCI wireless.wwan (sta mode) + network.wwan (DHCP) + adds wwan to firewall WAN zone, then calls `wifi reload` get-status now includes a `wan` object with configured/ssid/ip/ internet fields so the UI can show current uplink state. Frontend WAN panel: scan → pick SSID (signal bars) → enter password → apply. Shows "Configure WAN first" hint above TollGate install button when internet is not available. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd3a3dfbac
commit
9a782fb551
@ -234,6 +234,8 @@ impl RpcHandler {
|
||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
|
||||
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
|
||||
@ -71,6 +71,9 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"No pre-built TollGate",
|
||||
"opkg not found",
|
||||
"apk update failed",
|
||||
"No wireless interface",
|
||||
"No wireless radio",
|
||||
"Missing required field",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@ -4,6 +4,8 @@ use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
wan,
|
||||
wifi_scan,
|
||||
};
|
||||
use crate::network::router as net_router;
|
||||
|
||||
@ -114,6 +116,8 @@ impl RpcHandler {
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
@ -121,6 +125,7 @@ impl RpcHandler {
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -190,6 +195,74 @@ impl RpcHandler {
|
||||
"mint_url": config.mint_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Scan for visible WiFi networks from the router's radio.
|
||||
///
|
||||
/// Params: same host/credentials as other openwrt methods.
|
||||
pub(super) async fn handle_openwrt_scan_wifi(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "networks": result }))
|
||||
}
|
||||
|
||||
/// Configure WAN/WISP — connect the router to an upstream WiFi network.
|
||||
///
|
||||
/// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }`
|
||||
pub(super) async fn handle_openwrt_configure_wan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
|
||||
let ssid = p.get("ssid").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
|
||||
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption };
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||
|
||||
@ -3,5 +3,7 @@ pub mod opkg;
|
||||
pub mod router;
|
||||
pub mod tollgate;
|
||||
pub mod uci;
|
||||
pub mod wan;
|
||||
pub mod wifi_scan;
|
||||
|
||||
pub use router::Router;
|
||||
|
||||
91
core/openwrt/src/wan.rs
Normal file
91
core/openwrt/src/wan.rs
Normal file
@ -0,0 +1,91 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use crate::Router;
|
||||
|
||||
pub struct WispConfig {
|
||||
pub ssid: String,
|
||||
pub password: String,
|
||||
pub encryption: String, // psk2 | psk | sae | none
|
||||
}
|
||||
|
||||
pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
|
||||
info!("[{}] Configuring WISP → ssid={}", router.host, config.ssid);
|
||||
|
||||
let radio = detect_radio(router)?;
|
||||
|
||||
// Create/update named sta wifi-iface "wwan" (idempotent: uci set creates if absent)
|
||||
router.uci_set("wireless.wwan", "wifi-iface")?;
|
||||
router.uci_set("wireless.wwan.device", &radio)?;
|
||||
router.uci_set("wireless.wwan.mode", "sta")?;
|
||||
router.uci_set("wireless.wwan.ssid", &config.ssid)?;
|
||||
router.uci_set("wireless.wwan.network", "wwan")?;
|
||||
router.uci_set("wireless.wwan.encryption", &config.encryption)?;
|
||||
if config.encryption != "none" && !config.password.is_empty() {
|
||||
router.uci_set("wireless.wwan.key", &config.password)?;
|
||||
}
|
||||
router.uci_commit(Some("wireless"))?;
|
||||
|
||||
// Create/update wwan network interface (DHCP)
|
||||
router.uci_set("network.wwan", "interface")?;
|
||||
router.uci_set("network.wwan.proto", "dhcp")?;
|
||||
router.uci_commit(Some("network"))?;
|
||||
|
||||
// Add wwan to the WAN firewall zone (walk zones by name)
|
||||
ensure_wwan_in_wan_zone(router)?;
|
||||
|
||||
// Apply wireless changes; fall back to full network restart if wifi reload fails
|
||||
let (_, code) = router.run("wifi reload 2>&1")?;
|
||||
if code != 0 {
|
||||
router.run_ok("/etc/init.d/network restart 2>&1")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
let configured = router
|
||||
.uci_get("network.wwan.proto")
|
||||
.map(|v| v == "dhcp")
|
||||
.unwrap_or(false);
|
||||
|
||||
let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default();
|
||||
let encryption = router.uci_get("wireless.wwan.encryption").unwrap_or_default();
|
||||
|
||||
// Source IP for reaching 8.8.8.8 — empty if no default route yet
|
||||
let ip = router
|
||||
.run_ok("ip -4 route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i==\"src\"){print $(i+1); exit}}'")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
serde_json::json!({
|
||||
"configured": configured,
|
||||
"ssid": ssid,
|
||||
"encryption": encryption,
|
||||
"ip": ip,
|
||||
"internet": !ip.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
// radio0 is universal; verify it exists
|
||||
let out = router.uci_get("wireless.radio0").unwrap_or_default();
|
||||
if !out.is_empty() {
|
||||
return Ok("radio0".to_string());
|
||||
}
|
||||
anyhow::bail!("No wireless radio (radio0) found in UCI config")
|
||||
}
|
||||
|
||||
fn ensure_wwan_in_wan_zone(router: &Router) -> Result<()> {
|
||||
// Walk zones 0-9, find the one named "wan", add wwan to its network list
|
||||
let script = "for i in $(seq 0 9); do \
|
||||
name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
|
||||
if [ \"$name\" = \"wan\" ]; then \
|
||||
uci add_list firewall.@zone[$i].network=wwan 2>/dev/null; \
|
||||
uci commit firewall; \
|
||||
break; \
|
||||
fi; \
|
||||
done; echo ok";
|
||||
router.run_ok(script)?;
|
||||
Ok(())
|
||||
}
|
||||
97
core/openwrt/src/wifi_scan.rs
Normal file
97
core/openwrt/src/wifi_scan.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use anyhow::Result;
|
||||
use crate::Router;
|
||||
|
||||
pub struct ScannedNetwork {
|
||||
pub ssid: String,
|
||||
pub bssid: String,
|
||||
pub signal: i32,
|
||||
pub channel: u8,
|
||||
pub encryption: String,
|
||||
}
|
||||
|
||||
pub fn scan_networks(router: &Router) -> Result<Vec<ScannedNetwork>> {
|
||||
let iface = find_wireless_iface(router)?;
|
||||
let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?;
|
||||
if output.contains("No scan results") || output.trim().is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
parse_iwinfo_scan(&output)
|
||||
}
|
||||
|
||||
fn find_wireless_iface(router: &Router) -> Result<String> {
|
||||
let (_, code) = router.run("test -d /sys/class/net/wlan0/wireless")?;
|
||||
if code == 0 {
|
||||
return Ok("wlan0".to_string());
|
||||
}
|
||||
let out = router.run_ok(
|
||||
"ls /sys/class/net/ | while read i; do [ -d /sys/class/net/$i/wireless ] && echo $i && break; done",
|
||||
)?;
|
||||
let iface = out.trim().to_string();
|
||||
if iface.is_empty() {
|
||||
anyhow::bail!("No wireless interface found on this router");
|
||||
}
|
||||
Ok(iface)
|
||||
}
|
||||
|
||||
fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
let mut networks: Vec<ScannedNetwork> = Vec::new();
|
||||
let mut current: Option<ScannedNetwork> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("Cell ") {
|
||||
if let Some(n) = current.take() {
|
||||
if !n.ssid.is_empty() {
|
||||
networks.push(n);
|
||||
}
|
||||
}
|
||||
let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string();
|
||||
current = Some(ScannedNetwork {
|
||||
ssid: String::new(),
|
||||
bssid,
|
||||
signal: -100,
|
||||
channel: 0,
|
||||
encryption: "none".to_string(),
|
||||
});
|
||||
} else if let Some(ref mut n) = current {
|
||||
if let Some(rest) = line.strip_prefix("ESSID:") {
|
||||
n.ssid = rest.trim().trim_matches('"').to_string();
|
||||
} else if line.contains("Channel:") && !line.starts_with("Encryption") {
|
||||
if let Some(ch_part) = line.split("Channel:").nth(1) {
|
||||
n.channel = ch_part.trim().split_whitespace().next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
} else if line.starts_with("Signal:") {
|
||||
if let Some(dbm_str) = line.split_whitespace().nth(1) {
|
||||
n.signal = dbm_str.parse().unwrap_or(-100);
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("Encryption:") {
|
||||
n.encryption = normalize_encryption(rest.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(n) = current {
|
||||
if !n.ssid.is_empty() {
|
||||
networks.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
networks.sort_by(|a, b| b.signal.cmp(&a.signal));
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
fn normalize_encryption(raw: &str) -> String {
|
||||
let lower = raw.to_lowercase();
|
||||
if lower.contains("wpa3") || lower.contains("sae") {
|
||||
"sae".to_string()
|
||||
} else if lower.contains("wpa2") || lower.contains("psk2") {
|
||||
"psk2".to_string()
|
||||
} else if lower.contains("wpa") {
|
||||
"psk".to_string()
|
||||
} else if lower.contains("none") || lower.contains("open") || lower.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
lower
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,14 @@ interface TollGateStatus {
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
interface WanStatus {
|
||||
configured: boolean
|
||||
ssid: string
|
||||
encryption: string
|
||||
ip: string
|
||||
internet: boolean
|
||||
}
|
||||
|
||||
interface RouterStatus {
|
||||
host: string
|
||||
hostname: string
|
||||
@ -37,6 +45,15 @@ interface RouterStatus {
|
||||
release: ReleaseInfo
|
||||
tollgate: TollGateStatus
|
||||
wifi_interfaces: WifiInterface[]
|
||||
wan: WanStatus
|
||||
}
|
||||
|
||||
interface ScannedNetwork {
|
||||
ssid: string
|
||||
bssid: string
|
||||
signal: number
|
||||
channel: number
|
||||
encryption: string
|
||||
}
|
||||
|
||||
const status = ref<RouterStatus | null>(null)
|
||||
@ -47,14 +64,20 @@ const sshUser = ref('root')
|
||||
const sshPassword = ref('')
|
||||
const showConnectForm = ref(false)
|
||||
const connecting = ref(false)
|
||||
|
||||
// Credentials used for the last successful connection (reused for provisioning)
|
||||
const connectedParams = ref<Record<string, string> | null>(null)
|
||||
|
||||
const provisioning = ref(false)
|
||||
const provisionError = ref('')
|
||||
const provisionSuccess = ref(false)
|
||||
|
||||
// WAN setup flow
|
||||
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'connecting' | 'done'
|
||||
const wanStep = ref<WanStep>('idle')
|
||||
const scannedNetworks = ref<ScannedNetwork[]>([])
|
||||
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
||||
const wanPassword = ref('')
|
||||
const wanError = ref('')
|
||||
|
||||
async function load(params?: Record<string, string>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@ -95,13 +118,11 @@ async function provisionTollgate() {
|
||||
provisionSuccess.value = false
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
// Use explicitly connected creds if available, otherwise fall back to
|
||||
// host from the loaded status (backend will use saved router_config).
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 180000 })
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
provisionSuccess.value = true
|
||||
await load(connectedParams.value ?? undefined)
|
||||
} catch (e) {
|
||||
@ -111,6 +132,76 @@ async function provisionTollgate() {
|
||||
}
|
||||
}
|
||||
|
||||
function startWanSetup() {
|
||||
wanStep.value = 'scan'
|
||||
wanError.value = ''
|
||||
scannedNetworks.value = []
|
||||
selectedNetwork.value = null
|
||||
wanPassword.value = ''
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
wanStep.value = 'scanning'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
|
||||
method: 'openwrt.scan-wifi',
|
||||
params,
|
||||
timeout: 30000,
|
||||
})
|
||||
scannedNetworks.value = result.networks
|
||||
wanStep.value = 'list'
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'scan'
|
||||
}
|
||||
}
|
||||
|
||||
function selectNetwork(net: ScannedNetwork) {
|
||||
selectedNetwork.value = net
|
||||
wanPassword.value = ''
|
||||
wanError.value = ''
|
||||
wanStep.value = 'password'
|
||||
}
|
||||
|
||||
async function configureWan() {
|
||||
if (!selectedNetwork.value) return
|
||||
wanStep.value = 'connecting'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
ssid: selectedNetwork.value.ssid,
|
||||
password: wanPassword.value,
|
||||
encryption: selectedNetwork.value.encryption,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 })
|
||||
wanStep.value = 'done'
|
||||
// Give the router ~8s to associate before reloading status
|
||||
setTimeout(() => {
|
||||
wanStep.value = 'idle'
|
||||
load(connectedParams.value ?? undefined)
|
||||
}, 8000)
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'password'
|
||||
}
|
||||
}
|
||||
|
||||
function signalBars(dbm: number): number {
|
||||
if (dbm >= -50) return 4
|
||||
if (dbm >= -65) return 3
|
||||
if (dbm >= -75) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function formatUptime(secs: number): string {
|
||||
const d = Math.floor(secs / 86400)
|
||||
const h = Math.floor((secs % 86400) / 3600)
|
||||
@ -150,7 +241,7 @@ onMounted(() => load())
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
<!-- Connect form (shown when no router is configured) -->
|
||||
<!-- Connect form -->
|
||||
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
|
||||
<div class="space-y-3">
|
||||
@ -245,14 +336,137 @@ onMounted(() => load())
|
||||
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors"
|
||||
@click="load()"
|
||||
>
|
||||
<button class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors" @click="load()">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WAN / Uplink -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80">WAN / Uplink</h2>
|
||||
<button
|
||||
v-if="wanStep === 'idle' || wanStep === 'done'"
|
||||
class="text-xs text-white/40 hover:text-white transition-colors"
|
||||
@click="startWanSetup"
|
||||
>
|
||||
{{ status.wan?.configured ? 'Reconfigure →' : 'Set up →' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current status (idle) -->
|
||||
<template v-if="wanStep === 'idle' || wanStep === 'done'">
|
||||
<div v-if="status.wan?.configured" class="flex items-center gap-3">
|
||||
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0"
|
||||
:class="status.wan.internet ? 'bg-green-400' : 'bg-yellow-400 animate-pulse'"></span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ status.wan.ssid }}</div>
|
||||
<div class="text-xs text-white/40">
|
||||
{{ status.wan.internet ? status.wan.ip : 'Connecting…' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-white/50">
|
||||
Not configured — router has no internet access.
|
||||
</div>
|
||||
<p v-if="wanStep === 'done'" class="mt-3 text-xs text-green-400">
|
||||
WAN configured. Router is connecting…
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: scan prompt -->
|
||||
<template v-else-if="wanStep === 'scan'">
|
||||
<button
|
||||
class="glass-button glass-button-warning w-full text-sm font-medium"
|
||||
@click="scanWifi"
|
||||
>
|
||||
Scan for Networks
|
||||
</button>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'idle'">Cancel</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: scanning -->
|
||||
<template v-else-if="wanStep === 'scanning'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Scanning for networks…</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step: network list -->
|
||||
<template v-else-if="wanStep === 'list'">
|
||||
<p class="text-xs text-white/40 mb-3">{{ scannedNetworks.length }} network{{ scannedNetworks.length !== 1 ? 's' : '' }} found. Select one:</p>
|
||||
<div v-if="scannedNetworks.length === 0" class="text-sm text-white/50">No networks found. Try scanning again.</div>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
v-for="net in scannedNetworks"
|
||||
:key="net.bssid"
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg border border-white/10 hover:border-white/30 hover:bg-white/5 transition-colors text-left"
|
||||
@click="selectNetwork(net)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Signal bars -->
|
||||
<div class="flex items-end gap-0.5 h-4">
|
||||
<div v-for="b in 4" :key="b"
|
||||
class="w-1 rounded-sm"
|
||||
:class="[
|
||||
b * 1 <= signalBars(net.signal) ? 'bg-white/70' : 'bg-white/15',
|
||||
b === 1 ? 'h-1' : b === 2 ? 'h-2' : b === 3 ? 'h-3' : 'h-4'
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-white text-sm">{{ net.ssid || '(hidden)' }}</span>
|
||||
<span v-if="net.encryption !== 'none'" class="text-white/30 text-xs">🔒</span>
|
||||
</div>
|
||||
<span class="text-xs text-white/30 font-mono flex-shrink-0">ch{{ net.channel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'scan'">← Scan again</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: password entry -->
|
||||
<template v-else-if="wanStep === 'password'">
|
||||
<p class="text-sm text-white mb-3">
|
||||
Connect to <span class="font-semibold">{{ selectedNetwork?.ssid }}</span>
|
||||
</p>
|
||||
<input
|
||||
v-if="selectedNetwork?.encryption !== 'none'"
|
||||
v-model="wanPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-4 py-3 mb-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
@keyup.enter="configureWan"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
||||
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="configureWan"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
||||
</div>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: applying -->
|
||||
<template v-else-if="wanStep === 'connecting'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Applying WAN config…</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- TollGate -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
||||
@ -262,6 +476,9 @@ onMounted(() => load())
|
||||
<span class="w-2 h-2 rounded-full bg-white/20 inline-block"></span>
|
||||
<span class="text-sm text-white/50">Not installed</span>
|
||||
</div>
|
||||
<div v-if="!status.wan?.internet" class="mb-3 text-xs text-yellow-400/80 bg-yellow-400/10 rounded-lg px-3 py-2">
|
||||
Router needs internet access to install TollGate. Configure WAN above first.
|
||||
</div>
|
||||
<button
|
||||
:disabled="provisioning"
|
||||
class="glass-button glass-button-success w-full text-sm font-medium"
|
||||
@ -276,10 +493,8 @@ onMounted(() => load())
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full inline-block"
|
||||
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"
|
||||
></span>
|
||||
<span class="w-2 h-2 rounded-full inline-block"
|
||||
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
|
||||
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
@ -322,15 +537,11 @@ onMounted(() => load())
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full inline-block"
|
||||
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"
|
||||
></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block"
|
||||
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"></span>
|
||||
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
|
||||
<span
|
||||
v-if="iface.network === 'tollgate'"
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300"
|
||||
>TollGate</span>
|
||||
<span v-if="iface.network === 'tollgate'"
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">TollGate</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/40 ml-3.5">
|
||||
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user