Checkpoint
This commit is contained in:
368
ui/src/pages/meshcore/MeshCoreNodesView.tsx
Normal file
368
ui/src/pages/meshcore/MeshCoreNodesView.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user