From 365728be02c3de45ace7ae58237b6f6803a76d31 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 28 Feb 2026 15:33:50 -0800 Subject: [PATCH] Make pathing clearable on click --- app/routers/contacts.py | 28 +++++ frontend/src/api.ts | 4 + frontend/src/components/ChatHeader.tsx | 39 ++++++- frontend/src/components/RepeaterDashboard.tsx | 39 ++++++- frontend/src/test/repeaterDashboard.test.tsx | 101 ++++++++++++++++++ tests/test_contacts_router.py | 72 +++++++++++++ 6 files changed, 279 insertions(+), 4 deletions(-) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index cd54890..defb331 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -429,3 +429,31 @@ async def request_trace(public_key: str) -> TraceResponse: ) return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len) + + +@router.post("/{public_key}/reset-path") +async def reset_contact_path(public_key: str) -> dict: + """Reset a contact's routing path to flood.""" + contact = await _resolve_contact_or_404(public_key) + + await ContactRepository.update_path(contact.public_key, "", -1) + logger.info("Reset path to flood for %s", contact.public_key[:12]) + + # Push the updated path to radio if connected and contact is on radio + if radio_manager.is_connected and contact.on_radio: + try: + updated = await ContactRepository.get_by_key(contact.public_key) + if updated: + async with radio_manager.radio_operation("reset_path_on_radio") as mc: + await mc.commands.add_contact(updated.to_radio_dict()) + except Exception: + logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12]) + + # Broadcast updated contact so frontend refreshes + from app.websocket import broadcast_event + + updated_contact = await ContactRepository.get_by_key(contact.public_key) + if updated_contact: + broadcast_event("contact", updated_contact.model_dump()) + + return {"status": "ok", "public_key": contact.public_key} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4f69e28..c8c208a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -134,6 +134,10 @@ export const api = { fetchJson(`/contacts/${publicKey}/trace`, { method: 'POST', }), + resetContactPath: (publicKey: string) => + fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, { + method: 'POST', + }), // Channels getChannels: () => fetchJson('/channels'), diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 41e29f1..1ce2899 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,5 +1,6 @@ import type React from 'react'; import { toast } from './ui/sonner'; +import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; import { getMapFocusHash } from '../utils/urlHash'; @@ -87,9 +88,43 @@ export function ChatHeader({ if (contact.last_path_len === -1) { parts.push('flood'); } else if (contact.last_path_len === 0) { - parts.push('direct'); + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + direct + + ); } else if (contact.last_path_len > 0) { - parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`); + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + {contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''} + + ); } if (isValidLocation(contact.lat, contact.lon)) { const distFromUs = diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 1134d62..2996f1f 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -15,6 +15,7 @@ import { Input } from './ui/input'; import { Separator } from './ui/separator'; import { RepeaterLogin } from './RepeaterLogin'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; +import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { isFavorite } from '../utils/favorites'; import { cn } from '@/lib/utils'; @@ -849,9 +850,43 @@ export function RepeaterDashboard({ if (contact.last_path_len === -1) { parts.push('flood'); } else if (contact.last_path_len === 0) { - parts.push('direct'); + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + direct + + ); } else if (contact.last_path_len > 0) { - parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`); + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + {contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''} + + ); } if (isValidLocation(contact.lat, contact.lon)) { const distFromUs = diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index b3a644f..c45e643 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -268,4 +268,105 @@ describe('RepeaterDashboard', () => { expect(screen.getByText('Type a CLI command below...')).toBeInTheDocument(); }); + + describe('path type display and reset', () => { + it('shows flood when last_path_len is -1', () => { + render(); + + expect(screen.getByText('flood')).toBeInTheDocument(); + }); + + it('shows direct when last_path_len is 0', () => { + const directContacts: Contact[] = [ + { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + ]; + + render(); + + expect(screen.getByText('direct')).toBeInTheDocument(); + }); + + it('shows N hops when last_path_len > 0', () => { + const hoppedContacts: Contact[] = [ + { ...contacts[0], last_path_len: 3, last_seen: 1700000000 }, + ]; + + render(); + + expect(screen.getByText('3 hops')).toBeInTheDocument(); + }); + + it('shows 1 hop (singular) for single hop', () => { + const oneHopContacts: Contact[] = [ + { ...contacts[0], last_path_len: 1, last_seen: 1700000000 }, + ]; + + render(); + + expect(screen.getByText('1 hop')).toBeInTheDocument(); + }); + + it('direct path is clickable with reset title', () => { + const directContacts: Contact[] = [ + { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + ]; + + render(); + + const directEl = screen.getByTitle('Click to reset path to flood'); + expect(directEl).toBeInTheDocument(); + expect(directEl.textContent).toBe('direct'); + }); + + it('clicking direct path calls resetContactPath on confirm', async () => { + const directContacts: Contact[] = [ + { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + ]; + + // Mock window.confirm to return true + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + // Mock the api module + const { api } = await import('../api'); + const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({ + status: 'ok', + public_key: REPEATER_KEY, + }); + + render(); + + fireEvent.click(screen.getByTitle('Click to reset path to flood')); + + expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?'); + expect(resetSpy).toHaveBeenCalledWith(REPEATER_KEY); + + confirmSpy.mockRestore(); + resetSpy.mockRestore(); + }); + + it('clicking path does not call API when confirm is cancelled', async () => { + const directContacts: Contact[] = [ + { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + ]; + + // Mock window.confirm to return false + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + const { api } = await import('../api'); + const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({ + status: 'ok', + public_key: REPEATER_KEY, + }); + + render(); + + fireEvent.click(screen.getByTitle('Click to reset path to flood')); + + expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?'); + expect(resetSpy).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + resetSpy.mockRestore(); + }); + }); }); diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 01eb8c2..c3b23d2 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -605,6 +605,78 @@ class TestCreateContactWithHistorical: mock_start.assert_not_awaited() +class TestResetPath: + """Test POST /api/contacts/{public_key}/reset-path.""" + + @pytest.mark.asyncio + async def test_reset_path_to_flood(self, test_db, client): + """Happy path: resets path to flood and returns ok.""" + await _insert_contact(KEY_A, last_path="1122", last_path_len=1) + + with ( + patch("app.routers.contacts.radio_manager") as mock_rm, + patch("app.websocket.broadcast_event"), + ): + mock_rm.is_connected = False + response = await client.post(f"/api/contacts/{KEY_A}/reset-path") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["public_key"] == KEY_A + + # Verify path was reset in DB + contact = await ContactRepository.get_by_key(KEY_A) + assert contact.last_path == "" + assert contact.last_path_len == -1 + + @pytest.mark.asyncio + async def test_reset_path_not_found(self, test_db, client): + response = await client.post(f"/api/contacts/{KEY_A}/reset-path") + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_reset_path_pushes_to_radio(self, test_db, client): + """When radio connected and contact on_radio, pushes updated path.""" + await _insert_contact(KEY_A, on_radio=True, last_path="1122", last_path_len=1) + + mock_mc = MagicMock() + mock_result = MagicMock() + mock_result.type = EventType.OK + mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) + + with ( + patch("app.routers.contacts.radio_manager") as mock_rm, + patch("app.websocket.broadcast_event"), + ): + mock_rm.is_connected = True + mock_rm.radio_operation = _noop_radio_operation(mock_mc) + response = await client.post(f"/api/contacts/{KEY_A}/reset-path") + + assert response.status_code == 200 + mock_mc.commands.add_contact.assert_called_once() + + @pytest.mark.asyncio + async def test_reset_path_broadcasts_websocket_event(self, test_db, client): + """After resetting, broadcasts updated contact via WebSocket.""" + await _insert_contact(KEY_A, last_path="1122", last_path_len=1) + + with ( + patch("app.routers.contacts.radio_manager") as mock_rm, + patch("app.websocket.broadcast_event") as mock_broadcast, + ): + mock_rm.is_connected = False + response = await client.post(f"/api/contacts/{KEY_A}/reset-path") + + assert response.status_code == 200 + mock_broadcast.assert_called_once() + event_type, event_data = mock_broadcast.call_args[0] + assert event_type == "contact" + assert event_data["public_key"] == KEY_A + assert event_data["last_path_len"] == -1 + + class TestAddRemoveRadio: """Test add-to-radio and remove-from-radio endpoints."""