Make pathing clearable on click

This commit is contained in:
Jack Kingsman
2026-02-28 15:33:50 -08:00
parent 7cad4a98dd
commit 365728be02
6 changed files with 279 additions and 4 deletions

View File

@@ -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}

View File

@@ -134,6 +134,10 @@ export const api = {
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
resetContactPath: (publicKey: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, {
method: 'POST',
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),

View File

@@ -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(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`);
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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' : ''}
</span>
);
}
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =

View File

@@ -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(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`);
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
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' : ''}
</span>
);
}
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =

View File

@@ -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(<RepeaterDashboard {...defaultProps} />);
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(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
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(<RepeaterDashboard {...defaultProps} contacts={hoppedContacts} />);
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(<RepeaterDashboard {...defaultProps} contacts={oneHopContacts} />);
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(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
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(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
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(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
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();
});
});
});

View File

@@ -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."""