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:
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user