diff --git a/ui/src/components/TimeAgo.test.tsx b/ui/src/components/TimeAgo.test.tsx new file mode 100644 index 0000000..ea9a81a --- /dev/null +++ b/ui/src/components/TimeAgo.test.tsx @@ -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(); + expect(screen.getByText('10s ago')).toBeTruthy(); + }); + + it('renders minutes ago', () => { + const t = new Date(now - 5 * 60 * 1000).toISOString(); + render(); + expect(screen.getByText('5m ago')).toBeTruthy(); + }); + + it('renders hours ago', () => { + const t = new Date(now - 2 * 60 * 60 * 1000).toISOString(); + render(); + expect(screen.getByText('2h ago')).toBeTruthy(); + }); + + it('renders days ago', () => { + const t = new Date(now - 4 * 24 * 60 * 60 * 1000).toISOString(); + render(); + expect(screen.getByText('4d ago')).toBeTruthy(); + }); + + it('renders weeks ago', () => { + const t = new Date(now - 14 * 24 * 60 * 60 * 1000).toISOString(); + render(); + expect(screen.getByText('2w ago')).toBeTruthy(); + }); + + it('renders months ago', () => { + const t = new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString(); + render(); + expect(screen.getByText('1mo ago')).toBeTruthy(); + }); + + it('renders dash for null/undefined', () => { + render(); + expect(screen.getByText('-')).toBeTruthy(); + }); + + it('renders dash for invalid time', () => { + render(); + expect(screen.getByText('-')).toBeTruthy(); + }); +}); diff --git a/ui/src/components/TimeAgo.tsx b/ui/src/components/TimeAgo.tsx new file mode 100644 index 0000000..c2f9cf0 --- /dev/null +++ b/ui/src/components/TimeAgo.tsx @@ -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 = ({ 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 {label}; +}; + +export default TimeAgo; diff --git a/ui/src/components/TimeTicker.ts b/ui/src/components/TimeTicker.ts new file mode 100644 index 0000000..375acfd --- /dev/null +++ b/ui/src/components/TimeTicker.ts @@ -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 }; diff --git a/ui/src/pages/meshcore/MeshCoreNodesView.tsx b/ui/src/pages/meshcore/MeshCoreNodesView.tsx index 0b8a4a3..6290cf2 100644 --- a/ui/src/pages/meshcore/MeshCoreNodesView.tsx +++ b/ui/src/pages/meshcore/MeshCoreNodesView.tsx @@ -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(1); const [visibleRows, setVisibleRows] = useState(50); const [fetchLimit] = useState(50); @@ -19,6 +29,7 @@ export const MeshCoreNodesView: React.FC = () => { const [allNodes, setAllNodes] = useState(null); const [allLoading, setAllLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedType, setSelectedType] = useState(null); const [search, setSearch] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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 = () => { )} + + {/* Type filter dropdown moved to header so it's visible even without table items */} + + + + + + { + setSelectedType(null); + const newParams = new URLSearchParams(searchParams); + newParams.delete('type'); + setSearchParams(newParams, { replace: true }); + setPage(1); + setAllNodes(null); + setPager(null); + }} + > + All + + {NODE_TYPES.filter((o) => o.value).map((opt) => ( + { + 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} + + ))} + + @@ -262,12 +320,23 @@ export const MeshCoreNodesView: React.FC = () => {
{/* search moved to header */}
- +
+ + + + + + + - + @@ -286,7 +355,7 @@ export const MeshCoreNodesView: React.FC = () => { - + ))} @@ -351,8 +420,8 @@ export const MeshCoreNodesView: React.FC = () => {
Prefix: {renderHighlighted(String(selectedNode.prefix || ''), search)}
Type: {selectedNode.type}
Public key: {selectedNode.public_key}
-
First seen: {selectedNode.first_heard_at ? new Date(selectedNode.first_heard_at).toLocaleString() : '-'}
-
Last seen: {selectedNode.last_heard_at ? new Date(selectedNode.last_heard_at).toLocaleString() : '-'}
+
First seen:
+
Last seen:
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
Location: {selectedNode.last_latitude}, {selectedNode.last_longitude}
)} diff --git a/ui/src/services/API.ts b/ui/src/services/API.ts index 1120b8f..2d20248 100644 --- a/ui/src/services/API.ts +++ b/ui/src/services/API.ts @@ -25,12 +25,16 @@ export class APIService { return response.data as T; } - public async fetchPaginated(endpoint: string, page: number = 1, limit: number = 20): Promise> { - const response = await this.client.get>(endpoint, { - params: { page, limit }, - }); - return response.data as Pager; - } + public async fetchPaginated(endpoint: string, page: number = 1, limit: number = 20, extraParams?: Record): Promise> { + const params: Record = { page, limit }; + if (extraParams) { + Object.assign(params, extraParams); + } + const response = await this.client.get>(endpoint, { + params, + }); + return response.data as Pager; + } public async fetchRadios(protocol?: string): Promise { const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios'; diff --git a/ui/src/services/MeshCoreService.ts b/ui/src/services/MeshCoreService.ts index 7e8eb66..4f673ae 100644 --- a/ui/src/services/MeshCoreService.ts +++ b/ui/src/services/MeshCoreService.ts @@ -216,9 +216,10 @@ export class MeshCoreService { return this.api.fetch(`/meshcore/stats/packets/${encodeURIComponent(view)}`); } - public async fetchNodes(page: number = 1, limit: number = 200): Promise> { - return this.api.fetchPaginated('/meshcore/nodes', page, limit); - } + public async fetchNodes(page: number = 1, limit: number = 200, type?: string): Promise> { + const extra: Record | undefined = type ? { type } : undefined; + return this.api.fetchPaginated('/meshcore/nodes', page, limit, extra); + } public async fetchNodesCloseTo(hash: string): Promise { return this.api.fetch(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`); diff --git a/ui/src/styles/theme/_bootstrap-overrides.scss b/ui/src/styles/theme/_bootstrap-overrides.scss index 0b2e52a..d4b1e37 100644 --- a/ui/src/styles/theme/_bootstrap-overrides.scss +++ b/ui/src/styles/theme/_bootstrap-overrides.scss @@ -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; +}
Name Node IDType +
+ Type +
+
Public key Last seen
{renderHighlighted(String(n.prefix || n.id || ''), search)} {n.type} {renderHighlighted(formatPubKey(n.public_key), search)}{n.last_heard_at ? new Date(n.last_heard_at).toLocaleString() : '-'}