369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
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<number>(1);
|
|
const [visibleRows, setVisibleRows] = useState<number>(50);
|
|
const [fetchLimit] = useState<number>(50);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const paginationRef = useRef<HTMLDivElement | null>(null);
|
|
const [pager, setPager] = useState<any | null>(null);
|
|
const [allNodes, setAllNodes] = useState<any[] | null>(null);
|
|
const [allLoading, setAllLoading] = useState<boolean>(false);
|
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
const [search, setSearch] = useState<string>('');
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedNode, setSelectedNode] = useState<any | null>(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(<u key={start + idx}>{txt.slice(idx, idx + q.length)}</u>);
|
|
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<string, any> = {};
|
|
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 (
|
|
<VerticalSplit
|
|
ratio="3:1"
|
|
left={(
|
|
<Card className="data-table-card h-100 d-flex flex-column">
|
|
<Card.Header className="data-table-header d-flex align-items-center justify-content-between">
|
|
<div>
|
|
MeshCore Nodes
|
|
<small className="ms-2">({filteredItems.length})</small>
|
|
</div>
|
|
<div className="d-flex align-items-center gap-2" style={{ minWidth: 260 }}>
|
|
<Form.Control
|
|
placeholder={allNodes ? 'Search by name or hash...' : 'Search will be enabled after all nodes are loaded'}
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
disabled={!allNodes}
|
|
size="sm"
|
|
/>
|
|
{!allNodes && allLoading && (
|
|
<div className="text-secondary d-flex align-items-center" style={{ fontSize: 12 }}>
|
|
<Spinner animation="border" size="sm" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Body className="data-table-body d-flex flex-column p-0">
|
|
{isLoading && (
|
|
<div className="d-flex align-items-center gap-2 p-3 text-secondary">
|
|
<Spinner animation="border" size="sm" /> Loading nodes...
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert variant="danger" className="m-3">{error}</Alert>
|
|
)}
|
|
|
|
{!isLoading && !error && (pager || allNodes) && (
|
|
<div ref={containerRef} className="p-2 d-flex flex-column h-100">
|
|
{/* search moved to header */}
|
|
<div ref={tableWrapperRef} className="table-responsive mb-2" style={{ overflowY: 'auto' }}>
|
|
<Table hover size="sm" className="mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Node ID</th>
|
|
<th>Type</th>
|
|
<th>Public key</th>
|
|
<th>Last seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{currentItems.map((n: any, idx: number) => (
|
|
<tr
|
|
key={n.id}
|
|
data-nav-item="true"
|
|
onClick={() => { setSelectedNode(n); setSelectedIndex(idx); }}
|
|
style={{ cursor: 'pointer' }}
|
|
className={selectedIndex === idx ? 'table-active' : ''}
|
|
aria-selected={selectedIndex === idx}
|
|
>
|
|
<td>{renderHighlighted(n.name || '(unknown)', search)}</td>
|
|
<td><span className="meshcore-hash">{renderHighlighted(String(n.prefix || n.id || ''), search)}</span></td>
|
|
<td>{n.type}</td>
|
|
<td><span className="meshcore-hash">{renderHighlighted(formatPubKey(n.public_key), search)}</span></td>
|
|
<td>{n.last_heard_at ? new Date(n.last_heard_at).toLocaleString() : '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div ref={paginationRef} className="p-2 mt-auto">
|
|
<Pagination className="mb-0">
|
|
<Pagination.First onClick={() => setPage(1)} disabled={page === 1} />
|
|
<Pagination.Prev onClick={() => 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' ? (
|
|
<Pagination.Ellipsis key={`ell-${idx}`} disabled />
|
|
) : (
|
|
<Pagination.Item
|
|
key={p}
|
|
active={p === page}
|
|
onClick={() => setPage(p as number)}
|
|
>
|
|
{p}
|
|
</Pagination.Item>
|
|
)
|
|
);
|
|
})()}
|
|
|
|
<Pagination.Next onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages} />
|
|
<Pagination.Last onClick={() => setPage(totalPages)} disabled={page === totalPages} />
|
|
</Pagination>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
)}
|
|
right={(
|
|
<Card className="data-table-card h-100 d-flex flex-column">
|
|
<Card.Header className="data-table-header">Node Details</Card.Header>
|
|
<Card.Body className="data-table-body d-flex flex-column">
|
|
{!selectedNode && (
|
|
<div className="text-secondary">Select a node to view details.</div>
|
|
)}
|
|
|
|
{selectedNode && (
|
|
<div>
|
|
<h5>{renderHighlighted(selectedNode.name || '(unknown)', search)}</h5>
|
|
<div><strong>ID:</strong> <span className="meshcore-hash">{renderHighlighted(String(selectedNode.id || ''), search)}</span></div>
|
|
<div><strong>Prefix:</strong> <span className="meshcore-hash">{renderHighlighted(String(selectedNode.prefix || ''), search)}</span></div>
|
|
<div><strong>Type:</strong> {selectedNode.type}</div>
|
|
<div><strong>Public key:</strong> <span className="meshcore-hash">{selectedNode.public_key}</span></div>
|
|
<div><strong>First seen:</strong> {selectedNode.first_heard_at ? new Date(selectedNode.first_heard_at).toLocaleString() : '-'}</div>
|
|
<div><strong>Last seen:</strong> {selectedNode.last_heard_at ? new Date(selectedNode.last_heard_at).toLocaleString() : '-'}</div>
|
|
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
|
|
<div><strong>Location:</strong> {selectedNode.last_latitude}, {selectedNode.last_longitude}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default MeshCoreNodesView;
|