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.
|
- [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.
|
- [ ] **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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
// Connected nodes
|
// 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(() => {
|
onMounted(() => {
|
||||||
loadNetworkData()
|
loadNetworkData()
|
||||||
loadPeerCount()
|
loadPeerCount()
|
||||||
|
loadInterfaces()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showWifiModal, (open) => {
|
||||||
|
if (open) scanWifi()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function restartServices() {
|
async function restartServices() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user