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:
malice 2026-07-09 09:36:31 -06:00 committed by GitHub
commit 486ad1016f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 212 additions and 0 deletions

View file

@ -22,6 +22,7 @@ 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'
@ -46,6 +47,7 @@ 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 />} />

View file

@ -53,6 +53,7 @@ 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 },
],
},
{

View file

@ -595,6 +595,29 @@ 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 }
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 {
name: string | null
pubkey: string

View 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>
)
}