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:
Dorian 2026-03-11 00:35:55 +00:00
parent d26b95e256
commit b9efd1b3d0
2 changed files with 186 additions and 2 deletions

View File

@ -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.

View File

@ -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' }} &middot; {{ 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() {