MeshCore Contacts roster + Companion status (read-only) (#17)

* feat(dashboard): MeshCore Contacts roster + Companion status (read-only)

Expose the live companion's contact roster (get_contacts) and self/channel
status via /api/meshcore/contacts + /api/meshcore/self. Fill the Contacts
(roster table) and Companion (status + channels) pages. Telemetry auto-poll
comes next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(meshcore): self-advertisement (send-advert + advert-on-connect + periodic)

AIDA now announces itself: send_advert(flood=True) on every connect, an
optional periodic auto-advert (meshcore_advert_interval_seconds), and a
manual "Send Advert" button + POST /api/meshcore/advert. Makes the
companion discoverable/DM-able on the mesh.

---------

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-03 16:14:10 -06:00 committed by GitHub
commit 284fb5cbf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1142 additions and 42 deletions

View file

@ -492,6 +492,48 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
return fetchJson<MeshcoreChannels>('/api/meshcore/channels')
}
export interface MeshcoreContact {
name: string | null
pubkey: string
type: string | null
last_advert: number | null
lat: number | null
lon: number | null
out_path_len: number | null
}
export interface MeshcoreContacts {
active: boolean
contacts: MeshcoreContact[]
}
export interface MeshcoreSelf {
name?: string | null
pubkey?: string | null
connected: boolean
host?: string
port?: number
channel_count?: number
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
}
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
return fetchJson<MeshcoreContacts>('/api/meshcore/contacts')
}
export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
return fetchJson<MeshcoreSelf>('/api/meshcore/self')
}
export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
const response = await fetch('/api/meshcore/advert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function sendTestMessage(body: {
transport: 'meshtastic' | 'meshcore'
channel: string | number

View file

@ -1,34 +1,292 @@
import { useEffect } from 'react'
import { Bot } from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import { Bot, Radio } from 'lucide-react'
import {
fetchMeshcoreSelf,
getMeshcoreChannels,
sendMeshcoreAdvert,
updateConfig,
type MeshcoreSelf,
type MeshcoreChannels,
type TestSendResult,
} from '../lib/api'
/** Format epoch seconds as a human-readable relative time string. */
function relativeTime(epochSec: number): string {
const diffSec = Math.floor(Date.now() / 1000 - epochSec)
if (diffSec < 5) return 'just now'
if (diffSec < 60) return `${diffSec}s ago`
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.floor(diffHr / 24)}d ago`
}
export default function MeshCoreCompanion() {
const [self, setSelf] = useState<MeshcoreSelf | null>(null)
const [channels, setChannels] = useState<MeshcoreChannels | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Send Advert state
const [advertSending, setAdvertSending] = useState(false)
const [advertResult, setAdvertResult] = useState<TestSendResult | null>(null)
// Auto-advert control state — interval in hours (0 = disabled)
// Loaded from connection config; editable in-page and PUTted back.
const [advertIntervalHours, setAdvertIntervalHours] = useState<number>(3)
const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false)
const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false)
useEffect(() => {
document.title = 'Companion & Channels - MeshAI'
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setError(null)
try {
const [selfData, channelData] = await Promise.all([
fetchMeshcoreSelf(),
getMeshcoreChannels(),
])
if (cancelled) return
setSelf(selfData)
setChannels(channelData)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Failed to load companion status')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
// Load advert interval from connection config on mount.
useEffect(() => {
;(async () => {
try {
const resp = await fetch('/api/config/connection')
if (resp.ok) {
const data = await resp.json() as Record<string, unknown>
const sec = data['meshcore_advert_interval_seconds']
if (typeof sec === 'number') {
setAdvertIntervalHours(sec > 0 ? sec / 3600 : 0)
}
}
} catch {
// non-fatal — keep default
}
})()
}, [])
const handleSendAdvert = useCallback(async () => {
setAdvertSending(true)
setAdvertResult(null)
try {
const result = await sendMeshcoreAdvert()
setAdvertResult(result)
if (result.sent) {
// Refresh self to pick up updated last_advert_sent.
try {
const updated = await fetchMeshcoreSelf()
setSelf(updated)
} catch {
// non-fatal
}
}
} catch (err) {
setAdvertResult({
sent: false,
detail: err instanceof Error ? err.message : 'Request failed',
})
} finally {
setAdvertSending(false)
}
}, [])
const handleSaveAdvertInterval = useCallback(async () => {
setAdvertIntervalSaving(true)
setAdvertIntervalSaved(false)
try {
const seconds = Math.round(advertIntervalHours * 3600)
await updateConfig('connection', { meshcore_advert_interval_seconds: seconds })
setAdvertIntervalSaved(true)
setTimeout(() => setAdvertIntervalSaved(false), 2000)
} catch {
// keep saving=false, let UI show failure implicitly
} finally {
setAdvertIntervalSaving(false)
}
}, [advertIntervalHours])
const connected = self?.connected === true
const channelNames = channels?.active ? channels.channels : []
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Bot size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show live status for the AIDA MeshCore companion &mdash; its connection
health and the list of channels it is currently joined to. Once the companion status
API is available, you'll be able to monitor the companion here and see which channels
are reachable for broadcast delivery.
</p>
</div>
<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">
<Bot size={24} className="text-accent" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<p className="text-sm text-[#777]">
Live status for the AIDA MeshCore companion and its joined channels.
</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>
) : (
<>
{/* Status card */}
<div className="bg-bg-card border border-border p-6">
{connected ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-400">Connected</span>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4 text-sm">
<div>
<dt className="text-[#777] mb-1">Node name</dt>
<dd className="text-slate-100">{self?.name ?? 'unnamed'}</dd>
</div>
<div>
<dt className="text-[#777] mb-1">Host</dt>
<dd className="text-slate-100 font-mono">
{self?.host ?? '—'}
{self?.port != null ? `:${self.port}` : ''}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[#777] mb-1">Public key</dt>
<dd className="text-slate-100 font-mono text-xs break-all">
{self?.pubkey ?? '—'}
</dd>
</div>
<div>
<dt className="text-[#777] mb-1">Channels joined</dt>
<dd className="text-slate-100">{self?.channel_count ?? 0}</dd>
</div>
{self?.last_advert_sent != null && (
<div>
<dt className="text-[#777] mb-1">Last advertised</dt>
<dd className="text-slate-100">{relativeTime(self.last_advert_sent)}</dd>
</div>
)}
</dl>
{/* Send Advert */}
<div className="pt-2 border-t border-border space-y-2">
<div className="flex items-center gap-3">
<button
onClick={handleSendAdvert}
disabled={advertSending}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Radio size={14} />
{advertSending ? 'Sending…' : 'Send Advert'}
</button>
{advertResult != null && (
<span
className={`text-sm ${advertResult.sent ? 'text-green-400' : 'text-red-400'}`}
>
{advertResult.sent ? 'Advert sent' : advertResult.detail}
</span>
)}
</div>
<p className="text-xs text-[#555]">
Announce this node to the mesh so others can discover and DM it.
</p>
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-slate-600" />
<span className="text-sm font-medium text-slate-400">Not connected</span>
</div>
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
The MeshCore companion is offline or inactive. No node identity or channel
membership is available while the companion is disconnected.
</p>
</div>
)}
</div>
{/* Advertising settings */}
<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">Advertising</h3>
</div>
<div className="px-4 py-4 space-y-4">
<div className="space-y-1">
<label className="text-xs font-medium text-[#777] uppercase tracking-wide">
Auto-advert interval
</label>
<div className="flex items-center gap-3">
<select
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(Number(e.target.value))}
className="bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 text-sm rounded px-2 py-1.5 focus:outline-none focus:border-accent"
>
<option value={0}>Disabled</option>
<option value={1}>Every 1 hour</option>
<option value={3}>Every 3 hours (default)</option>
<option value={6}>Every 6 hours</option>
<option value={12}>Every 12 hours</option>
<option value={24}>Every 24 hours</option>
</select>
<button
onClick={handleSaveAdvertInterval}
disabled={advertIntervalSaving}
className="px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 transition-colors"
>
{advertIntervalSaving ? 'Saving…' : advertIntervalSaved ? 'Saved' : 'Save'}
</button>
</div>
<p className="text-xs text-[#555]">
AIDA sends a flood advertisement at this interval so it stays discoverable.
Stored in <code className="text-accent/80">connection.meshcore_advert_interval_seconds</code>.
</p>
</div>
</div>
</div>
{/* Channel list */}
<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>
</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>
) : (
<div className="px-4 py-3 text-sm text-[#777]">No channels</div>
)}
</div>
</>
)}
</div>
)
}

View file

@ -1,34 +1,160 @@
import { useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Users } from 'lucide-react'
import {
fetchMeshcoreContacts,
type MeshcoreContacts,
type MeshcoreContact,
} from '../lib/api'
function relativeTime(epochSeconds: number | null): string {
if (epochSeconds == null) return '—'
const diff = Math.floor(Date.now() / 1000) - epochSeconds
if (diff < 0) return 'just now'
if (diff < 60) return `${diff}s ago`
const mins = Math.floor(diff / 60)
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
chat: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' },
repeater: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' },
room: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' },
sensor: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' },
}
function TypeBadge({ type }: { type: string | null }) {
const meta = (type && TYPE_BADGES[type]) || {
label: type ?? 'unknown',
className: 'bg-slate-600/30 text-slate-400',
}
return (
<span className={`px-2 py-0.5 text-[10px] uppercase tracking-wide rounded ${meta.className}`}>
{meta.label}
</span>
)
}
function contactName(c: MeshcoreContact): string {
if (c.name) return c.name
if (c.pubkey) return `${c.pubkey.slice(0, 12)}`
return 'unnamed'
}
function shortPubkey(pubkey: string): string {
return pubkey.length > 12 ? `${pubkey.slice(0, 12)}` : pubkey
}
function position(c: MeshcoreContact): string {
if (c.lat != null && c.lon != null) {
return `${c.lat.toFixed(4)}, ${c.lon.toFixed(4)}`
}
return '—'
}
export default function MeshCoreContacts() {
const [data, setData] = useState<MeshcoreContacts | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
document.title = 'MeshCore Contacts - MeshAI'
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setError(null)
try {
const result = await fetchMeshcoreContacts()
if (!cancelled) setData(result)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load contacts')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Users size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show the MeshCore companion's contact roster &mdash; the names, public
keys, last-heard timestamps, and positions of the nodes your companion knows about.
It becomes available once the companion data API is wired up, at which point contacts
can be browsed here and referenced directly when configuring MeshCore DM delivery.
</p>
</div>
<div className="max-w-4xl 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">
<Users size={24} className="text-accent" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<p className="text-sm text-[#777]">
The companion's known contact roster &mdash; names, types, and last-heard times.
</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>
) : data && data.active === false ? (
<div className="bg-bg-card border border-border p-6">
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
The MeshCore companion is not connected. The contact roster is unavailable until the
companion comes online.
</p>
</div>
) : data && data.contacts.length === 0 ? (
<div className="bg-bg-card border border-border p-6">
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
No contacts yet. The companion is connected but has not discovered any nodes so far.
</p>
</div>
) : (
<div className="bg-bg-card border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-[#777]">
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Last heard</th>
<th className="px-4 py-2.5 font-medium">Position</th>
<th className="px-4 py-2.5 font-medium">Pubkey</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{(data?.contacts ?? []).map((c) => (
<tr key={c.pubkey} className="hover:bg-bg-hover">
<td className="px-4 py-2.5 text-slate-100">{contactName(c)}</td>
<td className="px-4 py-2.5">
<TypeBadge type={c.type} />
</td>
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
<td className="px-4 py-2.5 text-slate-300 font-mono text-xs">{position(c)}</td>
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
{shortPubkey(c.pubkey)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-[#777]">
Telemetry auto-poll is coming in the next pass.
</p>
</div>
)
}