Post-merge cleanup, AGENTS.md work, unused endpoints, etc.

This commit is contained in:
Jack Kingsman
2026-02-27 14:36:43 -08:00
parent b3606169fe
commit 66cbf98b74
8 changed files with 35 additions and 72 deletions

View File

@@ -110,13 +110,13 @@ Raw packet handling uses two identities by design:
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
## Repeater Advert Path Memory
## Contact Advert Path Memory
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per repeater in a dedicated table (`repeater_advert_paths`).
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
- This is independent of raw-packet payload deduplication.
- Paths are keyed per repeater + path, with `heard_count`, `first_seen`, and `last_seen`.
- Only the N most recent unique paths are retained per repeater (currently 10).
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
- Only the N most recent unique paths are retained per contact (currently 10).
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
## Data Flow
@@ -288,7 +288,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{key}/repeater/radio-settings` | Fetch radio settings via CLI |
| POST | `/api/contacts/{key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{key}/repeater/owner-info` | Fetch owner info |
| POST | `/api/contacts/{key}/repeater/clock` | Fetch repeater clock |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}` | Get channel by key |
| POST | `/api/channels` | Create channel |

View File

@@ -134,7 +134,6 @@ app/
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`
- `POST /contacts/{public_key}/repeater/clock`
### Channels
- `GET /channels`

View File

@@ -276,12 +276,6 @@ class RepeaterOwnerInfoResponse(BaseModel):
guest_password: str | None = Field(default=None, description="Guest password")
class RepeaterClockResponse(BaseModel):
"""Clock output from a repeater."""
clock_output: str | None = Field(default=None, description="Output from 'clock' command")
class LppSensor(BaseModel):
"""A single CayenneLPP sensor reading from req_telemetry_sync."""

View File

@@ -24,7 +24,6 @@ from app.models import (
NeighborInfo,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterClockResponse,
RepeaterLoginRequest,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
@@ -481,21 +480,6 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
return RepeaterOwnerInfoResponse(**results)
@router.post("/{public_key}/repeater/clock", response_model=RepeaterClockResponse)
async def repeater_clock(public_key: str) -> RepeaterClockResponse:
"""Fetch clock output from a repeater."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
results = await _batch_cli_fetch(
contact,
"repeater_clock",
[("clock", "clock_output")],
)
return RepeaterClockResponse(**results)
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),

View File

@@ -48,7 +48,10 @@ frontend/src/
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
── lastViewedConversation.ts # localStorage for last-viewed conversation
── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
│ └── radioPresets.ts # LoRa radio preset configurations
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
@@ -69,6 +72,7 @@ frontend/src/
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── RepeaterDashboard.tsx # Repeater pane-based dashboard (telemetry, neighbors, ACL, etc.)
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
@@ -86,7 +90,10 @@ frontend/src/
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterMode.test.ts
├── repeaterFormatters.test.ts
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── localLabel.test.ts
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── unreadCounts.test.ts
@@ -200,13 +207,30 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `last_advert_time`
- `bots`
## Contact Info Pane
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/{key}/detail`:
- Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map)
- Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
## Repeater Dashboard
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info, Clock — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.

View File

@@ -178,7 +178,7 @@ When only a 1-byte prefix is known (from packet path bytes), the node is marked
**Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to.
**Solution:** The backend tracks recent unique advertisement paths per repeater in `repeater_advert_paths` (see root `AGENTS.md` § "Repeater Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix.
**Solution:** The backend tracks recent unique advertisement paths per contact in `contact_advert_paths` (see root `AGENTS.md` § "Contact Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix.
**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires:

View File

@@ -76,6 +76,7 @@ async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **over
"last_seen": None,
"on_radio": on_radio,
"last_contacted": None,
"first_seen": None,
}
data.update(overrides)
await ContactRepository.upsert(data)

View File

@@ -15,7 +15,6 @@ from app.routers.contacts import (
_fetch_repeater_response,
repeater_acl,
repeater_advert_intervals,
repeater_clock,
repeater_login,
repeater_lpp_telemetry,
repeater_neighbors,
@@ -84,6 +83,7 @@ async def _insert_contact(public_key: str, name: str = "Node", contact_type: int
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
@@ -1057,45 +1057,6 @@ class TestRepeaterOwnerInfo:
assert response.guest_password is None
class TestRepeaterClock:
@pytest.mark.asyncio
async def test_success(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": KEY_A[:12], "text": "2026-02-25 12:00:00 UTC", "txt_type": 1},
)
)
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await repeater_clock(KEY_A)
assert response.clock_output == "2026-02-25 12:00:00 UTC"
@pytest.mark.asyncio
async def test_timeout_returns_none(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_clock(KEY_A)
assert response.clock_output is None
def _make_contact(
public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2
) -> Contact: