mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
refactor(channels): move channel info onto the connection pages + expose MeshCore channel key (#104)
Remove the standalone Channels page and its nav entry (avoids menu bloat) and relocate the channel listings onto the existing connection pages: MeshCore channels (name, on-air hash, and the channel key/PSK with a copy button) on the MeshCore companion page, and Meshtastic channels (index, name, role) on the Meshtastic connection page. The MeshCore transport now retains the channel_secret it already fetches at enumeration and /api/meshcore/channels/detail returns it as `key` (hex PSK) so operators can provision companion radios. known_channels() and /api/meshcore/channels are unchanged. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
486ad1016f
commit
6e1831b606
8 changed files with 122 additions and 178 deletions
|
|
@ -22,7 +22,6 @@ import ScheduledBroadcasts from './pages/ScheduledBroadcasts'
|
|||
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
||||
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
||||
import Coverage from './pages/Coverage'
|
||||
import Channels from './pages/Channels'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { DirtyProvider } from './context/DirtyContext'
|
||||
|
||||
|
|
@ -47,7 +46,6 @@ function App() {
|
|||
{/* New aggregated pages */}
|
||||
<Route path="/places" element={<Places />} />
|
||||
<Route path="/coverage" element={<Coverage />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
{/* Custom sources folded into Data Feeds; keep old bookmark working */}
|
||||
<Route path="/data-sources" element={<Navigate to="/environment" replace />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const navGroups: NavGroup[] = [
|
|||
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
||||
{ path: '/places', label: 'Places', icon: MapPin },
|
||||
{ path: '/coverage', label: 'Coverage', icon: Map },
|
||||
{ path: '/channels', label: 'Channels', icon: Radio },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -595,8 +595,8 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
|||
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
|
||||
}
|
||||
|
||||
// MeshCore channels with on-air hash (routes by channel NAME, no slot/index).
|
||||
export interface MeshcoreChannelDetail { name: string; hash: string | null }
|
||||
// MeshCore channels with on-air hash + PSK key (routes by channel NAME, no slot/index).
|
||||
export interface MeshcoreChannelDetail { name: string; hash: string | null; key: string | null }
|
||||
export interface MeshcoreChannelsDetail {
|
||||
active: boolean
|
||||
channels: MeshcoreChannelDetail[]
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Radio } from 'lucide-react'
|
||||
import {
|
||||
getChannels,
|
||||
getMeshcoreChannelsDetail,
|
||||
type MeshtasticChannel,
|
||||
type MeshcoreChannelsDetail,
|
||||
} from '../lib/api'
|
||||
|
||||
/**
|
||||
* Read-only Channels overview.
|
||||
*
|
||||
* Two independent sections, one per mesh family:
|
||||
* - Meshtastic channels (routes by channel index)
|
||||
* - MeshCore channels (routes by channel name; no index/slot)
|
||||
*
|
||||
* Nothing here transmits or mutates — it is a status view only.
|
||||
*/
|
||||
export default function Channels() {
|
||||
const [mt, setMt] = useState<MeshtasticChannel[] | null>(null)
|
||||
const [mc, setMc] = useState<MeshcoreChannelsDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Channels - MeshAI'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [mtData, mcData] = await Promise.all([
|
||||
getChannels(),
|
||||
getMeshcoreChannelsDetail(),
|
||||
])
|
||||
if (cancelled) return
|
||||
setMt(mtData)
|
||||
setMc(mcData)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load channels')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const mtChannels = mt ?? []
|
||||
const mcChannels = mc?.active ? mc.channels : []
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
<Radio size={24} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">Channels</h2>
|
||||
<p className="text-sm text-[#777]">
|
||||
Channels configured on each connected radio. Read-only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-slate-400">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Meshtastic channels */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-slate-200">Meshtastic Channels</h3>
|
||||
<p className="text-xs text-[#555] mt-1">Routes by channel index.</p>
|
||||
</div>
|
||||
{mtChannels.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-slate-200">
|
||||
<thead className="bg-[#161616] border-b border-border">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Index</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mtChannels.map((ch) => (
|
||||
<tr key={ch.index} className="hover:bg-bg-hover">
|
||||
<td className="px-3 py-2 font-mono text-xs">{ch.index}</td>
|
||||
<td className="px-3 py-2">{ch.name}</td>
|
||||
<td className="px-3 py-2 text-xs text-[#999]">{ch.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-[#777]">
|
||||
Node offline — channels unavailable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MeshCore channels */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-slate-200">MeshCore Channels</h3>
|
||||
<p className="text-xs text-[#555] mt-1">Routes by channel name.</p>
|
||||
</div>
|
||||
{mcChannels.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-slate-200">
|
||||
<thead className="bg-[#161616] border-b border-border">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">On-air hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mcChannels.map((ch) => (
|
||||
<tr key={ch.name} className="hover:bg-bg-hover">
|
||||
<td className="px-3 py-2">{ch.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[#999]">
|
||||
{ch.hash != null ? `0x${ch.hash}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-[#777]">
|
||||
MeshCore not connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
import { Bot, Radio } from 'lucide-react'
|
||||
import {
|
||||
fetchMeshcoreSelf,
|
||||
getMeshcoreChannels,
|
||||
getMeshcoreChannelsDetail,
|
||||
sendMeshcoreAdvert,
|
||||
updateConfig,
|
||||
type MeshcoreSelf,
|
||||
type MeshcoreChannels,
|
||||
type MeshcoreChannelsDetail,
|
||||
type TestSendResult,
|
||||
} from '../lib/api'
|
||||
|
||||
|
|
@ -24,9 +24,10 @@ function relativeTime(epochSec: number): string {
|
|||
|
||||
export default function MeshCoreCompanion() {
|
||||
const [self, setSelf] = useState<MeshcoreSelf | null>(null)
|
||||
const [channels, setChannels] = useState<MeshcoreChannels | null>(null)
|
||||
const [channels, setChannels] = useState<MeshcoreChannelsDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
// Send Advert state
|
||||
const [advertSending, setAdvertSending] = useState(false)
|
||||
|
|
@ -50,7 +51,7 @@ export default function MeshCoreCompanion() {
|
|||
try {
|
||||
const [selfData, channelData] = await Promise.all([
|
||||
fetchMeshcoreSelf(),
|
||||
getMeshcoreChannels(),
|
||||
getMeshcoreChannelsDetail(),
|
||||
])
|
||||
if (cancelled) return
|
||||
setSelf(selfData)
|
||||
|
|
@ -125,8 +126,18 @@ export default function MeshCoreCompanion() {
|
|||
}
|
||||
}, [advertIntervalHours])
|
||||
|
||||
const handleCopyKey = useCallback(async (key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1500)
|
||||
} catch {
|
||||
// clipboard unavailable (e.g. non-secure context) — no-op
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connected = self?.connected === true
|
||||
const channelNames = channels?.active ? channels.channels : []
|
||||
const channelList = channels?.active ? channels.channels : []
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
|
|
@ -268,19 +279,52 @@ export default function MeshCoreCompanion() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
{/* Channel list — Name · Hash · Key (read-only) */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-slate-200">Channels</h3>
|
||||
<p className="text-xs text-[#555] mt-1">
|
||||
Key = the channel PSK; enter it (or the # name) on a companion radio to join.
|
||||
</p>
|
||||
</div>
|
||||
{channelNames.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{channelNames.map((name) => (
|
||||
<li key={name} className="px-4 py-2.5 text-sm text-slate-200 font-mono">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{channelList.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-slate-200">
|
||||
<thead className="bg-[#161616] border-b border-border">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">On-air hash</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Key</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{channelList.map((ch) => (
|
||||
<tr key={ch.name} className="hover:bg-bg-hover">
|
||||
<td className="px-3 py-2 font-mono">{ch.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[#999]">
|
||||
{ch.hash != null ? `0x${ch.hash}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{ch.key != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[#999] break-all">{ch.key}</span>
|
||||
<button
|
||||
onClick={() => handleCopyKey(ch.key as string)}
|
||||
className="flex-shrink-0 px-2 py-0.5 text-[10px] bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded transition-colors"
|
||||
title="Copy key to clipboard"
|
||||
>
|
||||
{copiedKey === ch.key ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-[#777]">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-[#777]">No channels</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfi
|
|||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
|
||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage, getChannels, type MeshtasticChannel } from '@/lib/api'
|
||||
import { useDirty } from '@/context/DirtyContext'
|
||||
|
||||
// Only the fields the "Bot behavior" section edits are typed explicitly; the
|
||||
|
|
@ -37,6 +37,9 @@ export default function MeshtasticConnection() {
|
|||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Read-only channel listing (index ↔ name ↔ role), fetched from the radio.
|
||||
const [channels, setChannels] = useState<MeshtasticChannel[]>([])
|
||||
|
||||
// Test send state
|
||||
const [testChannel, setTestChannel] = useState(0)
|
||||
const [testText, setTestText] = useState('')
|
||||
|
|
@ -86,6 +89,9 @@ export default function MeshtasticConnection() {
|
|||
useEffect(() => {
|
||||
document.title = 'Meshtastic Connection - MeshAI'
|
||||
fetchConfig()
|
||||
getChannels()
|
||||
.then(setChannels)
|
||||
.catch(() => setChannels([]))
|
||||
}, [fetchConfig])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -290,6 +296,40 @@ export default function MeshtasticConnection() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels card — read-only Index · Name · Role */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-slate-200">Channels</h3>
|
||||
<p className="text-xs text-[#555] mt-1">
|
||||
Channels configured on the radio. Meshtastic routes by channel index. Read-only.
|
||||
</p>
|
||||
</div>
|
||||
{channels.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-slate-200">
|
||||
<thead className="bg-[#161616] border-b border-border">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Index</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Name</th>
|
||||
<th className="px-3 py-2 font-sans text-[9px] uppercase tracking-widest text-[#666] text-left">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{channels.map((ch) => (
|
||||
<tr key={ch.index} className="hover:bg-bg-hover">
|
||||
<td className="px-3 py-2 font-mono text-xs">{ch.index}</td>
|
||||
<td className="px-3 py-2">{ch.name}</td>
|
||||
<td className="px-3 py-2 text-xs text-[#999]">{ch.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-[#777]">Node offline — channels unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send test message card */}
|
||||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ async def meshcore_channels(request: Request):
|
|||
async def meshcore_channels_detail(request: Request):
|
||||
"""Enumerated MeshCore channels with on-air hash, if connected.
|
||||
|
||||
Returns {"active": bool, "channels": [{"name": str, "hash": str|null}]}.
|
||||
Returns {"active": bool, "channels": [{"name": str, "hash": str|null, "key": str|null}]}.
|
||||
Routes by channel NAME (no slot/index), so no index is exposed here.
|
||||
"""
|
||||
connector = getattr(request.app.state, "connector", None)
|
||||
|
|
|
|||
|
|
@ -793,8 +793,23 @@ class MeshCoreTransport(MeshTransport):
|
|||
# null-truncated / utf-8-decoded — do NOT trim or lowercase).
|
||||
self._chan_name_to_idx[name] = slot
|
||||
# Additive read-only detail: preserve order, capture on-air hash.
|
||||
# Additive read-only detail: preserve order, capture on-air
|
||||
# hash + the channel KEY (PSK) as a hex string so operators
|
||||
# can provision the channel on companion radios. The lib
|
||||
# emits channel_secret as raw 16-byte PSK; hex-encode it.
|
||||
_secret = payload.get("channel_secret")
|
||||
if isinstance(_secret, (bytes, bytearray)):
|
||||
_key = _secret.hex()
|
||||
elif isinstance(_secret, str) and _secret:
|
||||
_key = _secret
|
||||
else:
|
||||
_key = None
|
||||
self._chan_details.append(
|
||||
{"name": name, "hash": payload.get("channel_hash")}
|
||||
{
|
||||
"name": name,
|
||||
"hash": payload.get("channel_hash"),
|
||||
"key": _key,
|
||||
}
|
||||
)
|
||||
empty_run = 0
|
||||
except Exception as exc:
|
||||
|
|
@ -809,9 +824,10 @@ class MeshCoreTransport(MeshTransport):
|
|||
return list(self._chan_name_to_idx.keys())
|
||||
|
||||
def channel_details(self) -> list[dict]:
|
||||
"""Ordered [{name, hash}] for each enumerated MeshCore channel (incl.
|
||||
Public); ``hash`` is the on-air channel hash or None. Read-only view
|
||||
for the dashboard; captured alongside _chan_name_to_idx at connect."""
|
||||
"""Ordered [{name, hash, key}] for each enumerated MeshCore channel
|
||||
(incl. Public); ``hash`` is the on-air channel hash or None, ``key``
|
||||
is the channel PSK as a hex string (or None). Read-only view for the
|
||||
dashboard; captured alongside _chan_name_to_idx at connect."""
|
||||
return list(self._chan_details)
|
||||
|
||||
def get_contacts(self) -> list[dict]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue