mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
bbe97398bc
commit
ea4c010967
14 changed files with 2970 additions and 502 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 — 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 — 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue