feat: add Network Interfaces section and WiFi scan modal to Server.vue
Shows detected physical interfaces (ethernet/wifi) with IP, MAC, and status. WiFi scan button opens a modal with signal strength bars and password-protected connection flow. Uses network.list-interfaces and network.scan-wifi RPC endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d26b95e256
commit
b9efd1b3d0
@ -54,7 +54,7 @@
|
||||
|
||||
- [x] **BACK-03** — Add WiFi/Ethernet configuration RPC endpoints. Create `core/archipelago/src/network/interfaces.rs` with: `network.list-interfaces` (lists eth0, wlan0, etc. with IP, MAC, status), `network.configure-wifi` (SSID, password, connects via `nmcli`), `network.configure-ethernet` (static IP or DHCP via `nmcli`), `network.scan-wifi` (available networks). Register in RPC router. **Acceptance**: `network.list-interfaces` returns real interface data on dev server.
|
||||
|
||||
- [ ] **BACK-04** — Add WiFi/Ethernet UI to Server.vue. Add a "Network Interfaces" section to Server.vue showing detected interfaces with their IPs and statuses. For WiFi, add "Scan & Connect" button that opens a modal listing available networks. For Ethernet, show DHCP/Static toggle. Use `glass-card` container with `bg-white/5` sub-rows. **Acceptance**: Real network interfaces visible on Server page; WiFi scan works on dev server. Deploy and verify.
|
||||
- [x] **BACK-04** — Add WiFi/Ethernet UI to Server.vue. Add a "Network Interfaces" section to Server.vue showing detected interfaces with their IPs and statuses. For WiFi, add "Scan & Connect" button that opens a modal listing available networks. For Ethernet, show DHCP/Static toggle. Use `glass-card` container with `bg-white/5` sub-rows. **Acceptance**: Real network interfaces visible on Server page; WiFi scan works on dev server. Deploy and verify.
|
||||
|
||||
- [ ] **BACK-05** — Implement CSRF protection on RPC layer. Address the High-severity finding from `docs/security-audit-2026-03-05.md`. Add CSRF token generation on login (return as cookie + response field), validate on all state-changing RPC calls. In `core/archipelago/src/api/rpc/mod.rs`, add `X-CSRF-Token` header check for non-GET methods. In `neode-ui/src/api/rpc-client.ts`, read the CSRF cookie and send it as header. **Acceptance**: RPC calls without CSRF token return 403; calls with correct token succeed.
|
||||
|
||||
|
||||
@ -232,11 +232,115 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="interfacesLoading">
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="iface in physicalInterfaces"
|
||||
:key="iface.name"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
|
||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
||||
<p v-else class="text-sm text-white/40">No IP</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Scan Modal -->
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
||||
<button @click="showWifiModal = false" class="text-white/40 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="wifiScanning">
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 4" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-12"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="wifiNetworks.length > 0">
|
||||
<div class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<button
|
||||
v-for="net in wifiNetworks"
|
||||
:key="net.ssid"
|
||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||
@click="selectWifi(net.ssid)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
|
||||
<p class="text-xs text-white/50">{{ net.security || 'Open' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-0.5">
|
||||
<div v-for="bar in 4" :key="bar" class="w-1 rounded-full" :class="bar <= Math.ceil(net.signal / 25) ? 'bg-white/80' : 'bg-white/20'" :style="{ height: (bar * 3 + 4) + 'px' }"></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/50">{{ net.signal }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
|
||||
</template>
|
||||
|
||||
<!-- WiFi password prompt -->
|
||||
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
|
||||
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
|
||||
<input
|
||||
v-model="wifiPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
|
||||
@keyup.enter="connectToWifi"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button @click="wifiConnecting = false; wifiPassword = ''" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
|
||||
<button @click="connectToWifi" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium" :disabled="!wifiPassword">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
// Connected nodes
|
||||
@ -300,9 +404,89 @@ async function loadPeerCount() {
|
||||
}
|
||||
}
|
||||
|
||||
// Network interfaces
|
||||
interface NetworkInterface {
|
||||
name: string
|
||||
type: string
|
||||
state: string
|
||||
mac: string
|
||||
ipv4: string[]
|
||||
}
|
||||
|
||||
interface WifiNetwork {
|
||||
ssid: string
|
||||
signal: number
|
||||
security: string
|
||||
}
|
||||
|
||||
const interfacesLoading = ref(true)
|
||||
const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() =>
|
||||
allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')
|
||||
)
|
||||
const wifiAvailable = computed(() =>
|
||||
allInterfaces.value.some(i => i.type === 'wifi')
|
||||
)
|
||||
|
||||
const showWifiModal = ref(false)
|
||||
const wifiScanning = ref(false)
|
||||
const wifiNetworks = ref<WifiNetwork[]>([])
|
||||
const wifiConnecting = ref(false)
|
||||
const wifiSelectedSsid = ref('')
|
||||
const wifiPassword = ref('')
|
||||
|
||||
async function loadInterfaces() {
|
||||
interfacesLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' })
|
||||
allInterfaces.value = res.interfaces
|
||||
} catch {
|
||||
allInterfaces.value = []
|
||||
} finally {
|
||||
interfacesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
wifiScanning.value = true
|
||||
wifiNetworks.value = []
|
||||
try {
|
||||
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
|
||||
wifiNetworks.value = res.networks
|
||||
} catch {
|
||||
wifiNetworks.value = []
|
||||
} finally {
|
||||
wifiScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectWifi(ssid: string) {
|
||||
wifiSelectedSsid.value = ssid
|
||||
wifiPassword.value = ''
|
||||
wifiConnecting.value = true
|
||||
}
|
||||
|
||||
async function connectToWifi() {
|
||||
if (!wifiPassword.value || !wifiSelectedSsid.value) return
|
||||
try {
|
||||
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password: wifiPassword.value } })
|
||||
showWifiModal.value = false
|
||||
wifiConnecting.value = false
|
||||
wifiPassword.value = ''
|
||||
loadInterfaces()
|
||||
} catch {
|
||||
if (import.meta.env.DEV) console.warn('WiFi connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNetworkData()
|
||||
loadPeerCount()
|
||||
loadInterfaces()
|
||||
})
|
||||
|
||||
watch(showWifiModal, (open) => {
|
||||
if (open) scanWifi()
|
||||
})
|
||||
|
||||
async function restartServices() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user