diff --git a/CHANGELOG.md b/CHANGELOG.md index 8662331..3326219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [3.15.0] - 2026-06-11 + +* Feature: Enhanced repeater telemetry with scrubbing and better extents +* Feature: Outbound message opt-in for Apprise +* Feature: Reverse-link button on trace pane +* Feature: Add recently traced contacts as own category in repeater pane +* Feature: More compact trace pane display +* Bugfix: Scavenge ACK codes for standalone acks, resolving issues with DM ack detection +* Bugfix: Proper timestamps for community MQTT +* Bugfix: Clearer packet history legend in packet view +* Misc: Add pubkey suffix to repeater neighbors +* Misc: Dependency bumps & test fixes + ## [3.14.1] - 2026-06-01 * Feature: Enhance online documentation diff --git a/LICENSES.md b/LICENSES.md index a4c7d8c..0a2513f 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -277,7 +277,7 @@ Apache License -### fastapi (0.128.0) — MIT +### fastapi (0.136.3) — MIT
Full license text diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index ac3ece9..d8d7074 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -132,6 +132,7 @@ HTTP webhook delivery. Config blob: Push notifications via Apprise library. Config blob: - `urls` — newline-separated Apprise notification service URLs - `preserve_identity` — suppress Discord webhook name/avatar override +- `include_outgoing` — when true, RemoteTerm-originated manual and bot-sent messages are forwarded to Apprise; missing/false preserves the legacy incoming-only behavior - `include_path` — include routing path in notification body - Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name. diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index a00f5ab..208cc95 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -188,14 +188,15 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: b class AppriseModule(FanoutModule): - """Sends push notifications via Apprise for incoming messages.""" + """Sends push notifications via Apprise for matched messages.""" def __init__(self, config_id: str, config: dict, *, name: str = "") -> None: super().__init__(config_id, config, name=name) async def on_message(self, data: dict) -> None: - # Skip outgoing messages — only notify on incoming - if data.get("outgoing"): + # Skip outgoing messages by default. Operators can opt in when they + # want RemoteTerm-originated manual/bot sends mirrored to Apprise. + if data.get("outgoing") and not self.config.get("include_outgoing", False): return urls = self.config.get("urls", "") diff --git a/app/routers/fanout.py b/app/routers/fanout.py index 43d0d6b..9e2daa2 100644 --- a/app/routers/fanout.py +++ b/app/routers/fanout.py @@ -274,9 +274,8 @@ def _validate_apprise_config(config: dict) -> None: status_code=400, detail=f"Invalid format string in {field}" ) from None - markdown_format = config.get("markdown_format") - if markdown_format is not None: - config["markdown_format"] = bool(markdown_format) + config["markdown_format"] = bool(config.get("markdown_format", True)) + config["include_outgoing"] = bool(config.get("include_outgoing", False)) def _validate_webhook_config(config: dict) -> None: diff --git a/frontend/package.json b/frontend/package.json index 61bdeaa..b6bace3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.14.1", + "version": "3.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx index 105e95d..82b7298 100644 --- a/frontend/src/components/TracePane.tsx +++ b/frontend/src/components/TracePane.tsx @@ -26,11 +26,14 @@ import { import { Input } from './ui/input'; import { cn } from '@/lib/utils'; -type TraceSortMode = 'alpha' | 'recent' | 'distance'; +type TraceSortMode = 'alpha' | 'recent' | 'distance' | 'traced'; type CustomHopBytes = 1 | 2 | 4; const RECENT_TRACES_KEY = 'remoteterm-recent-traces'; const MAX_RECENT_TRACES = 5; +const RECENT_NODES_KEY = 'remoteterm-recent-trace-nodes'; +const MAX_RECENT_NODES = 30; +const MAX_RENDERED_REPEATERS = 60; interface SavedTraceHop { kind: 'repeater' | 'custom'; @@ -71,6 +74,57 @@ function saveRecentTrace(trace: SavedTrace): void { } } +function repeaterKeysFromHops(hops: SavedTraceHop[]): string[] { + return [ + ...new Set( + hops + .filter((hop) => hop.kind === 'repeater' && hop.publicKey) + .map((hop) => hop.publicKey as string) + ), + ]; +} + +function loadRecentNodeKeys(): string[] { + try { + const raw = localStorage.getItem(RECENT_NODES_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return [ + ...new Set( + parsed + .map((entry) => + typeof entry === 'string' ? entry : ((entry?.publicKey as string) ?? null) + ) + .filter((key): key is string => typeof key === 'string' && key.length > 0) + ), + ].slice(0, MAX_RECENT_NODES); + } + // No usage history yet: seed from already-stored recent traces so the + // Recent Traced sort works immediately for users with existing history. + return repeaterKeysFromHops(loadRecentTraces().flatMap((trace) => trace.hops)).slice( + 0, + MAX_RECENT_NODES + ); + } catch { + return []; + } +} + +function saveRecentNodeKeys(hops: SavedTraceHop[]): void { + try { + // MRU order: repeaters from this trace first, then prior history. + const fresh = repeaterKeysFromHops(hops); + const rest = loadRecentNodeKeys().filter((key) => !fresh.includes(key)); + localStorage.setItem( + RECENT_NODES_KEY, + JSON.stringify([...fresh, ...rest].slice(0, MAX_RECENT_NODES)) + ); + } catch { + // localStorage may be disabled + } +} + type TraceDraftHop = | { id: string; kind: 'repeater'; publicKey: string } | { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes }; @@ -136,49 +190,45 @@ function nextDraftHopId(prefix: string, currentLength: number): string { function TraceNodeRow({ title, subtitle, + badge, meta, - note, fixed = false, - compact = false, actions, snr, }: { title: string; subtitle: string; + badge?: string; meta?: string | null; - note?: string | null; fixed?: boolean; - compact?: boolean; actions?: ReactNode; snr?: string | null; }) { return ( -
+
- {fixed ? 'Self' : 'Hop'} + {fixed ? 'Self' : (badge ?? 'Hop')}
-
-
{title}
-
{subtitle}
- {meta ?
{meta}
: null} - {note ?
{note}
: null} +
+ {title} + {subtitle} + {meta ? ( + + {meta} + + ) : null}
{snr ? ( -
-
SNR
-
{snr}
+
+ SNR + {snr}
) : null} {actions ?
{actions}
: null} @@ -200,6 +250,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) const [customHopError, setCustomHopError] = useState(null); const [recentTraces, setRecentTraces] = useState(loadRecentTraces); const [recentTracesOpen, setRecentTracesOpen] = useState(false); + const [recentNodeKeys, setRecentNodeKeys] = useState(loadRecentNodeKeys); const activeRunTokenRef = useRef(0); const repeaters = useMemo(() => { @@ -220,9 +271,16 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) [repeaters] ); + const tracedIndexByKey = useMemo( + () => new Map(recentNodeKeys.map((key, index) => [key, index])), + [recentNodeKeys] + ); + + const canSortByDistance = !!config && isValidLocation(config.lat, config.lon); + const filteredRepeaters = useMemo(() => { const query = searchQuery.trim().toLowerCase(); - const matching = query + let matching = query ? repeaters.filter( (contact) => (contact.name ?? '').toLowerCase().includes(query) || @@ -230,7 +288,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) ) : repeaters; + // Traced shows only repeaters actually used in traces; Dist. shows only + // repeaters with a computable distance (when the local radio has one). + if (sortMode === 'traced') { + matching = matching.filter((contact) => tracedIndexByKey.has(contact.public_key)); + } + const distanceByKey = + sortMode === 'distance' + ? new Map(matching.map((contact) => [contact.public_key, getDistanceKm(contact, config)])) + : null; + if (distanceByKey && canSortByDistance) { + matching = matching.filter((contact) => distanceByKey.get(contact.public_key) !== null); + } + return [...matching].sort((left, right) => { + if (sortMode === 'traced') { + const leftIndex = tracedIndexByKey.get(left.public_key) ?? Infinity; + const rightIndex = tracedIndexByKey.get(right.public_key) ?? Infinity; + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + } if (sortMode === 'recent') { const leftTs = getHeardTimestamp(left); const rightTs = getHeardTimestamp(right); @@ -238,9 +316,9 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) return rightTs - leftTs; } } - if (sortMode === 'distance') { - const leftDistance = getDistanceKm(left, config); - const rightDistance = getDistanceKm(right, config); + if (distanceByKey) { + const leftDistance = distanceByKey.get(left.public_key) ?? null; + const rightDistance = distanceByKey.get(right.public_key) ?? null; if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) { return leftDistance - rightDistance; } @@ -251,11 +329,15 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) getContactDisplayName(right.name, right.public_key, right.last_advert) ); }); - }, [config, repeaters, searchQuery, sortMode]); + }, [canSortByDistance, config, repeaters, searchQuery, sortMode, tracedIndexByKey]); + + const visibleRepeaters = useMemo( + () => filteredRepeaters.slice(0, MAX_RENDERED_REPEATERS), + [filteredRepeaters] + ); const localRadioName = config?.name || 'Local radio'; const localRadioKey = config?.public_key ?? null; - const canSortByDistance = !!config && isValidLocation(config.lat, config.lon); const customHopBytesLocked = useMemo( () => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null, [draftHops] @@ -318,6 +400,33 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) clearPendingResult(); }; + // Append the reversed hop chain (minus the current endpoint) to build a return + // path, e.g. [R1, R2, R3] -> [R1, R2, R3, R2, R1]. A single hop is left as-is. + // See issue #287. Reverses every queued hop, including custom prefixes. + const handleReverseLink = () => { + setDraftHops((current) => { + if (current.length < 2) return current; + const returnHops = [...current] + .reverse() + .slice(1) + .map( + (hop, i): TraceDraftHop => ({ + ...hop, + id: nextDraftHopId(hop.kind, current.length + i), + }) + ); + return [...current, ...returnHops]; + }); + clearPendingResult(); + }; + + const recordTraceRun = (hops: SavedTraceHop[]) => { + saveRecentTrace({ hops, ranAt: Date.now() }); + setRecentTraces(loadRecentTraces()); + saveRecentNodeKeys(hops); + setRecentNodeKeys(loadRecentNodeKeys()); + }; + const handleLoadRecentTrace = async (trace: SavedTrace) => { const hops: TraceDraftHop[] = trace.hops.map((h, i) => { if (h.kind === 'repeater' && h.publicKey) { @@ -356,10 +465,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) if (activeRunTokenRef.current !== runToken) return; setResult(traceResult); - // Re-save to bump this trace to the top of recents - const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() }; - saveRecentTrace(savedTrace); - setRecentTraces(loadRecentTraces()); + // Re-save to bump this trace and its nodes to the top of recents + recordTraceRun(trace.hops); } catch (err) { if (activeRunTokenRef.current !== runToken) return; setError(err instanceof Error ? err.message : 'Unknown error'); @@ -406,9 +513,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`, }; }); - const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() }; - saveRecentTrace(trace); - setRecentTraces(loadRecentTraces()); + recordTraceRun(savedHops); } catch (err) { if (activeRunTokenRef.current !== runToken) { return; @@ -470,16 +575,18 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
{( [ - ['alpha', 'Alpha'], - ['recent', 'Recent Heard'], - ['distance', 'Distance'], + ['alpha', 'A/Z', 'Sort alphabetically'], + ['recent', 'Heard', 'Most recently heard first'], + ['traced', 'Traced', 'Most recently used in traces first'], + ['distance', 'Dist.', 'Closest first'], ] as const - ).map(([value, label]) => ( + ).map(([value, label, description]) => (
@@ -616,18 +735,31 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) )}
{draftHops.length > 0 ? ( - +
+ + +
) : null}
@@ -636,7 +768,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) subtitle={getShortKey(localRadioKey)} meta="Origin" fixed - compact /> {draftHops.length === 0 ? (
@@ -663,13 +794,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
-
+
{draftHops.length === 0 ? 'No hops selected' - : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`} + : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace · you must be able to hear the final repeater for trace success`}
-
-
-

- Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''} -

- {result || error ? ( + {result || error ? ( +
+
+

+ Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''} +

- ) : null} +
+
+ {error ? ( +
+ {error} +
+ ) : null} + {result + ? resultNodes.map((node, index) => { + const title = + node.name || + (node.role === 'custom' + ? 'Custom hop' + : node.role === 'local' + ? localRadioName + : getShortKey(node.public_key)); + const subtitle = + node.role === 'custom' + ? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}` + : node.observed_hash && + node.public_key && + node.observed_hash.toLowerCase() !== + getShortKey(node.public_key).toLowerCase() + ? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}` + : getShortKey(node.public_key); + return ( +
+ +
+ ); + }) + : null} +
-
- {error ? ( -
- {error} -
- ) : null} - {!error && !result ? ( -
- Send a trace to see the returned hop-by-hop SNR values. -
- ) : null} - {result - ? resultNodes.map((node, index) => { - const title = - node.name || - (node.role === 'custom' - ? 'Custom hop' - : node.role === 'local' - ? localRadioName - : getShortKey(node.public_key)); - const subtitle = - node.role === 'custom' - ? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}` - : node.observed_hash && - node.public_key && - node.observed_hash.toLowerCase() !== - getShortKey(node.public_key).toLowerCase() - ? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}` - : getShortKey(node.public_key); - return ( -
- -
- ); - }) - : null} -
-
+ ) : null}
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 2dc3323..9ef0726 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -296,6 +296,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ config: { urls: '', preserve_identity: true, + include_outgoing: false, markdown_format: true, body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', body_format_channel: @@ -2599,6 +2600,23 @@ function AppriseConfigEditor({
+ +

Message Format

diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 3c26658..bf76c8e 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -561,6 +561,107 @@ describe('SettingsFanoutSection', () => { ); }); + it('creates Apprise with outgoing forwarding disabled by default', async () => { + const createdApprise: FanoutConfig = { + id: 'ap-new', + type: 'apprise', + name: 'Apprise #1', + enabled: true, + config: { + urls: '', + preserve_identity: true, + include_outgoing: false, + markdown_format: true, + body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', + body_format_channel: + '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', + }, + scope: { messages: 'all', raw_packets: 'none' }, + sort_order: 0, + created_at: 2000, + }; + mockedApi.createFanoutConfig.mockResolvedValue(createdApprise); + mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdApprise]); + + renderSection(); + await openCreateIntegrationDialog(); + selectCreateIntegration('Apprise'); + confirmCreateIntegration(); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + expect(screen.getByLabelText(/Forward RemoteTerm-sent messages/)).not.toBeChecked(); + expect( + screen.getByText(/Outgoing messages carry no routing path or signal data/) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({ + type: 'apprise', + name: 'Apprise #1', + config: { + urls: '', + preserve_identity: true, + include_outgoing: false, + markdown_format: true, + body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', + body_format_channel: + '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', + }, + scope: { messages: 'all', raw_packets: 'none' }, + enabled: true, + }) + ); + }); + + it('can enable outgoing forwarding for an existing Apprise integration', async () => { + const appriseConfig: FanoutConfig = { + id: 'ap-1', + type: 'apprise', + name: 'Apprise Feed', + enabled: true, + config: { + urls: 'discord://abc', + preserve_identity: true, + markdown_format: true, + }, + scope: { messages: 'all', raw_packets: 'none' }, + sort_order: 0, + created_at: 1000, + }; + mockedApi.getFanoutConfigs.mockResolvedValue([appriseConfig]); + mockedApi.updateFanoutConfig.mockResolvedValue({ + ...appriseConfig, + config: { ...appriseConfig.config, include_outgoing: true }, + }); + + renderSection(); + await waitFor(() => expect(screen.getByText('Apprise Feed')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + const includeOutgoing = screen.getByLabelText(/Forward RemoteTerm-sent messages/); + expect(includeOutgoing).not.toBeChecked(); + fireEvent.click(includeOutgoing); + fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' })); + + await waitFor(() => + expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('ap-1', { + name: 'Apprise Feed', + config: { + urls: 'discord://abc', + preserve_identity: true, + markdown_format: true, + include_outgoing: true, + }, + scope: { messages: 'all', raw_packets: 'none' }, + enabled: true, + }) + ); + }); + it('new draft names increment within the integration type', async () => { mockedApi.getFanoutConfigs.mockResolvedValue([ webhookConfig, diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx index cbab1e9..8aedd2d 100644 --- a/frontend/src/test/tracePane.test.tsx +++ b/frontend/src/test/tracePane.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TracePane } from '../components/TracePane'; import type { Contact, RadioConfig, RadioTraceResponse } from '../types'; @@ -45,6 +45,10 @@ const config: RadioConfig = { }; describe('TracePane', () => { + beforeEach(() => { + localStorage.clear(); + }); + it('shows only full-key repeaters and filters by name or key', () => { render( { fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); - expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i })); fireEvent.click(screen.getByRole('button', { name: /send trace/i })); @@ -129,11 +133,54 @@ describe('TracePane', () => { expect(screen.getByText('+5.0 dB')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i })); - expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText(/1 hop selected · 4-byte trace/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i })); expect(screen.getByText('No hops selected')).toBeInTheDocument(); }); + it('reverse link appends the reversed hop chain to build a return path (issue #287)', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + const relayC = makeContact('33'.repeat(32), 'Relay Charlie'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 0, + timeout_seconds: 6, + nodes: [], + }) + ); + + render( + + ); + + // Single hop: Reverse link is a no-op (and disabled). + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + expect(screen.getByRole('button', { name: /reverse link/i })).toBeDisabled(); + + // R1, R2, R3 -> append R2, R1 => R1, R2, R3, R2, R1. + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i })); + fireEvent.click(screen.getByRole('button', { name: /reverse link/i })); + + expect(screen.getByText(/5 hops selected · 4-byte trace/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [ + { public_key: relayA.public_key }, + { public_key: relayB.public_key }, + { public_key: relayC.public_key }, + { public_key: relayB.public_key }, + { public_key: relayA.public_key }, + ]); + }); + }); + it('allows adding the same repeater multiple times from the picker row', () => { const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); @@ -142,7 +189,7 @@ describe('TracePane', () => { fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); - expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument(); expect(screen.getByText('Added 2 times')).toBeInTheDocument(); }); @@ -185,7 +232,7 @@ describe('TracePane', () => { fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } }); fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' })); - expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument(); + expect(screen.getByText(/1 hop selected · 1-byte trace/)).toBeInTheDocument(); expect(screen.getByText('AE (1-byte)')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); @@ -204,6 +251,128 @@ describe('TracePane', () => { expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument(); }); + it('Traced lists only trace-used repeaters in MRU order, persisted locally (issue #286)', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + const relayC = makeContact('33'.repeat(32), 'Relay Charlie'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 0, + timeout_seconds: 6, + nodes: [], + }) + ); + + const { unmount } = render( + + ); + + const rowNames = () => + screen + .queryAllByRole('button', { name: /^add repeater/i }) + .map((row) => row.getAttribute('aria-label')); + + // No history yet: Traced shows an explanatory empty state, not the full list. + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(screen.getByText(/no repeaters have been used in traces yet/i)).toBeInTheDocument(); + expect(rowNames()).toEqual([]); + + // Build and run a trace with B from the A/Z list. + fireEvent.click(screen.getByRole('button', { name: 'A/Z' })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(1)); + + // Traced lists only B; untraced A and C are filtered out. + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Beta']); + + // A second trace with C bumps it above B. + fireEvent.click(screen.getByRole('button', { name: 'A/Z' })); + fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']); + + // Order persists across remounts via localStorage. + unmount(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']); + }); + + it('seeds Traced from stored recent traces when no usage history exists', () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + localStorage.setItem( + 'remoteterm-recent-traces', + JSON.stringify([ + { + ranAt: 1, + hops: [ + { kind: 'repeater', publicKey: relayB.public_key, displayName: 'Relay Beta' }, + { kind: 'custom', hopHex: 'ae', hopBytes: 1, displayName: 'AE (1B)' }, + ], + }, + ]) + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect( + screen + .getAllByRole('button', { name: /^add repeater/i }) + .map((row) => row.getAttribute('aria-label')) + ).toEqual(['Add repeater Relay Beta']); + }); + + it('Dist. hides repeaters without a known distance when the radio has a location', () => { + const located = makeContact('11'.repeat(32), 'Relay Located', CONTACT_TYPE_REPEATER, { + lat: 10.1, + lon: 20.1, + }); + const unlocated = makeContact('22'.repeat(32), 'Relay Mystery'); + + render(); + + // A/Z shows both. + expect(screen.getByText('Relay Located')).toBeInTheDocument(); + expect(screen.getByText('Relay Mystery')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dist.' })); + expect(screen.getByText('Relay Located')).toBeInTheDocument(); + expect(screen.queryByText('Relay Mystery')).not.toBeInTheDocument(); + + // Without a local radio location, the filter is skipped (note explains instead). + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'mystery' } }); + expect(screen.getByText(/no repeaters with a known distance matched/i)).toBeInTheDocument(); + }); + + it('caps the rendered repeater list and reports the overflow', () => { + const contacts = Array.from({ length: 70 }, (_, i) => + makeContact(i.toString(16).padStart(2, '0').repeat(32), `Relay ${String(i).padStart(3, '0')}`) + ); + + render(); + + expect(screen.getAllByRole('button', { name: /^add repeater/i })).toHaveLength(60); + expect(screen.getByText(/showing the first 60 of 70 repeaters/i)).toBeInTheDocument(); + }); + it('drops an in-flight result after the draft path changes', async () => { const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); const relayB = makeContact('22'.repeat(32), 'Relay Beta'); @@ -228,7 +397,7 @@ describe('TracePane', () => { fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); - expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText(/2 hops selected · 4-byte trace/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled(); await act(async () => { @@ -256,8 +425,7 @@ describe('TracePane', () => { expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument(); expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument(); - expect( - screen.getByText('Send a trace to see the returned hop-by-hop SNR values.') - ).toBeInTheDocument(); + // The Results section stays hidden entirely until a result or error lands. + expect(screen.queryByRole('heading', { name: /^results/i })).not.toBeInTheDocument(); }); }); diff --git a/pyproject.toml b/pyproject.toml index 8b4dcc2..b0ee3dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.14.1" +version = "3.15.0" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 8f6d902..6597420 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -1017,7 +1017,7 @@ class TestAppriseModule: assert mod.status == "connected" @pytest.mark.asyncio - async def test_skips_outgoing_messages(self): + async def test_skips_outgoing_messages_by_default(self): from unittest.mock import patch as _patch from app.fanout.apprise_mod import AppriseModule @@ -1027,6 +1027,19 @@ class TestAppriseModule: await mod.on_message({"type": "PRIV", "text": "hi", "outgoing": True}) mock_send.assert_not_called() + @pytest.mark.asyncio + async def test_sends_outgoing_messages_when_enabled(self): + from unittest.mock import patch as _patch + + from app.fanout.apprise_mod import AppriseModule + + mod = AppriseModule("test", {"urls": "json://localhost", "include_outgoing": True}) + with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send: + await mod.on_message( + {"type": "PRIV", "text": "hi", "outgoing": True, "sender_name": "Me"} + ) + mock_send.assert_called_once() + @pytest.mark.asyncio async def test_sends_for_incoming_messages(self): from unittest.mock import patch as _patch @@ -1379,10 +1392,26 @@ class TestAppriseValidation: _validate_apprise_config(config) assert config["markdown_format"] is False - def test_validate_apprise_config_works_without_markdown_format(self): + def test_validate_apprise_config_defaults_markdown_format_true(self): from app.routers.fanout import _validate_apprise_config - _validate_apprise_config({"urls": "discord://123/abc"}) + config: dict = {"urls": "discord://123/abc"} + _validate_apprise_config(config) + assert config["markdown_format"] is True + + def test_validate_apprise_config_defaults_include_outgoing_false(self): + from app.routers.fanout import _validate_apprise_config + + config: dict = {"urls": "discord://123/abc"} + _validate_apprise_config(config) + assert config["include_outgoing"] is False + + def test_validate_apprise_config_normalizes_include_outgoing(self): + from app.routers.fanout import _validate_apprise_config + + config: dict = {"urls": "discord://123/abc", "include_outgoing": 1} + _validate_apprise_config(config) + assert config["include_outgoing"] is True class TestAppriseMarkdownFormat: diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index acb34a9..c9d9c29 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -1247,8 +1247,8 @@ class TestFanoutAppriseIntegration: assert "#general" in body_text @pytest.mark.asyncio - async def test_apprise_skips_outgoing(self, apprise_capture_server, integration_db): - """Apprise should NOT deliver outgoing messages.""" + async def test_apprise_skips_outgoing_by_default(self, apprise_capture_server, integration_db): + """Apprise should NOT deliver outgoing messages unless explicitly enabled.""" cfg = await FanoutConfigRepository.create( config_type="apprise", name="No Outgoing", @@ -1280,6 +1280,45 @@ class TestFanoutAppriseIntegration: assert len(apprise_capture_server.received) == 0 + @pytest.mark.asyncio + async def test_apprise_delivers_outgoing_when_enabled( + self, apprise_capture_server, integration_db + ): + """Apprise can opt in to delivering RemoteTerm-originated messages.""" + cfg = await FanoutConfigRepository.create( + config_type="apprise", + name="Include Outgoing", + config={ + "urls": f"json://127.0.0.1:{apprise_capture_server.port}", + "include_outgoing": True, + }, + scope={"messages": "all", "raw_packets": "none"}, + enabled=True, + ) + + manager = FanoutManager() + try: + await manager.load_from_db() + assert cfg["id"] in manager._modules + + await manager.broadcast_message( + { + "type": "PRIV", + "conversation_key": "pk1", + "text": "my outgoing", + "sender_name": "Me", + "outgoing": True, + } + ) + + results = await apprise_capture_server.wait_for(1) + finally: + await manager.stop_all() + + assert len(results) >= 1 + body_text = str(results[0]) + assert "my outgoing" in body_text + @pytest.mark.asyncio async def test_apprise_disabled_no_delivery(self, apprise_capture_server, integration_db): """Disabled Apprise module should not deliver anything.""" diff --git a/uv.lock b/uv.lock index e55b19c..1076a10 100644 --- a/uv.lock +++ b/uv.lock @@ -1550,7 +1550,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.14.1" +version = "3.15.0" source = { virtual = "." } dependencies = [ { name = "aiomqtt" },