mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(channels): read-only Channels view for Meshtastic + MeshCore (#103)
Add a read-only Channels page listing both transports' channels with the correct identifier per transport: Meshtastic by channel index (index, name, role) from /api/channels, and MeshCore by channel name plus the on-air hash from a new /api/meshcore/channels/detail endpoint. The MeshCore transport now retains the channel_hash it already fetches at enumeration (previously discarded); known_channels() and the existing /api/meshcore/channels endpoint are unchanged. Adds a Channels nav entry and /channels route. 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
0feb8adaca
commit
486ad1016f
6 changed files with 212 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ import ScheduledBroadcasts from './pages/ScheduledBroadcasts'
|
||||||
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
||||||
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
||||||
import Coverage from './pages/Coverage'
|
import Coverage from './pages/Coverage'
|
||||||
|
import Channels from './pages/Channels'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
import { DirtyProvider } from './context/DirtyContext'
|
import { DirtyProvider } from './context/DirtyContext'
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ function App() {
|
||||||
{/* New aggregated pages */}
|
{/* New aggregated pages */}
|
||||||
<Route path="/places" element={<Places />} />
|
<Route path="/places" element={<Places />} />
|
||||||
<Route path="/coverage" element={<Coverage />} />
|
<Route path="/coverage" element={<Coverage />} />
|
||||||
|
<Route path="/channels" element={<Channels />} />
|
||||||
{/* Custom sources folded into Data Feeds; keep old bookmark working */}
|
{/* Custom sources folded into Data Feeds; keep old bookmark working */}
|
||||||
<Route path="/data-sources" element={<Navigate to="/environment" replace />} />
|
<Route path="/data-sources" element={<Navigate to="/environment" replace />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const navGroups: NavGroup[] = [
|
||||||
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
||||||
{ path: '/places', label: 'Places', icon: MapPin },
|
{ path: '/places', label: 'Places', icon: MapPin },
|
||||||
{ path: '/coverage', label: 'Coverage', icon: Map },
|
{ path: '/coverage', label: 'Coverage', icon: Map },
|
||||||
|
{ path: '/channels', label: 'Channels', icon: Radio },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -595,6 +595,29 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
||||||
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
|
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 }
|
||||||
|
export interface MeshcoreChannelsDetail {
|
||||||
|
active: boolean
|
||||||
|
channels: MeshcoreChannelDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMeshcoreChannelsDetail(): Promise<MeshcoreChannelsDetail> {
|
||||||
|
return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meshtastic radio channels (routes by channel index).
|
||||||
|
export interface MeshtasticChannel {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannels(): Promise<MeshtasticChannel[]> {
|
||||||
|
return fetchJson<MeshtasticChannel[]>('/api/channels')
|
||||||
|
}
|
||||||
|
|
||||||
export interface MeshcoreContact {
|
export interface MeshcoreContact {
|
||||||
name: string | null
|
name: string | null
|
||||||
pubkey: string
|
pubkey: string
|
||||||
|
|
|
||||||
153
work/dashboard-frontend/src/pages/Channels.tsx
Normal file
153
work/dashboard-frontend/src/pages/Channels.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,24 @@ async def meshcore_channels(request: Request):
|
||||||
return {"active": False, "channels": []}
|
return {"active": False, "channels": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meshcore/channels/detail")
|
||||||
|
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}]}.
|
||||||
|
Routes by channel NAME (no slot/index), so no index is exposed here.
|
||||||
|
"""
|
||||||
|
connector = getattr(request.app.state, "connector", None)
|
||||||
|
mc = _find_child(connector, "meshcore")
|
||||||
|
if mc is not None and getattr(mc, "connected", False):
|
||||||
|
try:
|
||||||
|
channels = list(mc.channel_details())
|
||||||
|
except Exception:
|
||||||
|
channels = []
|
||||||
|
return {"active": True, "channels": channels}
|
||||||
|
return {"active": False, "channels": []}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meshcore/contacts")
|
@router.get("/meshcore/contacts")
|
||||||
async def meshcore_contacts(request: Request):
|
async def meshcore_contacts(request: Request):
|
||||||
"""Roster of known MeshCore contacts if a meshcore transport is connected."""
|
"""Roster of known MeshCore contacts if a meshcore transport is connected."""
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# Companion channel table: channel NAME -> slot index, built at
|
# Companion channel table: channel NAME -> slot index, built at
|
||||||
# connect time by _enumerate_channels(). Empty until connected.
|
# connect time by _enumerate_channels(). Empty until connected.
|
||||||
self._chan_name_to_idx: dict[str, int] = {}
|
self._chan_name_to_idx: dict[str, int] = {}
|
||||||
|
# Ordered [{name, hash}] detail for each enumerated channel (incl.
|
||||||
|
# Public), captured alongside _chan_name_to_idx at connect. Read-only
|
||||||
|
# view for the dashboard Channels page. Empty until connected.
|
||||||
|
self._chan_details: list[dict] = []
|
||||||
# Self-advertisement tracking.
|
# Self-advertisement tracking.
|
||||||
self._last_advert_sent: Optional[float] = None # epoch seconds or None
|
self._last_advert_sent: Optional[float] = None # epoch seconds or None
|
||||||
# asyncio.Task handle for the periodic advert loop; None when inactive.
|
# asyncio.Task handle for the periodic advert loop; None when inactive.
|
||||||
|
|
@ -758,6 +762,7 @@ class MeshCoreTransport(MeshTransport):
|
||||||
with a hard cap of 40 slots.
|
with a hard cap of 40 slots.
|
||||||
"""
|
"""
|
||||||
self._chan_name_to_idx = {}
|
self._chan_name_to_idx = {}
|
||||||
|
self._chan_details = []
|
||||||
try:
|
try:
|
||||||
empty_run = 0
|
empty_run = 0
|
||||||
for idx in range(40):
|
for idx in range(40):
|
||||||
|
|
@ -787,6 +792,10 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# Named slot: exact, case-sensitive (firmware name is already
|
# Named slot: exact, case-sensitive (firmware name is already
|
||||||
# null-truncated / utf-8-decoded — do NOT trim or lowercase).
|
# null-truncated / utf-8-decoded — do NOT trim or lowercase).
|
||||||
self._chan_name_to_idx[name] = slot
|
self._chan_name_to_idx[name] = slot
|
||||||
|
# Additive read-only detail: preserve order, capture on-air hash.
|
||||||
|
self._chan_details.append(
|
||||||
|
{"name": name, "hash": payload.get("channel_hash")}
|
||||||
|
)
|
||||||
empty_run = 0
|
empty_run = 0
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MeshCore: channel enumeration error: %s", exc)
|
logger.warning("MeshCore: channel enumeration error: %s", exc)
|
||||||
|
|
@ -799,6 +808,12 @@ class MeshCoreTransport(MeshTransport):
|
||||||
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
|
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
|
||||||
return list(self._chan_name_to_idx.keys())
|
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."""
|
||||||
|
return list(self._chan_details)
|
||||||
|
|
||||||
def get_contacts(self) -> list[dict]:
|
def get_contacts(self) -> list[dict]:
|
||||||
"""Roster of known MeshCore contacts. [] if not connected."""
|
"""Roster of known MeshCore contacts. [] if not connected."""
|
||||||
if self._mc is None or not self._connected:
|
if self._mc is None or not self._connected:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue