import React, { useEffect, useMemo, useState, useRef } from 'react'; import { Card, Table, Spinner, Alert, Pagination, Form } from 'react-bootstrap'; import API from '../../services/API'; import MeshCoreService from '../../services/MeshCoreService'; import VerticalSplit from '../../components/VerticalSplit'; import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation'; const meshCoreService = new MeshCoreService(API); export const MeshCoreNodesView: React.FC = () => { const [page, setPage] = useState(1); const [visibleRows, setVisibleRows] = useState(50); const [fetchLimit] = useState(50); const containerRef = useRef(null); const tableWrapperRef = useRef(null); const paginationRef = useRef(null); const [pager, setPager] = useState(null); const [allNodes, setAllNodes] = useState(null); const [allLoading, setAllLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(null); const [search, setSearch] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const renderHighlighted = (text: string, query: string | null) => { if (!query) return <>{text}; const q = query.trim().toLowerCase(); if (!q) return <>{text}; const txt = text || ''; const lower = txt.toLowerCase(); if (!lower.includes(q)) return <>{text}; const parts: React.ReactNode[] = []; let start = 0; let idx = 0; while ((idx = lower.indexOf(q, start)) !== -1) { if (idx > start) parts.push(txt.slice(start, idx)); parts.push({txt.slice(idx, idx + q.length)}); start = idx + q.length; } if (start < txt.length) parts.push(txt.slice(start)); return <>{parts}; }; const formatPubKey = (k: string | undefined) => { if (!k) return ''; const s = String(k); if (s.length <= 16) return s; return `${s.slice(0, 8)}…${s.slice(-8)}`; }; useEffect(() => { let isMounted = true; const load = async () => { setIsLoading(true); setError(null); try { // Map the visual `page` (which is based on `visibleRows`) to the server page const serverPage = Math.max(1, Math.ceil((page * visibleRows) / fetchLimit)); const p = await meshCoreService.fetchNodes(serverPage, fetchLimit); console.debug('MeshCoreNodesView: initial pager', { page, visibleRows, fetchLimit, serverPage, pager: p }); if (!isMounted) return; // Sort items alphabetically by name p.items.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); setPager(p); if (!selectedNode && p.items.length > 0) { setSelectedNode(p.items[0]); setSelectedIndex(0); } } catch (err) { if (!isMounted) return; setError(err instanceof Error ? err.message : 'Failed to load nodes'); } finally { if (isMounted) setIsLoading(false); } }; void load(); return () => { isMounted = false; }; }, [page, fetchLimit, visibleRows]); useEffect(() => { if (!pager || allNodes !== null || allLoading) return; let isMounted = true; const loadAllSerial = async () => { setAllLoading(true); try { const total = pager.total || 0; if (total === 0) { if (isMounted) setAllNodes([]); return; } // Use server-provided limit when available to compute pages const pageSize = Math.max(1, pager.limit || fetchLimit || 50); const pages = Math.max(1, Math.ceil(total / pageSize)); console.debug('MeshCoreNodesView: background serial load start', { total, pageSize, pages }); // Start with first page items returned by initial fetch const items: any[] = Array.isArray(pager.items) ? pager.items.slice() : []; for (let p = 2; p <= pages; p++) { console.debug('MeshCoreNodesView: fetching page', { p, pageSize }); try { // serial await ensures requests are not concurrent // eslint-disable-next-line no-await-in-loop const res = await meshCoreService.fetchNodes(p, pageSize); if (!isMounted) return; console.debug('MeshCoreNodesView: fetched page result', { p, items: res.items ? res.items.length : 0 }); if (Array.isArray(res.items)) items.push(...res.items); } catch (err) { console.debug('MeshCoreNodesView: fetch page error', { p, error: err }); // continue to next page on error } } if (!isMounted) return; // dedupe by id and sort alphabetically by name const byId: Record = {}; for (const it of items) { if (!it) continue; byId[String(it.id)] = it; } const unique = Object.values(byId); unique.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); console.debug('MeshCoreNodesView: finished serial background load, total unique items', unique.length); if (isMounted) setAllNodes(unique); } catch (err) { console.debug('MeshCoreNodesView: background loadAll fatal error', err); } finally { if (isMounted) setAllLoading(false); } }; void loadAllSerial(); return () => { isMounted = false; }; }, [pager, fetchLimit]); useEffect(() => { // Table sizing by visual viewport removed — allow vertical overflow again. }, [pager]); const totalPages = useMemo(() => { if (allNodes) return Math.max(1, Math.ceil(allNodes.length / visibleRows)); if (!pager) return 0; return Math.max(1, Math.ceil(pager.total / pager.limit)); }, [pager, allNodes, visibleRows]); const filteredItems = useMemo(() => { const nodes = allNodes ?? (pager ? pager.items : []); if (!search) return nodes; const q = search.trim().toLowerCase(); return (nodes || []).filter((n: any) => { const name = (n.name || '').toLowerCase(); const idOrPrefix = (n.prefix || n.id || '').toString().toLowerCase(); const pub = (n.public_key || '').toString().toLowerCase(); const matchName = name.includes(q); const matchId = idOrPrefix.includes(q); const matchPub = pub.startsWith(q); // only match prefix of public key return matchName || matchId || matchPub; }); }, [allNodes, pager, search]); const currentItems = useMemo(() => { if (allNodes) { return filteredItems.slice((page - 1) * visibleRows, page * visibleRows); } // pager-mode: compute slice relative to the last fetched server page if (!pager) return []; const items = Array.isArray(pager.items) ? pager.items : []; const serverPage = pager.page || 1; const serverLimit = pager.limit || fetchLimit; // global index range for this visual page const globalStart = (page - 1) * visibleRows; const serverStart = (serverPage - 1) * serverLimit; const offsetWithinServer = globalStart - serverStart; if (offsetWithinServer >= items.length || offsetWithinServer + visibleRows <= 0) { return []; } const start = Math.max(0, offsetWithinServer); return items.slice(start, start + visibleRows); }, [allNodes, filteredItems, pager, page, visibleRows, fetchLimit]); // keep selectedIndex in sync with selectedNode when page/items change useEffect(() => { if (!selectedNode) { setSelectedIndex(null); return; } const idx = currentItems.findIndex((it: any) => String(it.id) === String(selectedNode.id)); setSelectedIndex(idx >= 0 ? idx : null); }, [selectedNode, currentItems]); // wire page navigation into the keyboard hook (ArrowLeft/ArrowRight) const onPrevPage = React.useCallback(() => setPage((p) => Math.max(1, p - 1)), [setPage]); const onNextPage = React.useCallback(() => setPage((p) => Math.min(totalPages, p + 1)), [setPage, totalPages]); const { /* showShortcuts, setShowShortcuts, */ } = useKeyboardListNavigation({ itemCount: currentItems.length, selectedIndex, onSelectIndex: (i: number | null) => { setSelectedIndex(i); if (i === null) { setSelectedNode(null); } else { const it = currentItems[i]; if (it) setSelectedNode(it); } }, scrollContainerRef: tableWrapperRef, rowSelector: '[data-nav-item="true"]', enabled: true, onPrevPage, onNextPage, }); return (
MeshCore Nodes ({filteredItems.length})
{ setSearch(e.target.value); setPage(1); }} disabled={!allNodes} size="sm" /> {!allNodes && allLoading && (
)}
{isLoading && (
Loading nodes...
)} {error && ( {error} )} {!isLoading && !error && (pager || allNodes) && (
{/* search moved to header */}
{currentItems.map((n: any, idx: number) => ( { setSelectedNode(n); setSelectedIndex(idx); }} style={{ cursor: 'pointer' }} className={selectedIndex === idx ? 'table-active' : ''} aria-selected={selectedIndex === idx} > ))}
Name Node ID Type Public key Last seen
{renderHighlighted(n.name || '(unknown)', search)} {renderHighlighted(String(n.prefix || n.id || ''), search)} {n.type} {renderHighlighted(formatPubKey(n.public_key), search)} {n.last_heard_at ? new Date(n.last_heard_at).toLocaleString() : '-'}
setPage(1)} disabled={page === 1} /> setPage(Math.max(1, page - 1))} disabled={page === 1} /> {(() => { const items: (number | string)[] = []; if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) items.push(i); } else { items.push(1); const left = Math.max(2, page - 2); const right = Math.min(totalPages - 1, page + 2); if (left > 2) items.push('...'); for (let i = left; i <= right; i++) items.push(i); if (right < totalPages - 1) items.push('...'); items.push(totalPages); } return items.map((p, idx) => typeof p === 'string' ? ( ) : ( setPage(p as number)} > {p} ) ); })()} setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages} /> setPage(totalPages)} disabled={page === totalPages} />
)}
)} right={( Node Details {!selectedNode && (
Select a node to view details.
)} {selectedNode && (
{renderHighlighted(selectedNode.name || '(unknown)', search)}
ID: {renderHighlighted(String(selectedNode.id || ''), search)}
Prefix: {renderHighlighted(String(selectedNode.prefix || ''), search)}
Type: {selectedNode.type}
Public key: {selectedNode.public_key}
First seen: {selectedNode.first_heard_at ? new Date(selectedNode.first_heard_at).toLocaleString() : '-'}
Last seen: {selectedNode.last_heard_at ? new Date(selectedNode.last_heard_at).toLocaleString() : '-'}
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
Location: {selectedNode.last_latitude}, {selectedNode.last_longitude}
)}
)}
)} /> ); }; export default MeshCoreNodesView;