feat(mesh): Device settings tab (backlog #8)
New MeshDevicePanel.vue, added as a 4th/5th tab entry to activeTab/toolsTab/ mobileTab following the exact existing pattern (chat/bitcoin/deadman/ assistant/map). Shows firmware version, node ID, advert name, LoRa region, channel, and device type -- firmware_version/self_node_id were already server-side but never rendered; region is new (composed into MeshStatus from MeshConfig.lora_region at read time, not part of the live session state). Reboot button wired to the already-working mesh.reboot-radio RPC. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4a309a3ee4
commit
494f272815
@ -275,6 +275,7 @@ impl MeshState {
|
||||
channel_name: channel_name.to_string(),
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
region: None,
|
||||
}),
|
||||
event_tx: tx,
|
||||
next_message_id: RwLock::new(1),
|
||||
|
||||
@ -939,7 +939,13 @@ impl MeshService {
|
||||
|
||||
/// Get current mesh status.
|
||||
pub async fn status(&self) -> MeshStatus {
|
||||
self.state.status.read().await.clone()
|
||||
let mut status = self.state.status.read().await.clone();
|
||||
// The operator-configured LoRa region isn't part of the live session
|
||||
// state (it's config, read once at session start) — compose it in
|
||||
// here rather than threading it through the session's shared status
|
||||
// writes, for the Device tab (#8) to display.
|
||||
status.region = self.config.lora_region.clone();
|
||||
status
|
||||
}
|
||||
|
||||
/// Get a reference to the shared mesh state.
|
||||
|
||||
@ -178,6 +178,11 @@ pub struct MeshStatus {
|
||||
pub channel_name: String,
|
||||
pub messages_sent: u64,
|
||||
pub messages_received: u64,
|
||||
/// Operator-configured LoRa region (e.g. "EU_868"), for the Device tab
|
||||
/// (#8) — composed in by `MeshService::status()` from `MeshConfig`, not
|
||||
/// part of the live session state itself.
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// Information returned from device during initialization.
|
||||
|
||||
@ -19,6 +19,8 @@ export interface MeshStatus {
|
||||
/** Bitcoin block-header send/receive prefs (issue #28). */
|
||||
announce_block_headers?: boolean
|
||||
receive_block_headers?: boolean
|
||||
/** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */
|
||||
region?: string | null
|
||||
}
|
||||
|
||||
export interface MeshPeer {
|
||||
@ -555,6 +557,13 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function rebootRadio(seconds = 2) {
|
||||
return rpcClient.call<{ reboot: boolean; seconds: number }>({
|
||||
method: 'mesh.reboot-radio',
|
||||
params: { seconds },
|
||||
})
|
||||
}
|
||||
|
||||
async function getOutbox() {
|
||||
try {
|
||||
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
|
||||
@ -812,6 +821,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
fetchContent,
|
||||
sendReply,
|
||||
sendReaction,
|
||||
rebootRadio,
|
||||
getOutbox,
|
||||
sendReadReceipt,
|
||||
forwardMessage,
|
||||
|
||||
@ -8,6 +8,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import MeshMap from '@/components/MeshMap.vue'
|
||||
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
|
||||
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||
import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
|
||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
@ -259,16 +260,16 @@ async function clearAllMesh() {
|
||||
}
|
||||
|
||||
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
|
||||
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')
|
||||
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('bitcoin')
|
||||
|
||||
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
|
||||
// 'chat' shows the peers list / active conversation; the rest swap the pane to
|
||||
// that tool. Selecting a tool leaves any open conversation.
|
||||
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
|
||||
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')
|
||||
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device') {
|
||||
mobileTab.value = tab
|
||||
if (tab !== 'chat') mobileShowChat.value = false
|
||||
}
|
||||
@ -301,6 +302,12 @@ const showMapPanel = computed(() => {
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showDevicePanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'device'
|
||||
if (isMobile.value) return mobileTab.value === 'device'
|
||||
return activeTab.value === 'device'
|
||||
})
|
||||
// Mobile: tool pane shows whenever a non-chat tab is active.
|
||||
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
|
||||
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||
@ -1860,6 +1867,9 @@ function isImageMime(mime?: string): boolean {
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
|
||||
Map
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'device' }" @click="activeTab = 'device'" title="Device settings">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
@ -2203,11 +2213,13 @@ function isImageMime(mime?: string): boolean {
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'device' }" @click="toolsTab = 'device'" title="Device settings">⚙️</button>
|
||||
</div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<MeshAssistantPanel v-if="showAssistantPanel" />
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshDevicePanel v-if="showDevicePanel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2217,6 +2229,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<MeshAssistantPanel v-if="showAssistantPanel" />
|
||||
<MeshDevicePanel v-if="showDevicePanel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2234,6 +2247,7 @@ function isImageMime(mime?: string): boolean {
|
||||
</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'device' }" @click="selectMobileTab('device')" title="Device settings">⚙️</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
|
||||
68
neode-ui/src/views/mesh/MeshDevicePanel.vue
Normal file
68
neode-ui/src/views/mesh/MeshDevicePanel.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
|
||||
const rebooting = ref(false)
|
||||
const rebootError = ref<string | null>(null)
|
||||
|
||||
async function handleReboot() {
|
||||
rebooting.value = true
|
||||
rebootError.value = null
|
||||
try {
|
||||
await mesh.rebootRadio()
|
||||
} catch (e) {
|
||||
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
|
||||
} finally {
|
||||
rebooting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card mesh-device-panel">
|
||||
<h3 class="mesh-panel-title">Device</h3>
|
||||
<p class="mesh-panel-sub">Firmware, identity, and radio controls for the connected mesh device</p>
|
||||
|
||||
<div v-if="mesh.status" class="mesh-device-panel-grid">
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Firmware</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.firmware_version ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Node ID</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Name</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Region</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Channel</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
|
||||
</div>
|
||||
<div class="mesh-stat">
|
||||
<span class="mesh-stat-label">Type</span>
|
||||
<span class="mesh-stat-value">{{ mesh.status.device_type === 'unknown' ? '—' : mesh.status.device_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mesh-device-panel-actions">
|
||||
<button
|
||||
class="glass-button mesh-device-reboot-btn"
|
||||
:disabled="rebooting || !mesh.status?.device_connected"
|
||||
@click="handleReboot"
|
||||
>
|
||||
<span v-if="rebooting" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<template v-else>Reboot Radio</template>
|
||||
</button>
|
||||
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
|
||||
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -368,6 +368,12 @@
|
||||
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||
.mesh-device-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-device-panel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.mesh-device-panel-actions { display: flex; flex-direction: column; gap: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.mesh-device-reboot-btn { align-self: flex-start; padding: 8px 16px; font-size: 0.85rem; }
|
||||
.mesh-device-reboot-hint { font-size: 0.75rem; color: rgba(255,255,255,0.4); margin: 0; }
|
||||
.mesh-device-reboot-error { font-size: 0.8rem; color: #ef4444; margin: 0; }
|
||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.mesh-bitcoin-label { font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user