Checkpoint

This commit is contained in:
2026-03-08 22:22:51 +01:00
parent 247c827291
commit 9053ec65a6
65 changed files with 5874 additions and 708 deletions

View 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;