/meshcore/nodes: update table
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 35s
Test and build / Build receiver (push) Failing after 35s

This commit is contained in:
2026-03-08 22:56:19 +01:00
parent 9053ec65a6
commit d2e710d179
7 changed files with 339 additions and 27 deletions

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import TimeAgo from './TimeAgo';
describe('TimeAgo', () => {
const now = Date.UTC(2026, 2, 8, 12, 0, 0); // 2026-03-08T12:00:00Z
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('renders seconds ago', () => {
const t = new Date(now - 10 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('10s ago')).toBeTruthy();
});
it('renders minutes ago', () => {
const t = new Date(now - 5 * 60 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('5m ago')).toBeTruthy();
});
it('renders hours ago', () => {
const t = new Date(now - 2 * 60 * 60 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('2h ago')).toBeTruthy();
});
it('renders days ago', () => {
const t = new Date(now - 4 * 24 * 60 * 60 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('4d ago')).toBeTruthy();
});
it('renders weeks ago', () => {
const t = new Date(now - 14 * 24 * 60 * 60 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('2w ago')).toBeTruthy();
});
it('renders months ago', () => {
const t = new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString();
render(<TimeAgo time={t} />);
expect(screen.getByText('1mo ago')).toBeTruthy();
});
it('renders dash for null/undefined', () => {
render(<TimeAgo time={null} />);
expect(screen.getByText('-')).toBeTruthy();
});
it('renders dash for invalid time', () => {
render(<TimeAgo time={("not-a-time" as unknown) as Date} />);
expect(screen.getByText('-')).toBeTruthy();
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { useSyncExternalStore } from 'react';
import { subscribe, getSnapshot } from './TimeTicker';
interface TimeAgoProps {
time?: string | null | Date;
format?: 'short' | 'long';
showAgo?: boolean;
}
// Returns a compact label for a time difference (UTC millisecond inputs).
function formatRelative(nowMs: number, timeMs: number, fmt: 'short' | 'long' = 'short', showAgo = true): string {
const diffSec = Math.max(0, Math.floor((nowMs - timeMs) / 1000));
const units = [
{ threshold: 60, divisor: 1, short: 's', long: 'second' },
{ threshold: 60 * 60, divisor: 60, short: 'm', long: 'minute' },
{ threshold: 60 * 60 * 24, divisor: 60 * 60, short: 'h', long: 'hour' },
{ threshold: 60 * 60 * 24 * 7, divisor: 60 * 60 * 24, short: 'd', long: 'day' },
{ threshold: 60 * 60 * 24 * 30, divisor: 60 * 60 * 24 * 7, short: 'w', long: 'week' },
// months: fallback bucket
{ threshold: Infinity, divisor: 60 * 60 * 24 * 30, short: 'mo', long: 'month' },
];
const plural = (n: number, singular: string) => (n === 1 ? `${n} ${singular}` : `${n} ${singular}s`);
// find first matching unit
for (const u of units) {
if (diffSec < u.threshold) {
if (fmt === 'short') {
if (u.short === 'mo') {
// for months, compute from days to ensure at least 1 month
const months = Math.max(1, Math.floor(diffSec / u.divisor));
return `${months}${u.short}${showAgo ? ' ago' : ''}`;
}
const value = Math.floor(diffSec / u.divisor);
return `${value}${u.short}${showAgo ? ' ago' : ''}`;
}
// long format
if (diffSec < 1) return 'just now';
if (u.long === 'month') {
const months = Math.max(1, Math.floor(diffSec / u.divisor));
return `${plural(months, u.long)}${showAgo ? ' ago' : ''}`;
}
const value = Math.floor(diffSec / u.divisor);
return `${plural(value, u.long)}${showAgo ? ' ago' : ''}`;
}
}
return 'just now';
}
// TimeAgo subscribes to a single shared ticker (via useSyncExternalStore) so
// many instances update efficiently without per-component timers.
const TimeAgo: React.FC<TimeAgoProps> = ({ time, format = 'short', showAgo = true }) => {
if (!time) return <>-</>;
const now = useSyncExternalStore(subscribe, getSnapshot);
const timeMs = typeof time === 'string'
? Date.parse(time)
: (time instanceof Date ? time.getTime() : NaN);
if (!timeMs || Number.isNaN(timeMs)) return <>-</>;
const label = formatRelative(now, timeMs, format, showAgo);
return <span title={new Date(timeMs).toLocaleString()}>{label}</span>;
};
export default TimeAgo;

View File

@@ -0,0 +1,24 @@
let listeners = new Set<() => void>();
let timerId: number | null = null;
export const getSnapshot = (): number => Date.now();
export const subscribe = (cb: () => void) => {
listeners.add(cb);
// start shared timer when first subscriber appears
if (timerId === null) {
timerId = window.setInterval(() => {
for (const l of Array.from(listeners)) l();
}, 1000);
}
return () => {
listeners.delete(cb);
if (listeners.size === 0 && timerId !== null) {
window.clearInterval(timerId);
timerId = null;
}
};
};
export default { subscribe, getSnapshot };

View File

@@ -1,14 +1,24 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'; import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Card, Table, Spinner, Alert, Pagination, Form } from 'react-bootstrap'; import { useSearchParams } from 'react-router';
import { Card, Table, Spinner, Alert, Pagination, Form, Dropdown } from 'react-bootstrap';
import SearchIcon from '@mui/icons-material/Search';
import API from '../../services/API'; import API from '../../services/API';
import MeshCoreService from '../../services/MeshCoreService'; import MeshCoreService from '../../services/MeshCoreService';
import VerticalSplit from '../../components/VerticalSplit'; import VerticalSplit from '../../components/VerticalSplit';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation'; import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import TimeAgo from '../../components/TimeAgo';
const meshCoreService = new MeshCoreService(API); const meshCoreService = new MeshCoreService(API);
export const MeshCoreNodesView: React.FC = () => { export const MeshCoreNodesView: React.FC = () => {
const NODE_TYPES: { value: string; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'repeater', label: 'Repeater' },
{ value: 'chat', label: 'Chat' },
{ value: 'room', label: 'Room' },
{ value: 'sensor', label: 'Sensor' },
];
const [page, setPage] = useState<number>(1); const [page, setPage] = useState<number>(1);
const [visibleRows, setVisibleRows] = useState<number>(50); const [visibleRows, setVisibleRows] = useState<number>(50);
const [fetchLimit] = useState<number>(50); const [fetchLimit] = useState<number>(50);
@@ -19,6 +29,7 @@ export const MeshCoreNodesView: React.FC = () => {
const [allNodes, setAllNodes] = useState<any[] | null>(null); const [allNodes, setAllNodes] = useState<any[] | null>(null);
const [allLoading, setAllLoading] = useState<boolean>(false); const [allLoading, setAllLoading] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [selectedType, setSelectedType] = useState<string | null>(null);
const [search, setSearch] = useState<string>(''); const [search, setSearch] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -58,10 +69,10 @@ export const MeshCoreNodesView: React.FC = () => {
try { try {
// Map the visual `page` (which is based on `visibleRows`) to the server page // Map the visual `page` (which is based on `visibleRows`) to the server page
const serverPage = Math.max(1, Math.ceil((page * visibleRows) / fetchLimit)); const serverPage = Math.max(1, Math.ceil((page * visibleRows) / fetchLimit));
const p = await meshCoreService.fetchNodes(serverPage, fetchLimit); const p = await meshCoreService.fetchNodes(serverPage, fetchLimit, selectedType ?? undefined);
console.debug('MeshCoreNodesView: initial pager', { page, visibleRows, fetchLimit, serverPage, pager: p });
if (!isMounted) return; if (!isMounted) return;
// Sort items alphabetically by name // Ensure items is an array (server may return null) and sort alphabetically by name
p.items = Array.isArray(p.items) ? p.items : [];
p.items.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); p.items.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
setPager(p); setPager(p);
if (!selectedNode && p.items.length > 0) { if (!selectedNode && p.items.length > 0) {
@@ -78,7 +89,14 @@ export const MeshCoreNodesView: React.FC = () => {
void load(); void load();
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [page, fetchLimit, visibleRows]); }, [page, fetchLimit, visibleRows, selectedType]);
// sync search params for ?type=
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const t = searchParams.get('type');
setSelectedType(t);
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!pager || allNodes !== null || allLoading) return; if (!pager || allNodes !== null || allLoading) return;
@@ -95,22 +113,22 @@ export const MeshCoreNodesView: React.FC = () => {
// Use server-provided limit when available to compute pages // Use server-provided limit when available to compute pages
const pageSize = Math.max(1, pager.limit || fetchLimit || 50); const pageSize = Math.max(1, pager.limit || fetchLimit || 50);
const pages = Math.max(1, Math.ceil(total / pageSize)); 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 // Start with first page items returned by initial fetch
const items: any[] = Array.isArray(pager.items) ? pager.items.slice() : []; const items: any[] = Array.isArray(pager.items) ? pager.items.slice() : [];
for (let p = 2; p <= pages; p++) { for (let p = 2; p <= pages; p++) {
console.debug('MeshCoreNodesView: fetching page', { p, pageSize });
try { try {
// serial await ensures requests are not concurrent // serial await ensures requests are not concurrent
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const res = await meshCoreService.fetchNodes(p, pageSize); const res = await meshCoreService.fetchNodes(p, pageSize, selectedType ?? undefined);
if (!isMounted) return; 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); if (Array.isArray(res.items)) items.push(...res.items);
} catch (err) { } catch (err) {
console.debug('MeshCoreNodesView: fetch page error', { p, error: err });
// continue to next page on error // continue to next page on error
} }
} }
@@ -124,10 +142,10 @@ export const MeshCoreNodesView: React.FC = () => {
} }
const unique = Object.values(byId); const unique = Object.values(byId);
unique.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); 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); if (isMounted) setAllNodes(unique);
} catch (err) { } catch (err) {
console.debug('MeshCoreNodesView: background loadAll fatal error', err);
} finally { } finally {
if (isMounted) setAllLoading(false); if (isMounted) setAllLoading(false);
} }
@@ -135,7 +153,7 @@ export const MeshCoreNodesView: React.FC = () => {
void loadAllSerial(); void loadAllSerial();
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [pager, fetchLimit]); }, [pager, fetchLimit, selectedType]);
useEffect(() => { useEffect(() => {
// Table sizing by visual viewport removed — allow vertical overflow again. // Table sizing by visual viewport removed — allow vertical overflow again.
@@ -245,6 +263,46 @@ export const MeshCoreNodesView: React.FC = () => {
<Spinner animation="border" size="sm" /> <Spinner animation="border" size="sm" />
</div> </div>
)} )}
{/* Type filter dropdown moved to header so it's visible even without table items */}
<Dropdown className="ms-2">
<Dropdown.Toggle as="button" variant="link" bsPrefix="p-0 border-0 bg-transparent" id="type-filter-toggle-header" title="Filter by type">
<SearchIcon fontSize="small" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
active={!selectedType}
onClick={() => {
setSelectedType(null);
const newParams = new URLSearchParams(searchParams);
newParams.delete('type');
setSearchParams(newParams, { replace: true });
setPage(1);
setAllNodes(null);
setPager(null);
}}
>
All
</Dropdown.Item>
{NODE_TYPES.filter((o) => o.value).map((opt) => (
<Dropdown.Item
key={opt.value}
active={selectedType === opt.value}
onClick={() => {
setSelectedType(opt.value);
const newParams = new URLSearchParams(searchParams);
newParams.set('type', opt.value);
setSearchParams(newParams, { replace: true });
setPage(1);
setAllNodes(null);
setPager(null);
}}
>
{opt.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div> </div>
</Card.Header> </Card.Header>
<Card.Body className="data-table-body d-flex flex-column p-0"> <Card.Body className="data-table-body d-flex flex-column p-0">
@@ -262,12 +320,23 @@ export const MeshCoreNodesView: React.FC = () => {
<div ref={containerRef} className="p-2 d-flex flex-column h-100"> <div ref={containerRef} className="p-2 d-flex flex-column h-100">
{/* search moved to header */} {/* search moved to header */}
<div ref={tableWrapperRef} className="table-responsive mb-2" style={{ overflowY: 'auto' }}> <div ref={tableWrapperRef} className="table-responsive mb-2" style={{ overflowY: 'auto' }}>
<Table hover size="sm" className="mb-0"> <Table hover size="sm" className="mb-0" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col />
<col style={{ width: '8ch' }} />
<col style={{ width: '6ch' }} />
<col style={{ width: '14ch' }} />
<col style={{ width: '9ch' }} className="text-end" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Node ID</th> <th>Node ID</th>
<th>Type</th> <th>
<div className="d-flex align-items-center">
<span>Type</span>
</div>
</th>
<th>Public key</th> <th>Public key</th>
<th>Last seen</th> <th>Last seen</th>
</tr> </tr>
@@ -286,7 +355,7 @@ export const MeshCoreNodesView: React.FC = () => {
<td><span className="meshcore-hash">{renderHighlighted(String(n.prefix || n.id || ''), search)}</span></td> <td><span className="meshcore-hash">{renderHighlighted(String(n.prefix || n.id || ''), search)}</span></td>
<td>{n.type}</td> <td>{n.type}</td>
<td><span className="meshcore-hash">{renderHighlighted(formatPubKey(n.public_key), search)}</span></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> <td className="text-end"><TimeAgo time={n.last_heard_at} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -351,8 +420,8 @@ export const MeshCoreNodesView: React.FC = () => {
<div><strong>Prefix:</strong> <span className="meshcore-hash">{renderHighlighted(String(selectedNode.prefix || ''), 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>Type:</strong> {selectedNode.type}</div>
<div><strong>Public key:</strong> <span className="meshcore-hash">{selectedNode.public_key}</span></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>First seen:</strong> <TimeAgo time={selectedNode.first_heard_at} /></div>
<div><strong>Last seen:</strong> {selectedNode.last_heard_at ? new Date(selectedNode.last_heard_at).toLocaleString() : '-'}</div> <div><strong>Last seen:</strong> <TimeAgo time={selectedNode.last_heard_at} /></div>
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && ( {selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
<div><strong>Location:</strong> {selectedNode.last_latitude}, {selectedNode.last_longitude}</div> <div><strong>Location:</strong> {selectedNode.last_latitude}, {selectedNode.last_longitude}</div>
)} )}

View File

@@ -25,12 +25,16 @@ export class APIService {
return response.data as T; return response.data as T;
} }
public async fetchPaginated<T>(endpoint: string, page: number = 1, limit: number = 20): Promise<Pager<T>> { public async fetchPaginated<T>(endpoint: string, page: number = 1, limit: number = 20, extraParams?: Record<string, unknown>): Promise<Pager<T>> {
const response = await this.client.get<Pager<T>>(endpoint, { const params: Record<string, unknown> = { page, limit };
params: { page, limit }, if (extraParams) {
}); Object.assign(params, extraParams);
return response.data as Pager<T>; }
} const response = await this.client.get<Pager<T>>(endpoint, {
params,
});
return response.data as Pager<T>;
}
public async fetchRadios(protocol?: string): Promise<Radio[]> { public async fetchRadios(protocol?: string): Promise<Radio[]> {
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios'; const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';

View File

@@ -216,9 +216,10 @@ export class MeshCoreService {
return this.api.fetch<MeshCorePacketStats>(`/meshcore/stats/packets/${encodeURIComponent(view)}`); return this.api.fetch<MeshCorePacketStats>(`/meshcore/stats/packets/${encodeURIComponent(view)}`);
} }
public async fetchNodes(page: number = 1, limit: number = 200): Promise<Pager<FetchedNode>> { public async fetchNodes(page: number = 1, limit: number = 200, type?: string): Promise<Pager<FetchedNode>> {
return this.api.fetchPaginated<FetchedNode>('/meshcore/nodes', page, limit); const extra: Record<string, unknown> | undefined = type ? { type } : undefined;
} return this.api.fetchPaginated<FetchedNode>('/meshcore/nodes', page, limit, extra);
}
public async fetchNodesCloseTo(hash: string): Promise<FetchedNodesCloseTo> { public async fetchNodesCloseTo(hash: string): Promise<FetchedNodesCloseTo> {
return this.api.fetch<FetchedNodesCloseTo>(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`); return this.api.fetch<FetchedNodesCloseTo>(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`);

View File

@@ -67,3 +67,81 @@ pre {
.main-content a { .main-content a {
color: var(--app-accent-yellow) !important; color: var(--app-accent-yellow) !important;
} }
// Dropdown overrides to match global app theme
.dropdown,
.dropup,
.dropleft,
.dropright {
// ensure dropdown toggles use accent color for icons/links
.dropdown-toggle {
color: var(--app-accent-primary);
&:hover,
&:focus {
color: var(--app-accent-blue);
background: transparent;
text-decoration: none;
}
}
}
.dropdown-menu {
background-color: var(--app-bg-elevated);
color: var(--app-text);
border: 1px solid var(--app-border-color);
box-shadow: 0 6px 18px rgba(2,10,26,0.6);
min-width: 10rem;
padding: 0.25rem 0;
}
.dropdown-item {
color: var(--app-text);
padding: 0.375rem 1rem;
font-size: 0.92em;
}
.dropdown-item:hover,
.dropdown-item:focus,
.dropdown-item.active {
background-color: var(--app-button-hover);
color: var(--app-text);
}
// Ensure svg icons inside dropdown toggles inherit theme color
.dropdown-toggle svg {
color: currentColor;
fill: currentColor;
}
// Smaller caret spacing for compact header controls
.data-table-header .dropdown-toggle {
padding: 0 0.25rem;
color: var(--app-accent-yellow);
}
.data-table-header .dropdown-toggle:hover,
.data-table-header .dropdown-toggle:focus {
color: var(--app-accent-yellow-muted);
}
/* Some Dropdown.Toggle instances use custom bsPrefix and don't include
the `dropdown-toggle` class. Target the header button by id and title
so the icon and button color are always visible. */
.data-table-header button#type-filter-toggle-header,
.data-table-header button#type-filter-toggle,
.data-table-header button[title="Filter by type"] {
color: var(--app-accent-yellow) !important;
}
.data-table-header button#type-filter-toggle-header:hover,
.data-table-header button#type-filter-toggle:hover,
.data-table-header button[title="Filter by type"]:hover {
color: var(--app-accent-yellow-muted) !important;
}
.data-table-header button#type-filter-toggle-header svg,
.data-table-header button#type-filter-toggle svg,
.data-table-header button[title="Filter by type"] svg {
color: currentColor;
fill: currentColor;
}