feat(meshcore): report the true connection + add roster/channel management (#153)

self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.

meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.

Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.

Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
  find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
  remove_contact, import_contact, export_roster, contacts_synced_at;
  auto_update_contacts enabled (configurable — it costs one incremental
  fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
  contacts/export, POST contacts/import, GET route-health

Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
  toolbar with last-synced and the added/removed counts; staleness badges;
  search, filters and sortable columns; per-contact delete behind a
  confirm; Companion tab shows the real transport + target.

A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.

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-16 22:24:49 -06:00 committed by GitHub
commit ea4c010967
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2970 additions and 502 deletions

View file

@ -699,15 +699,25 @@ export interface MeshcoreContact {
export interface MeshcoreContacts {
active: boolean
contacts: MeshcoreContact[]
last_synced_at?: number | null // epoch seconds the roster was pulled from the companion
}
export interface MeshcoreSelf {
name?: string | null
pubkey?: string | null
connected: boolean
host?: string
port?: number
// Connection reporting: only the fields for the live `conn_type` are set;
// the rest are null. A serial companion has no host/port — showing a config
// leftover there would name a device meshai is not actually talking to.
conn_type?: 'tcp' | 'serial' | 'ble' | string
target?: string // human-readable: "serial:/dev/x@115200" | "host:port" | "ble:addr"
host?: string | null
port?: number | null
serial_port?: string | null
baud?: number | null
ble_address?: string | null
channel_count?: number
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
contacts_synced_at?: number | null
}
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
@ -717,6 +727,109 @@ export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
return fetchJson<MeshcoreSelf>('/api/meshcore/self')
}
// Result of a full roster resync: what the reconcile actually changed.
export interface MeshcoreRefreshStats {
before: number
after: number
added: number
removed: number
updated: number
added_keys: string[]
removed_keys: string[]
}
// Channels are also a connect-time snapshot, so a resync re-reads them too.
export interface MeshcoreChannelStats {
before: number
after: number
added: string[]
removed: string[]
}
export interface MeshcoreRefreshResult {
active: boolean
stats: MeshcoreRefreshStats
channel_stats: MeshcoreChannelStats
contacts: MeshcoreContact[]
channels: string[]
last_synced_at: number | null
}
// Re-read the companion's device view: FULL contact refetch + reconcile (drops
// contacts the companion no longer has) AND channel re-enumeration. Throws with
// the backend's `detail` when MeshCore is not connected or the fetch fails.
export async function refreshMeshcoreContacts(): Promise<MeshcoreRefreshResult> {
const response = await fetch('/api/meshcore/contacts/refresh', { method: 'POST' })
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Write contact records onto the companion (upsert; nothing is removed, and no
// mesh traffic is generated). Used both for a manual single add — e.g. after a
// room server is rebuilt with a new keypair — and for restoring an exported
// roster onto a replacement companion.
export interface MeshcoreImportResult {
active: boolean
imported: number
failed: number
errors: { pubkey: string | null; detail: string }[]
}
export async function importMeshcoreContacts(
contacts: Record<string, unknown>[],
): Promise<MeshcoreImportResult> {
const response = await fetch('/api/meshcore/contacts/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contacts }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Remove a contact from the companion. Requires the FULL 64-hex pubkey.
export async function removeMeshcoreContact(pubkey: string): Promise<MeshcoreContacts> {
const response = await fetch(`/api/meshcore/contacts/${encodeURIComponent(pubkey)}`, {
method: 'DELETE',
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// A routing cell whose MeshCore target does not resolve on the companion.
export interface MeshcoreDanglingRoute {
family: string
region: string
target: string
kind: 'room' | 'channel' | string
reason: 'room_not_found' | 'channel_not_found' | 'not_a_room' | string
enabled: boolean
}
// Roster entries sharing a name but not a keypair — indistinguishable by name.
export interface MeshcoreNameCollision {
name: string
count: number
contacts: { pubkey: string; type: number | null }[]
}
export interface MeshcoreRouteHealth {
active: boolean
dangling: MeshcoreDanglingRoute[]
dangling_enabled: number
collisions: MeshcoreNameCollision[]
checked: number
mc_enabled: boolean
}
export async function fetchMeshcoreRouteHealth(): Promise<MeshcoreRouteHealth> {
return fetchJson<MeshcoreRouteHealth>('/api/meshcore/route-health')
}
export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
const response = await fetch('/api/meshcore/advert', {
method: 'POST',

View file

@ -10,6 +10,32 @@ import {
type TestSendResult,
} from '../lib/api'
// How meshai is attached to the companion. Distinct colours so the transport is
// readable at a glance — an operator must be able to tell WHICH device this is
// before acting on it.
const CONN_TYPE_BADGE: Record<string, string> = {
serial: 'bg-emerald-500/15 text-emerald-400',
tcp: 'bg-sky-500/15 text-sky-400',
ble: 'bg-violet-500/15 text-violet-400',
}
/** The live connection target, from whichever fields belong to this conn_type.
*
* Falls back to the per-transport fields if `target` is absent (older backend).
* Never falls back to host/port for a non-TCP link: that is exactly the
* mistake this display exists to prevent.
*/
function connectionTarget(self: MeshcoreSelf | null): string {
if (!self) return '—'
if (self.target) return self.target
if (self.conn_type === 'serial') {
return self.serial_port ? `${self.serial_port}@${self.baud ?? 115200}` : 'serial'
}
if (self.conn_type === 'ble') return self.ble_address || 'ble'
if (self.host) return `${self.host}${self.port != null ? `:${self.port}` : ''}`
return '—'
}
/** Format epoch seconds as a human-readable relative time string. */
function relativeTime(epochSec: number): string {
const diffSec = Math.floor(Date.now() / 1000 - epochSec)
@ -178,10 +204,18 @@ export default function MeshCoreCompanion() {
<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}` : ''}
<dt className="text-[#777] mb-1">Connection</dt>
<dd className="flex items-center gap-2">
<span
className={`px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded ${
CONN_TYPE_BADGE[self?.conn_type ?? ''] ?? 'bg-slate-600/30 text-slate-400'
}`}
>
{self?.conn_type ?? 'unknown'}
</span>
<span className="text-slate-100 font-mono text-xs break-all">
{connectionTarget(self)}
</span>
</dd>
</div>
<div className="sm:col-span-2">

View file

@ -1,13 +1,20 @@
import { Fragment, useCallback, useEffect, useState } from 'react'
import { Users } from 'lucide-react'
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Copy, Download, Plus, RefreshCw, Trash2, Users } from 'lucide-react'
import {
fetchMeshcoreContacts,
fetchMeshcoreRouteHealth,
fetchMeshcoreTelemetry,
fetchConnectionConfig,
importMeshcoreContacts,
pollMeshcoreContact,
refreshMeshcoreContacts,
removeMeshcoreContact,
updateConfig,
type MeshcoreChannelStats,
type MeshcoreContacts,
type MeshcoreContact,
type MeshcoreRefreshStats,
type MeshcoreRouteHealth,
type MeshcoreTelemetry,
type MeshcoreTelemetryEntry,
type MeshcoreTelemetryData,
@ -18,10 +25,32 @@ import {
const TELEMETRY_POLL_MS = 15000
const MIN_INTERVAL_MINUTES = 5
// Relative time for epoch-seconds fields (last_advert).
// A contact not heard from in this long is flagged stale. Adverts are typically
// hours apart, so days — not hours — is the honest threshold for "gone quiet".
const STALE_AFTER_DAYS = 14
const STALE_AFTER_SECONDS = STALE_AFTER_DAYS * 86400
type SortKey = 'name' | 'type' | 'last_advert'
type TypeFilter = 'all' | 'rooms' | 'stale'
function isStale(c: MeshcoreContact): boolean {
if (c.last_advert == null || c.last_advert <= 0) return false
return Math.floor(Date.now() / 1000) - c.last_advert > STALE_AFTER_SECONDS
}
// Why a routing cell will not resolve, in operator language.
const DANGLING_REASON: Record<string, string> = {
room_not_found: 'no room server with this key is on the companion',
channel_not_found: 'this channel is not on the companion',
not_a_room: 'this key belongs to a contact that is not a room server',
}
// Relative time for epoch-seconds fields (last_advert, last_synced_at).
// Floors the DIFFERENCE, not just the clock: last_synced_at is a float, so
// flooring only Date.now() would leave a fractional "28.6851...s ago".
function relativeTime(epochSeconds: number | null): string {
if (epochSeconds == null) return '—'
const diff = Math.floor(Date.now() / 1000) - epochSeconds
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)
@ -145,6 +174,31 @@ export default function MeshCoreContacts() {
const [connectionConfig, setConnectionConfig] = useState<ConnectionConfig | null>(null)
const [telemetry, setTelemetry] = useState<MeshcoreTelemetry | null>(null)
// Roster management: resync / export / delete / route health.
const [routeHealth, setRouteHealth] = useState<MeshcoreRouteHealth | null>(null)
const [resyncing, setResyncing] = useState(false)
const [resyncStats, setResyncStats] = useState<MeshcoreRefreshStats | null>(null)
const [channelStats, setChannelStats] = useState<MeshcoreChannelStats | null>(null)
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null)
// Manual add — the companion learns most contacts by advert, but a node that
// has been rekeyed (or is not yet heard) has to be entered by key.
const [showAdd, setShowAdd] = useState(false)
const [addName, setAddName] = useState('')
const [addPubkey, setAddPubkey] = useState('')
const [addType, setAddType] = useState(1)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedKey, setExpandedKey] = useState<string | null>(null)
// Table controls.
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
const [sortKey, setSortKey] = useState<SortKey>('name')
const [sortAsc, setSortAsc] = useState(true)
// Per-row transient UI state.
const [savingId, setSavingId] = useState<string | null>(null)
const [savedId, setSavedId] = useState<string | null>(null)
@ -169,7 +223,10 @@ export default function MeshCoreContacts() {
setError(null)
try {
const result = await fetchMeshcoreContacts()
if (!cancelled) setData(result)
if (!cancelled) {
setData(result)
setLastSyncedAt(result.last_synced_at ?? null)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load contacts')
@ -183,6 +240,21 @@ export default function MeshCoreContacts() {
}
}, [])
// Route health (once): which routing cells point at something that no longer
// exists. Non-fatal — the roster is still useful if this check fails.
const loadRouteHealth = useCallback(async () => {
try {
const health = await fetchMeshcoreRouteHealth()
setRouteHealth(health)
} catch {
// non-fatal — banner simply stays hidden
}
}, [])
useEffect(() => {
loadRouteHealth()
}, [loadRouteHealth])
// Connection config (once) — kept whole so PUTs send it back intact.
useEffect(() => {
let cancelled = false
@ -292,6 +364,150 @@ export default function MeshCoreContacts() {
}
}, [])
// Full resync: refetch the whole roster from the node and reconcile, so
// entries the companion no longer has are dropped rather than lingering.
const handleResync = useCallback(async () => {
setResyncing(true)
setResyncStats(null)
setChannelStats(null)
setSaveError(null)
try {
const result = await refreshMeshcoreContacts()
setData({ active: result.active, contacts: result.contacts })
setLastSyncedAt(result.last_synced_at)
setResyncStats(result.stats)
setChannelStats(result.channel_stats)
// Roster and channels just changed — re-check the routing cells against them.
loadRouteHealth()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Resync failed')
} finally {
setResyncing(false)
}
}, [loadRouteHealth])
// Add a contact by key. Minimal record: the companion fills in the rest when
// the node next adverts; out_path_len -1 means "flood until a path is known".
const handleAdd = useCallback(async () => {
const pubkey = addPubkey.trim().toLowerCase()
const name = addName.trim()
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
setAddError('Pubkey must be exactly 64 hex characters (the full key, not a prefix)')
return
}
if (!name) {
setAddError('A name is required')
return
}
setAdding(true)
setAddError(null)
try {
const result = await importMeshcoreContacts([
{ pubkey, name, type: addType, flags: 0, out_path_len: -1, out_path: '', last_advert: 0 },
])
if (result.failed > 0) {
setAddError(result.errors[0]?.detail || 'Add failed')
return
}
setShowAdd(false)
setAddName('')
setAddPubkey('')
// Re-read so the new contact appears with whatever the companion stored.
const refreshed = await fetchMeshcoreContacts()
setData(refreshed)
setLastSyncedAt(refreshed.last_synced_at ?? null)
loadRouteHealth()
} catch (err) {
setAddError(err instanceof Error ? err.message : 'Add failed')
} finally {
setAdding(false)
}
}, [addPubkey, addName, addType, loadRouteHealth])
// Export streams from the API (not from component state) so the file is the
// full importable record set, not the display projection shown in the table.
const handleExport = useCallback(() => {
window.location.href = '/api/meshcore/contacts/export'
}, [])
const handleDelete = useCallback(async (c: MeshcoreContact) => {
setDeletingId(c.pubkey)
setSaveError(null)
try {
const result = await removeMeshcoreContact(c.pubkey)
setData((prev) => ({ active: true, contacts: result.contacts, last_synced_at: prev?.last_synced_at ?? null }))
setConfirmDelete(null)
loadRouteHealth()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeletingId((d) => (d === c.pubkey ? null : d))
}
}, [loadRouteHealth])
const handleCopyPubkey = useCallback(async (pubkey: string) => {
try {
await navigator.clipboard.writeText(pubkey)
} catch {
// clipboard unavailable (non-secure context) — no-op
}
}, [])
const toggleSort = useCallback((key: SortKey) => {
setSortKey((prev) => {
if (prev === key) {
setSortAsc((asc) => !asc)
return prev
}
setSortAsc(true)
return key
})
}, [])
// Names collide across keypairs, so a name alone cannot identify a contact.
// Flag the ambiguous ones inline rather than leaving two identical rows.
const collidingNames = useMemo(() => {
const names = new Set<string>()
for (const collision of routeHealth?.collisions ?? []) names.add(collision.name)
return names
}, [routeHealth])
const visibleContacts = useMemo(() => {
let list = data?.contacts ?? []
const needle = search.trim().toLowerCase()
if (needle) {
list = list.filter(
(c) =>
(c.name ?? '').toLowerCase().includes(needle) ||
c.pubkey.toLowerCase().includes(needle)
)
}
if (typeFilter === 'rooms') list = list.filter((c) => c.type === 3)
else if (typeFilter === 'stale') list = list.filter(isStale)
const sorted = [...list].sort((a, b) => {
let cmp = 0
if (sortKey === 'name') {
cmp = (a.name ?? '').localeCompare(b.name ?? '')
} else if (sortKey === 'type') {
cmp = (a.type ?? 0) - (b.type ?? 0)
} else {
cmp = (a.last_advert ?? 0) - (b.last_advert ?? 0)
}
return sortAsc ? cmp : -cmp
})
return sorted
}, [data, search, typeFilter, sortKey, sortAsc])
const staleCount = useMemo(
() => (data?.contacts ?? []).filter(isStale).length,
[data]
)
const roomCount = useMemo(
() => (data?.contacts ?? []).filter((c) => c.type === 3).length,
[data]
)
const handleSaveInterval = useCallback(async () => {
if (!connectionConfig) return
const minutes = Math.max(MIN_INTERVAL_MINUTES, Math.round(intervalMinutes) || MIN_INTERVAL_MINUTES)
@ -316,9 +532,11 @@ export default function MeshCoreContacts() {
}, [connectionConfig, intervalMinutes])
const rosterActive = data?.active !== false
const dangling = routeHealth?.dangling ?? []
const collisions = routeHealth?.collisions ?? []
return (
<div className="max-w-4xl mx-auto space-y-4">
<div className="max-w-5xl 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">
@ -333,6 +551,192 @@ export default function MeshCoreContacts() {
</div>
</div>
{/* Dangling-route warning a cell pointing into the void fails SILENTLY
at send time, so this is the only place it becomes visible. */}
{dangling.length > 0 && (
<div className="border border-red-500/40 bg-red-500/10 p-4 space-y-2">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2 min-w-0">
<h3 className="text-sm font-semibold text-red-300">
{dangling.length} routing {dangling.length === 1 ? 'cell points' : 'cells point'} at a
destination that no longer exists
</h3>
<p className="text-xs text-red-300/70 max-w-prose">
These cells cannot be delivered a send to a missing room or channel fails
silently. Fix the target on the Routing page, or resync if the roster is stale.
</p>
<ul className="space-y-1">
{dangling.map((d) => (
<li
key={`${d.family}-${d.region}-${d.target}`}
className="text-xs text-slate-200 flex flex-wrap items-center gap-x-2 gap-y-1"
>
<span className="px-1.5 py-0.5 rounded bg-red-500/20 text-red-300 uppercase tracking-wide text-[10px]">
{d.family}
</span>
<span className="text-slate-300">{d.region}</span>
<span className="text-[#777]"></span>
<span className="font-mono text-[11px] text-red-300 break-all">{d.target}</span>
<span className="text-[#777]">
{DANGLING_REASON[d.reason] ?? d.reason}
</span>
{!d.enabled && (
<span className="px-1.5 py-0.5 rounded bg-slate-600/30 text-slate-400 text-[10px] uppercase tracking-wide">
disabled
</span>
)}
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Name collisions two keypairs advertising the same name are
indistinguishable in any name-based picker. */}
{collisions.length > 0 && (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2 min-w-0">
<h3 className="text-sm font-semibold text-amber-300">
{collisions.length} duplicated {collisions.length === 1 ? 'name' : 'names'} on the roster
</h3>
<p className="text-xs text-amber-300/70 max-w-prose">
These names each map to more than one public key. A name alone cannot identify
them always confirm the key before routing to or deleting one.
</p>
<ul className="space-y-1">
{collisions.map((c) => (
<li key={c.name} className="text-xs text-slate-200">
<span className="text-slate-100">{c.name}</span>{' '}
<span className="text-[#777]">×{c.count}</span>
<span className="ml-2 font-mono text-[11px] text-amber-300/80">
{c.contacts.map((x) => shortPubkey(x.pubkey)).join(' · ')}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Roster toolbar: sync state + resync/export */}
{rosterActive && (
<div className="bg-bg-card border border-border p-4 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleResync}
disabled={resyncing}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
title="Refetch the full roster from the companion and drop entries it no longer has"
>
<RefreshCw size={14} className={resyncing ? 'animate-spin' : undefined} />
{resyncing ? 'Resyncing…' : 'Resync from node'}
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 hover:border-accent/40"
title="Download the roster as JSON"
>
<Download size={14} />
Export JSON
</button>
<button
onClick={() => { setShowAdd((s) => !s); setAddError(null) }}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 hover:border-accent/40"
title="Add a contact by public key"
>
<Plus size={14} />
Add contact
</button>
<span className="text-xs text-[#777]">
Last synced {lastSyncedAt != null ? relativeTime(lastSyncedAt) : 'unknown'}
</span>
{resyncStats && (
<span className="text-xs text-slate-300">
<span className="text-emerald-400">+{resyncStats.added} added</span>
{' · '}
<span className="text-red-400">{resyncStats.removed} removed</span>
{' · '}
<span className="text-[#777]">{resyncStats.updated} updated</span>
{' · '}
<span className="text-[#777]">{resyncStats.after} total</span>
{channelStats && (
<span className="text-[#777]">
{' · '}channels {channelStats.after}
{channelStats.added.length > 0 && (
<span className="text-emerald-400"> +{channelStats.added.length}</span>
)}
{channelStats.removed.length > 0 && (
<span className="text-red-400"> {channelStats.removed.length}</span>
)}
</span>
)}
</span>
)}
</div>
{/* Add a contact by key the companion learns most nodes by advert,
but a rekeyed or out-of-range node has to be entered manually. */}
{showAdd && (
<div className="border border-[#1e2a3a] bg-[#0a0e17] p-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
value={addName}
onChange={(e) => setAddName(e.target.value)}
placeholder="Name"
className="w-40 px-2 py-1 text-sm bg-bg-card border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<input
value={addPubkey}
onChange={(e) => setAddPubkey(e.target.value)}
placeholder="Full 64-character hex public key"
className="flex-1 min-w-[280px] px-2 py-1 text-sm font-mono bg-bg-card border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<select
value={addType}
onChange={(e) => setAddType(Number(e.target.value))}
className="px-2 py-1 text-sm bg-bg-card border border-[#1e2a3a] rounded text-slate-200"
>
<option value={1}>Chat</option>
<option value={2}>Repeater</option>
<option value={3}>Room</option>
<option value={4}>Sensor</option>
</select>
<button
onClick={handleAdd}
disabled={adding}
className="px-3 py-1 text-sm rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
>
{adding ? 'Adding…' : 'Add'}
</button>
<button
onClick={() => setShowAdd(false)}
className="px-2 py-1 text-sm text-[#777] hover:text-slate-200"
>
Cancel
</button>
</div>
{addError && <p className="text-xs text-red-400">{addError}</p>}
<p className="text-xs text-[#777] max-w-prose">
Writes the contact straight to the companion &mdash; nothing is transmitted. Use this
when a node has been rebuilt with a new keypair, or is not yet in range to advert.
</p>
</div>
)}
<p className="text-xs text-[#777] max-w-prose">
The roster and channel list are a snapshot cached from the companion at connect. Resync
re-reads both from the node and reconciles them &mdash; the only action that removes
entries the companion has dropped, or picks up a channel added on the radio.
</p>
</div>
)}
{/* Auto-poll interval control */}
{rosterActive && connectionConfig && (
<div className="bg-bg-card border border-border p-4 space-y-2">
@ -389,13 +793,56 @@ export default function MeshCoreContacts() {
</p>
</div>
) : (
<div className="bg-bg-card border border-border overflow-x-auto">
<div className="bg-bg-card border border-border">
{/* Filter / search toolbar */}
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-border">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name or pubkey…"
className="flex-1 min-w-[180px] px-2 py-1 text-sm bg-[#0a0e17] border border-[#1e2a3a] rounded text-slate-100 placeholder:text-[#555]"
/>
<div className="flex gap-1">
{([
{ key: 'all', label: `All ${data?.contacts.length ?? 0}` },
{ key: 'rooms', label: `Rooms ${roomCount}` },
{ key: 'stale', label: `Stale ${staleCount}` },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setTypeFilter(key)}
className={`px-2.5 py-1 text-xs rounded border transition-colors ${
typeFilter === key
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-[#1e2a3a] bg-[#0a0e17] text-[#777] hover:text-slate-200'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="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">
<button onClick={() => toggleSort('name')} className="hover:text-slate-200 uppercase">
Name{sortKey === 'name' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">
<button onClick={() => toggleSort('type')} className="hover:text-slate-200 uppercase">
Type{sortKey === 'type' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">
<button onClick={() => toggleSort('last_advert')} className="hover:text-slate-200 uppercase">
Last heard{sortKey === 'last_advert' ? (sortAsc ? ' ▲' : ' ▼') : ''}
</button>
</th>
<th className="px-4 py-2.5 font-medium">Position</th>
<th className="px-4 py-2.5 font-medium">Pubkey</th>
<th className="px-4 py-2.5 font-medium">Auto-poll</th>
@ -403,7 +850,7 @@ export default function MeshCoreContacts() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{(data?.contacts ?? []).map((c) => {
{visibleContacts.map((c) => {
const id = contactId(c)
const entry = entryFor(c)
const selected = isSelected(c)
@ -437,14 +884,53 @@ export default function MeshCoreContacts() {
return (
<Fragment key={c.pubkey}>
<tr 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 text-slate-100">
<div className="flex items-center gap-2">
<span>{contactName(c)}</span>
{c.name != null && collidingNames.has(c.name) && (
<span
className="px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded bg-amber-500/15 text-amber-400"
title="Another contact advertises this same name with a different key — check the pubkey"
>
dup name
</span>
)}
</div>
</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">
<div className="flex items-center gap-2">
<span className="text-slate-300">{relativeTime(c.last_advert)}</span>
{isStale(c) && (
<span
className="px-1.5 py-0.5 text-[10px] uppercase tracking-wide rounded bg-orange-500/15 text-orange-400"
title={`Not heard from in over ${STALE_AFTER_DAYS} days`}
>
stale
</span>
)}
</div>
</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)}
<div className="flex items-center gap-1.5">
<button
onClick={() => setExpandedKey((k) => (k === c.pubkey ? null : c.pubkey))}
className="hover:text-accent"
title={c.pubkey}
>
{expandedKey === c.pubkey ? c.pubkey : shortPubkey(c.pubkey)}
</button>
<button
onClick={() => handleCopyPubkey(c.pubkey)}
className="text-[#555] hover:text-accent flex-shrink-0"
title="Copy full pubkey"
>
<Copy size={11} />
</button>
</div>
</td>
<td className="px-4 py-2.5">
<label
@ -472,15 +958,55 @@ export default function MeshCoreContacts() {
</label>
</td>
<td className="px-4 py-2.5">
<button
onClick={() => handlePollNow(c)}
disabled={pollingId === id}
className="px-2 py-1 text-xs rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
>
{pollingId === id ? 'Polling…' : 'Poll now'}
</button>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => handlePollNow(c)}
disabled={pollingId === id}
className="px-2 py-1 text-xs rounded bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50"
>
{pollingId === id ? 'Polling…' : 'Poll now'}
</button>
{/* Two-step delete: removal from the companion is
permanent the node must be rediscovered. */}
{confirmDelete === c.pubkey ? (
<>
<button
onClick={() => handleDelete(c)}
disabled={deletingId === c.pubkey}
className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-300 hover:bg-red-500/30 disabled:opacity-50"
>
{deletingId === c.pubkey ? 'Deleting…' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="px-2 py-1 text-xs rounded text-[#777] hover:text-slate-200"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDelete(c.pubkey)}
className="p-1 rounded text-[#555] hover:text-red-400 hover:bg-red-500/10"
title="Remove this contact from the companion"
>
<Trash2 size={13} />
</button>
)}
</div>
</td>
</tr>
{confirmDelete === c.pubkey && (
<tr className="bg-red-500/5">
<td colSpan={7} className="px-4 py-2 border-t border-red-500/20">
<span className="text-xs text-red-300">
Remove <span className="text-slate-100">{contactName(c)}</span>{' '}
<span className="font-mono text-[11px]">{shortPubkey(c.pubkey)}</span>{' '}
from the companion? It will only return if the node advertises again.
</span>
</td>
</tr>
)}
{hasReadout && (
<tr className="bg-[#0a0e17]/40">
<td colSpan={7} className="px-4 py-2 border-t border-border/50">
@ -500,6 +1026,12 @@ export default function MeshCoreContacts() {
})}
</tbody>
</table>
{visibleContacts.length === 0 && (
<div className="px-4 py-6 text-sm text-[#777]">
No contacts match this filter.
</div>
)}
</div>
</div>
)}
</div>