/meshcore/nodes: update table
This commit is contained in:
64
ui/src/components/TimeAgo.test.tsx
Normal file
64
ui/src/components/TimeAgo.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
72
ui/src/components/TimeAgo.tsx
Normal file
72
ui/src/components/TimeAgo.tsx
Normal 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;
|
||||
24
ui/src/components/TimeTicker.ts
Normal file
24
ui/src/components/TimeTicker.ts
Normal 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 };
|
||||
@@ -1,14 +1,24 @@
|
||||
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 MeshCoreService from '../../services/MeshCoreService';
|
||||
import VerticalSplit from '../../components/VerticalSplit';
|
||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||
import TimeAgo from '../../components/TimeAgo';
|
||||
|
||||
const meshCoreService = new MeshCoreService(API);
|
||||
|
||||
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 [visibleRows, setVisibleRows] = 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 [allLoading, setAllLoading] = useState<boolean>(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -58,10 +69,10 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
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 });
|
||||
const p = await meshCoreService.fetchNodes(serverPage, fetchLimit, selectedType ?? undefined);
|
||||
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 || ''));
|
||||
setPager(p);
|
||||
if (!selectedNode && p.items.length > 0) {
|
||||
@@ -78,7 +89,14 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
|
||||
void load();
|
||||
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(() => {
|
||||
if (!pager || allNodes !== null || allLoading) return;
|
||||
@@ -95,22 +113,22 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
// 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);
|
||||
const res = await meshCoreService.fetchNodes(p, pageSize, selectedType ?? undefined);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -124,10 +142,10 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -135,7 +153,7 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
|
||||
void loadAllSerial();
|
||||
return () => { isMounted = false; };
|
||||
}, [pager, fetchLimit]);
|
||||
}, [pager, fetchLimit, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
// Table sizing by visual viewport removed — allow vertical overflow again.
|
||||
@@ -245,6 +263,46 @@ export const MeshCoreNodesView: React.FC = () => {
|
||||
<Spinner animation="border" size="sm" />
|
||||
</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>
|
||||
</Card.Header>
|
||||
<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">
|
||||
{/* search moved to header */}
|
||||
<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>
|
||||
<tr>
|
||||
<th>Name</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>Last seen</th>
|
||||
</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>{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>
|
||||
<td className="text-end"><TimeAgo time={n.last_heard_at} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</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>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>
|
||||
<div><strong>First seen:</strong> <TimeAgo time={selectedNode.first_heard_at} /></div>
|
||||
<div><strong>Last seen:</strong> <TimeAgo time={selectedNode.last_heard_at} /></div>
|
||||
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
|
||||
<div><strong>Location:</strong> {selectedNode.last_latitude}, {selectedNode.last_longitude}</div>
|
||||
)}
|
||||
|
||||
@@ -25,9 +25,13 @@ export class APIService {
|
||||
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 params: Record<string, unknown> = { page, limit };
|
||||
if (extraParams) {
|
||||
Object.assign(params, extraParams);
|
||||
}
|
||||
const response = await this.client.get<Pager<T>>(endpoint, {
|
||||
params: { page, limit },
|
||||
params,
|
||||
});
|
||||
return response.data as Pager<T>;
|
||||
}
|
||||
|
||||
@@ -216,8 +216,9 @@ export class MeshCoreService {
|
||||
return this.api.fetch<MeshCorePacketStats>(`/meshcore/stats/packets/${encodeURIComponent(view)}`);
|
||||
}
|
||||
|
||||
public async fetchNodes(page: number = 1, limit: number = 200): Promise<Pager<FetchedNode>> {
|
||||
return this.api.fetchPaginated<FetchedNode>('/meshcore/nodes', page, limit);
|
||||
public async fetchNodes(page: number = 1, limit: number = 200, type?: string): Promise<Pager<FetchedNode>> {
|
||||
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> {
|
||||
|
||||
@@ -67,3 +67,81 @@ pre {
|
||||
.main-content a {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user