/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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user