Repeater UI overhaul

This commit is contained in:
Jack Kingsman
2026-02-25 21:02:38 -08:00
parent f4a383082e
commit 26fbfcd015
26 changed files with 3622 additions and 1305 deletions

View File

@@ -278,9 +278,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{key}/add-to-radio` | Push contact to radio |
| POST | `/api/contacts/{key}/remove-from-radio` | Remove contact from radio |
| POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{key}/trace` | Trace route to contact |
| POST | `/api/contacts/{key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
| POST | `/api/contacts/{key}/repeater/neighbors` | Fetch repeater neighbors |
| POST | `/api/contacts/{key}/repeater/acl` | Fetch repeater ACL |
| 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

@@ -124,9 +124,17 @@ app/
- `POST /contacts/{public_key}/add-to-radio`
- `POST /contacts/{public_key}/remove-from-radio`
- `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/telemetry`
- `POST /contacts/{public_key}/command`
- `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/repeater/login`
- `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
- `POST /contacts/{public_key}/repeater/neighbors`
- `POST /contacts/{public_key}/repeater/acl`
- `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`
@@ -237,7 +245,7 @@ tests/
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
├── test_radio_sync.py # Polling, sync, advertisement
├── test_repeater_routes.py # Repeater command/telemetry/trace
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
├── test_repository.py # Data access layer
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation

View File

@@ -211,12 +211,93 @@ class SendChannelMessageRequest(SendMessageRequest):
channel_key: str = Field(description="Channel key (32-char hex)")
class TelemetryRequest(BaseModel):
class RepeaterLoginRequest(BaseModel):
"""Request to log in to a repeater."""
password: str = Field(
default="", description="Repeater password (empty string for no password)"
default="", description="Repeater password (empty string for guest login)"
)
class RepeaterLoginResponse(BaseModel):
"""Response from repeater login."""
status: str = Field(description="Login result status")
class RepeaterStatusResponse(BaseModel):
"""Status telemetry from a repeater (single attempt, no retries)."""
battery_volts: float = Field(description="Battery voltage in volts")
tx_queue_len: int = Field(description="Transmit queue length")
noise_floor_dbm: int = Field(description="Noise floor in dBm")
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
last_snr_db: float = Field(description="Last SNR in dB")
packets_received: int = Field(description="Total packets received")
packets_sent: int = Field(description="Total packets sent")
airtime_seconds: int = Field(description="TX airtime in seconds")
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
uptime_seconds: int = Field(description="Uptime in seconds")
sent_flood: int = Field(description="Flood packets sent")
sent_direct: int = Field(description="Direct packets sent")
recv_flood: int = Field(description="Flood packets received")
recv_direct: int = Field(description="Direct packets received")
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
class RepeaterRadioSettingsResponse(BaseModel):
"""Radio settings from a repeater (batch CLI get commands)."""
firmware_version: str | None = Field(default=None, description="Firmware version string")
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
tx_power: str | None = Field(default=None, description="TX power in dBm")
airtime_factor: str | None = Field(default=None, description="Airtime factor")
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
flood_max: str | None = Field(default=None, description="Max flood hops")
name: str | None = Field(default=None, description="Repeater name")
lat: str | None = Field(default=None, description="Latitude")
lon: str | None = Field(default=None, description="Longitude")
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
class RepeaterAdvertIntervalsResponse(BaseModel):
"""Advertisement intervals from a repeater."""
advert_interval: str | None = Field(default=None, description="Local advert interval")
flood_advert_interval: str | None = Field(default=None, description="Flood advert interval")
class RepeaterOwnerInfoResponse(BaseModel):
"""Owner info and guest password from a repeater."""
owner_info: str | None = Field(default=None, description="Owner info string")
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."""
channel: int = Field(description="LPP channel number")
type_name: str = Field(description="Sensor type name (e.g. temperature, humidity)")
value: float | dict = Field(
description="Scalar value or dict for multi-value sensors (GPS, accel)"
)
class RepeaterLppTelemetryResponse(BaseModel):
"""CayenneLPP sensor telemetry from a repeater."""
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
class NeighborInfo(BaseModel):
"""Information about a neighbor seen by a repeater."""
@@ -237,34 +318,18 @@ class AclEntry(BaseModel):
permission_name: str = Field(description="Human-readable permission name")
class TelemetryResponse(BaseModel):
"""Telemetry data from a repeater, formatted for human readability."""
class RepeaterNeighborsResponse(BaseModel):
"""Neighbors list from a repeater."""
pubkey_prefix: str = Field(description="12-char public key prefix")
battery_volts: float = Field(description="Battery voltage in volts")
tx_queue_len: int = Field(description="Transmit queue length")
noise_floor_dbm: int = Field(description="Noise floor in dBm")
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
last_snr_db: float = Field(description="Last SNR in dB")
packets_received: int = Field(description="Total packets received")
packets_sent: int = Field(description="Total packets sent")
airtime_seconds: int = Field(description="TX airtime in seconds")
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
uptime_seconds: int = Field(description="Uptime in seconds")
sent_flood: int = Field(description="Flood packets sent")
sent_direct: int = Field(description="Direct packets sent")
recv_flood: int = Field(description="Flood packets received")
recv_direct: int = Field(description="Direct packets received")
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
neighbors: list[NeighborInfo] = Field(
default_factory=list, description="List of neighbors seen by repeater"
)
class RepeaterAclResponse(BaseModel):
"""ACL list from a repeater."""
acl: list[AclEntry] = Field(default_factory=list, description="Access control list")
clock_output: str | None = Field(
default=None, description="Output from 'clock' command (or error message)"
)
class TraceResponse(BaseModel):

View File

@@ -19,10 +19,19 @@ from app.models import (
ContactAdvertPathSummary,
ContactDetail,
CreateContactRequest,
LppSensor,
NearestRepeater,
NeighborInfo,
TelemetryRequest,
TelemetryResponse,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterClockResponse,
RepeaterLoginRequest,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
@@ -63,6 +72,14 @@ def _monotonic() -> float:
return time.monotonic()
def _extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
async def _fetch_repeater_response(
mc,
target_pubkey_prefix: str,
@@ -167,17 +184,7 @@ async def prepare_repeater_connection(mc, contact: Contact, password: str) -> No
"""
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR:
logger.warning(
"Failed to add repeater %s to radio: %s — continuing anyway",
contact.public_key[:12],
add_result.payload,
)
broadcast_error(
"Failed to add repeater contact to radio, attempting to continue",
str(add_result.payload),
)
await _ensure_on_radio(mc, contact)
# Send login with password
logger.info("Sending login to repeater %s", contact.public_key[:12])
@@ -191,6 +198,305 @@ async def prepare_repeater_connection(mc, contact: Contact, password: str) -> No
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
def _require_repeater(contact: Contact) -> None:
"""Raise 400 if contact is not a repeater."""
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
)
async def _ensure_on_radio(mc, contact: Contact) -> None:
"""Add a contact to the radio for routing, raising 500 on failure."""
add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
)
# ---------------------------------------------------------------------------
# Granular repeater endpoints — one attempt, no server-side retries.
# Frontend manages retry logic for better UX control.
# ---------------------------------------------------------------------------
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Log in to a repeater. Adds contact to radio, sends login, waits for key exchange."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"repeater_login",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await prepare_repeater_connection(mc, contact, request.password)
return RepeaterLoginResponse(status="ok")
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"repeater_status", pause_polling=True, suspend_auto_fetch=True
) as mc:
# Ensure contact is on radio for routing
await _ensure_on_radio(mc, contact)
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
)
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"repeater_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from repeater")
sensors: list[LppSensor] = []
for entry in telemetry:
channel = entry.get("channel", 0)
type_name = str(entry.get("type", "unknown"))
value = entry.get("value", 0)
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
return RepeaterLppTelemetryResponse(sensors=sensors)
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"repeater_neighbors", pause_polling=True, suspend_auto_fetch=True
) as mc:
# Ensure contact is on radio for routing
await _ensure_on_radio(mc, contact)
neighbors_data = await mc.commands.fetch_all_neighbours(
contact.public_key, timeout=10, min_timeout=5
)
neighbors: list[NeighborInfo] = []
if neighbors_data and "neighbours" in neighbors_data:
for n in neighbors_data["neighbours"]:
pubkey_prefix = n.get("pubkey", "")
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
neighbors.append(
NeighborInfo(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
snr=n.get("snr", 0.0),
last_heard_seconds=n.get("secs_ago", 0),
)
)
return RepeaterNeighborsResponse(neighbors=neighbors)
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"repeater_acl", pause_polling=True, suspend_auto_fetch=True
) as mc:
# Ensure contact is on radio for routing
await _ensure_on_radio(mc, contact)
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
acl_entries: list[AclEntry] = []
if acl_data and isinstance(acl_data, list):
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(
AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
)
)
return RepeaterAclResponse(acl=acl_entries)
async def _batch_cli_fetch(
contact: Contact,
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a repeater and collect responses.
Opens a radio operation with polling paused and auto-fetch suspended (since
we call get_msg() directly via _fetch_repeater_response), adds the contact
to the radio for routing, then sends each command sequentially with a 1-second
gap between them.
Returns a dict mapping field names to response strings (or None on timeout).
"""
results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for i, (cmd, field) in enumerate(commands):
if i > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await _fetch_repeater_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = _extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
"""Fetch radio settings from a repeater via batch CLI commands."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
results = await _batch_cli_fetch(
contact,
"repeater_radio_settings",
[
("ver", "firmware_version"),
("get radio", "radio"),
("get tx", "tx_power"),
("get af", "airtime_factor"),
("get repeat", "repeat_enabled"),
("get flood.max", "flood_max"),
("get name", "name"),
("get lat", "lat"),
("get lon", "lon"),
("clock", "clock_utc"),
],
)
return RepeaterRadioSettingsResponse(**results)
@router.post(
"/{public_key}/repeater/advert-intervals", response_model=RepeaterAdvertIntervalsResponse
)
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
"""Fetch advertisement intervals from a repeater via CLI commands."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
results = await _batch_cli_fetch(
contact,
"repeater_advert_intervals",
[
("get advert.interval", "advert_interval"),
("get flood.advert.interval", "flood_advert_interval"),
],
)
return RepeaterAdvertIntervalsResponse(**results)
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
"""Fetch owner info and guest password from a repeater via CLI commands."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
results = await _batch_cli_fetch(
contact,
"repeater_owner_info",
[
("get owner.info", "owner_info"),
("get guest.password", "guest_password"),
],
)
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),
@@ -501,168 +807,13 @@ async def delete_contact(public_key: str) -> dict:
return {"status": "ok"}
@router.post("/{public_key}/telemetry", response_model=TelemetryResponse)
async def request_telemetry(public_key: str, request: TelemetryRequest) -> TelemetryResponse:
"""Request telemetry from a repeater.
The contact must be a repeater (type=2). If not on the radio, it will be added.
Uses login + status request with retry logic.
"""
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
# Verify it's a repeater
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
)
async with radio_manager.radio_operation(
"request_telemetry",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Prepare connection (add/remove dance + login)
await prepare_repeater_connection(mc, contact, request.password)
# Request status with retries
logger.info("Requesting status from repeater %s", contact.public_key[:12])
status = None
for attempt in range(1, 4):
logger.debug("Status request attempt %d/3", attempt)
status = await mc.commands.req_status_sync(
contact.public_key, timeout=10, min_timeout=5
)
if status:
break
logger.debug("Status request timeout, retrying...")
if not status:
raise HTTPException(
status_code=504, detail="No response from repeater after 3 attempts"
)
logger.info("Received telemetry from %s: %s", contact.public_key[:12], status)
# Fetch neighbors (fetch_all_neighbours handles pagination)
logger.info("Fetching neighbors from repeater %s", contact.public_key[:12])
neighbors_data = None
for attempt in range(1, 4):
logger.debug("Neighbors request attempt %d/3", attempt)
neighbors_data = await mc.commands.fetch_all_neighbours(
contact.public_key, timeout=10, min_timeout=5
)
if neighbors_data:
break
logger.debug("Neighbors request timeout, retrying...")
# Process neighbors - resolve pubkey prefixes to contact names
neighbors: list[NeighborInfo] = []
if neighbors_data and "neighbours" in neighbors_data:
logger.info("Received %d neighbors", len(neighbors_data["neighbours"]))
for n in neighbors_data["neighbours"]:
pubkey_prefix = n.get("pubkey", "")
# Try to resolve to a contact name from our database
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
neighbors.append(
NeighborInfo(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
snr=n.get("snr", 0.0),
last_heard_seconds=n.get("secs_ago", 0),
)
)
# Fetch ACL
logger.info("Fetching ACL from repeater %s", contact.public_key[:12])
acl_data = None
for attempt in range(1, 4):
logger.debug("ACL request attempt %d/3", attempt)
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
if acl_data:
break
logger.debug("ACL request timeout, retrying...")
# Process ACL - resolve pubkey prefixes to contact names
acl_entries: list[AclEntry] = []
if acl_data and isinstance(acl_data, list):
logger.info("Received %d ACL entries", len(acl_data))
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
# Try to resolve to a contact name from our database
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(
AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
)
)
# Fetch clock output (up to 2 attempts)
logger.info("Fetching clock from repeater %s", contact.public_key[:12])
clock_output: str | None = None
for attempt in range(1, 3):
logger.debug("Clock request attempt %d/2", attempt)
send_result = await mc.commands.send_cmd(contact.public_key, "clock")
if send_result.type == EventType.ERROR:
logger.debug("Clock command send error: %s", send_result.payload)
continue
response_event = await _fetch_repeater_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is None:
logger.debug("Clock request timeout, retrying...")
continue
clock_output = response_event.payload.get("text", "")
logger.info("Received clock output: %s", clock_output)
break
if clock_output is None:
clock_output = "Unable to fetch `clock` output (repeater did not respond)"
# Convert raw telemetry to response format
# bat is in mV, convert to V (e.g., 3775 -> 3.775)
return TelemetryResponse(
pubkey_prefix=status.get("pubkey_pre", contact.public_key[:12]),
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
neighbors=neighbors,
acl=acl_entries,
clock_output=clock_output,
)
@router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater.
The contact must be a repeater (type=2). The user must have already logged in
via the telemetry endpoint. This endpoint ensures the contact is on the radio
before sending commands (the repeater remembers ACL permissions after login).
via the repeater/login endpoint. This endpoint ensures the contact is on the
radio before sending commands (the repeater remembers ACL permissions after login).
Common commands:
- get name, set name <value>
@@ -678,13 +829,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
# Verify it's a repeater
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
)
_require_repeater(contact)
async with radio_manager.radio_operation(
"send_repeater_command",
@@ -693,17 +838,8 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
) as mc:
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR:
logger.warning(
"Failed to add repeater %s to radio: %s — continuing anyway",
contact.public_key[:12],
add_result.payload,
)
broadcast_error(
"Failed to add repeater contact to radio, attempting to continue",
str(add_result.payload),
)
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
@@ -730,7 +866,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
)
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
response_text = response_event.payload.get("text", str(response_event.payload))
response_text = _extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
@@ -763,18 +899,8 @@ async def request_trace(public_key: str) -> TraceResponse:
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
# from the reader loop, not via get_msg().
async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc:
# Ensure contact is on radio so the trace can reach them (non-fatal)
add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR:
logger.warning(
"Failed to add contact %s to radio for trace: %s — continuing anyway",
contact.public_key[:12],
add_result.payload,
)
broadcast_error(
"Failed to add contact to radio for trace, attempting to continue",
str(add_result.payload),
)
# Ensure contact is on radio so the trace can reach them
await _ensure_on_radio(mc, contact)
logger.info(
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash

View File

@@ -33,8 +33,7 @@ frontend/src/
│ ├── index.ts # Central re-export of all hooks
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRepeaterMode.ts # Repeater login/command workflow
│ ├── useAirtimeTracking.ts # Repeater airtime stats polling
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
@@ -68,6 +67,8 @@ frontend/src/
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── 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)
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
@@ -75,7 +76,6 @@ frontend/src/
├── setup.ts
├── fixtures/websocket_events.json
├── api.test.ts
├── useAirtimeTracking.test.ts
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
@@ -85,6 +85,7 @@ frontend/src/
├── pathUtils.test.ts
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterMode.test.ts
├── settingsModal.test.tsx
├── sidebar.test.tsx
@@ -92,7 +93,7 @@ frontend/src/
├── urlHash.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useRepeaterMode.test.ts
├── useRepeaterDashboard.test.ts
├── useWebSocket.dispatch.test.ts
└── useWebSocket.lifecycle.test.ts
```
@@ -108,12 +109,7 @@ frontend/src/
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationMessages`: fetch, pagination, dedup/update helpers
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useRepeaterMode`: repeater login/command workflow
- `useAirtimeTracking`: repeater airtime stats polling
### Local message IDs
`useRepeaterMode` and `useAirtimeTracking` each have a `createLocalMessage` that generates ephemeral (non-persisted) message IDs via `-Date.now()`. Both hooks write to the same `setMessages`, so a same-millisecond call would produce duplicate IDs. In practice this requires a repeater command response and an airtime poll to land in the exact same ms — staggeringly unlikely and cosmetic-only (React duplicate key warning). Not worth fixing.
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
### Initial load + realtime
@@ -204,13 +200,19 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `last_advert_time`
- `bots`
## Repeater Mode
## Repeater Dashboard
For repeater contacts (`type=2`):
1. Telemetry/login phase (`POST /api/contacts/{key}/telemetry`)
2. Command phase (`POST /api/contacts/{key}/command`)
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
CLI responses are rendered as local-only messages (not persisted to DB).
**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).
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
**Console pane**: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB).
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Styling

View File

@@ -12,7 +12,6 @@ import { api } from './api';
import { takePrefetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
useRepeaterMode,
useUnreadCounts,
useConversationMessages,
getMessageContentKey,
@@ -35,8 +34,12 @@ import {
} from './components/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { CONTACT_TYPE_REPEATER } from './types';
// Lazy-load heavy components to reduce initial bundle
const RepeaterDashboard = lazy(() =>
import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
);
const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
const VisualizerView = lazy(() =>
import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView }))
@@ -167,7 +170,6 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
setMessages,
fetchOlderMessages,
addMessageIfNew,
updateMessageAck,
@@ -182,12 +184,12 @@ export function App() {
trackNewMessage,
} = useUnreadCounts(channels, contacts, activeConversation);
const {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
} = useRepeaterMode(activeConversation, contacts, setMessages, activeConversationRef);
// Determine if active contact is a repeater (used for routing to dashboard)
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(
@@ -562,6 +564,27 @@ export function App() {
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading dashboard...
</div>
}
>
<RepeaterDashboard
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onDeleteContact={handleDeleteContact}
/>
</Suspense>
) : (
<>
<ChatHeader
@@ -595,25 +618,14 @@ export function App() {
/>
<MessageInput
ref={messageInputRef}
onSend={
activeContactIsRepeater
? repeaterLoggedIn
? handleRepeaterCommand
: handleTelemetryRequest
: handleSendMessage
}
onSend={handleSendMessage}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
: `Message ${activeConversation.name}...`
}
/>
</>

View File

@@ -15,8 +15,15 @@ import type {
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
StatisticsResponse,
TelemetryResponse,
TraceResponse,
UnreadCounts,
} from './types';
@@ -118,11 +125,6 @@ export const api = {
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
method: 'POST',
}),
requestTelemetry: (publicKey: string, password: string) =>
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
sendRepeaterCommand: (publicKey: string, command: string) =>
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
method: 'POST',
@@ -240,4 +242,39 @@ export const api = {
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
// Granular repeater endpoints
repeaterLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/repeater/login`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
repeaterStatus: (publicKey: string) =>
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
}),
repeaterAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
method: 'POST',
}),
repeaterRadioSettings: (publicKey: string) =>
fetchJson<RepeaterRadioSettingsResponse>(`/contacts/${publicKey}/repeater/radio-settings`, {
method: 'POST',
}),
repeaterAdvertIntervals: (publicKey: string) =>
fetchJson<RepeaterAdvertIntervalsResponse>(`/contacts/${publicKey}/repeater/advert-intervals`, {
method: 'POST',
}),
repeaterOwnerInfo: (publicKey: string) =>
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
method: 'POST',
}),
repeaterLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
};

View File

@@ -33,8 +33,6 @@ interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
/** Conversation type for character limit calculation */
conversationType?: 'contact' | 'channel' | 'raw';
/** Sender name (radio name) for channel message limit calculation */
@@ -48,7 +46,7 @@ export interface MessageInputHandle {
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName },
{ onSend, disabled, placeholder, conversationType, senderName },
ref
) {
const [text, setText] = useState('');
@@ -112,45 +110,25 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
// For repeater mode, empty password means guest login
if (isRepeaterMode) {
if (sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to request telemetry:', err);
toast.error('Failed to request telemetry', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input (now in CLI command mode)
setTimeout(() => inputRef.current?.focus(), 0);
} else {
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
},
[text, sending, disabled, onSend, isRepeaterMode]
[text, sending, disabled, onSend]
);
const handleKeyDown = useCallback(
@@ -163,12 +141,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
[handleSubmit]
);
// For repeater mode, always allow submit (empty = guest login)
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
const canSubmit = text.trim().length > 0;
// Show counter for messages (not repeater mode or raw).
// Show counter for messages (not raw).
// Desktop: always visible. Mobile: only show count after 100 characters.
const showCharCounter = !isRepeaterMode && limits !== null;
const showCharCounter = limits !== null;
const showMobileCounterValue = text.length > 100;
return (
@@ -180,7 +157,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
<div className="flex gap-2">
<Input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
type="text"
autoComplete="off"
name="chat-message-input"
data-lpignore="true"
@@ -189,10 +166,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder ||
(isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...')
}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
@@ -201,15 +175,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
disabled={disabled || sending || !canSubmit}
className="flex-shrink-0"
>
{sending
? isRepeaterMode
? 'Logging in...'
: 'Sending...'
: isRepeaterMode
? text.trim()
? 'Log in with password'
: 'Log in as guest/use repeater ACLs'
: 'Send'}
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
{showCharCounter && (

View File

@@ -0,0 +1,98 @@
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
interface Neighbor {
lat: number | null;
lon: number | null;
name: string | null;
pubkey_prefix: string;
snr: number;
}
interface Props {
neighbors: Neighbor[];
radioLat?: number | null;
radioLon?: number | null;
radioName?: string | null;
}
export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: Props) {
const valid = neighbors.filter(
(n): n is Neighbor & { lat: number; lon: number } => n.lat != null && n.lon != null
);
const hasRadio = radioLat != null && radioLon != null && !(radioLat === 0 && radioLon === 0);
if (valid.length === 0 && !hasRadio) return null;
// Center on radio if available, otherwise first neighbor
const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon];
return (
<div className="min-h-48 flex-1 rounded border border-border overflow-hidden">
<MapContainer
center={center}
zoom={10}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Dotted lines from radio to each neighbor */}
{hasRadio &&
valid.map((n, i) => (
<Polyline
key={`line-${i}`}
positions={[
[radioLat!, radioLon!],
[n.lat, n.lon],
]}
pathOptions={{
color: '#3b82f6',
weight: 1.5,
opacity: 0.5,
dashArray: '6 4',
}}
/>
))}
{/* Radio node (bright blue) */}
{hasRadio && (
<CircleMarker
center={[radioLat!, radioLon!]}
radius={8}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm font-medium">{radioName || 'Our Radio'}</span>
</Popup>
</CircleMarker>
)}
{/* Neighbor nodes (SNR-colored) */}
{valid.map((n, i) => (
<CircleMarker
key={i}
center={[n.lat, n.lon]}
radius={6}
pathOptions={{
color: '#000',
fillColor: n.snr >= 6 ? '#22c55e' : n.snr >= 0 ? '#eab308' : '#ef4444',
fillOpacity: 0.8,
weight: 1,
}}
>
<Popup>
<span className="text-sm">{n.name || n.pubkey_prefix}</span>
</Popup>
</CircleMarker>
))}
</MapContainer>
</div>
);
}

View File

@@ -0,0 +1,971 @@
import {
useState,
useCallback,
useRef,
useEffect,
useMemo,
type FormEvent,
lazy,
Suspense,
} from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Separator } from './ui/separator';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { cn } from '@/lib/utils';
import type {
Contact,
Conversation,
Favorite,
LppSensor,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
NeighborInfo,
} from '../types';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
// Lazy-load the entire mini-map file so react-leaflet imports are bundled together
// and MapContainer only mounts once (avoids "already initialized" crash).
const NeighborsMiniMap = lazy(() =>
import('./NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
);
// --- Shared Icons ---
function RefreshIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}
// --- Utility ---
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
return `${mins}m`;
}
// --- Generic Pane Wrapper ---
function RepeaterPane({
title,
state,
onRefresh,
disabled,
children,
className,
contentClassName,
}: {
title: string;
state: PaneState;
onRefresh?: () => void;
disabled?: boolean;
children: React.ReactNode;
className?: string;
contentClassName?: string;
}) {
return (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh"
>
<RefreshIcon
className={cn(
'w-3.5 h-3.5',
state.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
)}
</div>
{state.error && (
<div className="px-3 py-1.5 text-xs text-destructive bg-destructive/5 border-b border-border">
{state.error}
</div>
)}
<div className={cn('p-3', contentClassName)}>
{state.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
</p>
) : (
children
)}
</div>
</div>
);
}
function NotFetched() {
return <p className="text-sm text-muted-foreground italic">&lt;not fetched&gt;</p>;
}
function KvRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between items-center text-sm py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
// --- Individual Panes ---
function TelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterStatusResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Telemetry" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="space-y-2">
<KvRow label="Battery" value={`${data.battery_volts.toFixed(3)}V`} />
<KvRow label="Uptime" value={formatDuration(data.uptime_seconds)} />
<KvRow label="TX Airtime" value={formatDuration(data.airtime_seconds)} />
<KvRow label="RX Airtime" value={formatDuration(data.rx_airtime_seconds)} />
<Separator className="my-1" />
<KvRow label="Noise Floor" value={`${data.noise_floor_dbm} dBm`} />
<KvRow label="Last RSSI" value={`${data.last_rssi_dbm} dBm`} />
<KvRow label="Last SNR" value={`${data.last_snr_db.toFixed(1)} dB`} />
<Separator className="my-1" />
<KvRow
label="Packets"
value={`${data.packets_received.toLocaleString()} rx / ${data.packets_sent.toLocaleString()} tx`}
/>
<KvRow
label="Flood"
value={`${data.recv_flood.toLocaleString()} rx / ${data.sent_flood.toLocaleString()} tx`}
/>
<KvRow
label="Direct"
value={`${data.recv_direct.toLocaleString()} rx / ${data.sent_direct.toLocaleString()} tx`}
/>
<KvRow
label="Duplicates"
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/>
<Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} />
<KvRow label="Debug Flags" value={data.full_events} />
</div>
)}
</RepeaterPane>
);
}
function NeighborsPane({
data,
state,
onRefresh,
disabled,
contacts,
radioLat,
radioLon,
radioName,
}: {
data: RepeaterNeighborsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
contacts: Contact[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
}) {
// Resolve contact data for each neighbor in a single pass — used for
// coords (mini-map), distances (table column), and sorted display order.
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
if (!data) {
return {
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
hasDistances: false,
};
}
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
let anyDist = false;
for (const n of data.neighbors) {
const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix));
const nLat = contact?.lat ?? null;
const nLon = contact?.lon ?? null;
let dist: string | null = null;
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
anyDist = true;
}
}
enriched.push({ ...n, distance: dist });
if (isValidLocation(nLat, nLon)) {
withCoords.push({ ...n, lat: nLat, lon: nLon });
}
}
enriched.sort((a, b) => b.snr - a.snr);
return {
neighborsWithCoords: withCoords,
sorted: enriched,
hasDistances: anyDist,
};
}, [data, contacts, radioLat, radioLon]);
return (
<RepeaterPane
title="Neighbors"
state={state}
onRefresh={onRefresh}
disabled={disabled}
className="flex flex-col"
contentClassName="flex-1 flex flex-col"
>
{!data ? (
<NotFetched />
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">No neighbors reported</p>
) : (
<div className="flex-1 flex flex-col gap-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">SNR</th>
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
<th className="pb-1 font-medium text-right">Last Heard</th>
</tr>
</thead>
<tbody>
{sorted.map((n, i) => {
const dist = n.distance;
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
const snrColor =
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
return (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>
<td className={cn('py-1 text-right font-mono', snrColor)}>{snrStr} dB</td>
{hasDistances && (
<td className="py-1 text-right text-muted-foreground font-mono">
{dist ?? '—'}
</td>
)}
<td className="py-1 text-right text-muted-foreground">
{formatDuration(n.last_heard_seconds)} ago
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
Loading map...
</div>
}
>
<NeighborsMiniMap
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
neighbors={neighborsWithCoords}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</Suspense>
)}
</div>
)}
</RepeaterPane>
);
}
function AclPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterAclResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const permColor: Record<number, string> = {
0: 'bg-muted text-muted-foreground',
1: 'bg-blue-500/10 text-blue-500',
2: 'bg-green-500/10 text-green-500',
3: 'bg-amber-500/10 text-amber-500',
};
return (
<RepeaterPane title="ACL" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.acl.length === 0 ? (
<p className="text-sm text-muted-foreground">No ACL entries</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">Permission</th>
</tr>
</thead>
<tbody>
{data.acl.map((entry, i) => (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{entry.name || entry.pubkey_prefix}</td>
<td className="py-1 text-right">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
permColor[entry.permission] ?? 'bg-muted text-muted-foreground'
)}
>
{entry.permission_name}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</RepeaterPane>
);
}
export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
// Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
// Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
let parsed: Date;
const fwMatch = clockUtc.match(
/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/
);
if (fwMatch) {
const [, hh, mm, ss, dd, mo, yyyy] = fwMatch;
parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0)));
} else {
parsed = new Date(
clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z')
);
}
if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
const driftMs = Math.abs(Date.now() - parsed.getTime());
const driftSec = Math.floor(driftMs / 1000);
if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
const h = Math.floor(driftSec / 3600);
const m = Math.floor((driftSec % 3600) / 60);
const s = driftSec % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return { text: parts.join(''), isLarge: false };
}
function RadioSettingsPane({
data,
state,
onRefresh,
disabled,
advertData,
advertState,
onRefreshAdvert,
}: {
data: RepeaterRadioSettingsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
advertData: RepeaterAdvertIntervalsResponse | null;
advertState: PaneState;
onRefreshAdvert: () => void;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
return (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
<Separator className="my-1" />
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<Separator className="my-1" />
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}
<Separator className="my-2" />
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-muted-foreground">Advert Intervals</span>
<button
type="button"
onClick={onRefreshAdvert}
disabled={disabled || advertState.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || advertState.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(
'w-3 h-3',
advertState.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
</div>
{advertState.error && <p className="text-xs text-destructive mb-1">{advertState.error}</p>}
{advertState.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
</p>
) : !advertData ? (
<NotFetched />
) : (
<div>
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
<KvRow
label="Flood Advert"
value={formatAdvertInterval(advertData.flood_advert_interval)}
/>
</div>
)}
</RepeaterPane>
);
}
function formatAdvertInterval(val: string | null): string {
if (val == null) return '—';
const trimmed = val.trim();
if (trimmed === '0') return '<disabled>';
return `${trimmed}h`;
}
const LPP_UNIT_MAP: Record<string, string> = {
temperature: '°C',
humidity: '%',
barometer: 'hPa',
voltage: 'V',
current: 'mA',
luminosity: 'lux',
altitude: 'm',
power: 'W',
distance: 'mm',
energy: 'kWh',
direction: '°',
concentration: 'ppm',
colour: '',
};
function formatLppLabel(typeName: string): string {
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
}
function LppSensorRow({ sensor }: { sensor: LppSensor }) {
const label = formatLppLabel(sensor.type_name);
if (typeof sensor.value === 'object' && sensor.value !== null) {
// Multi-value sensor (GPS, accelerometer, etc.)
return (
<div className="py-0.5">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="pl-3">
{Object.entries(sensor.value).map(([k, v]) => (
<KvRow
key={k}
label={k.charAt(0).toUpperCase() + k.slice(1)}
value={typeof v === 'number' ? v.toFixed(2) : String(v)}
/>
))}
</div>
</div>
);
}
const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
const formatted =
typeof sensor.value === 'number'
? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
: String(sensor.value);
return <KvRow label={label} value={formatted} />;
}
function LppTelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterLppTelemetryResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.sensors.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensor data available</p>
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} />
))}
</div>
)}
</RepeaterPane>
);
}
function OwnerInfoPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterOwnerInfoResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Owner Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="break-all">
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
</div>
)}
</RepeaterPane>
);
}
function ActionsPane({
onSendAdvert,
onSyncClock,
onReboot,
consoleLoading,
}: {
onSendAdvert: () => void;
onSyncClock: () => void;
onReboot: () => void;
consoleLoading: boolean;
}) {
const [confirmReboot, setConfirmReboot] = useState(false);
const handleReboot = useCallback(() => {
if (!confirmReboot) {
setConfirmReboot(true);
return;
}
setConfirmReboot(false);
onReboot();
}, [confirmReboot, onReboot]);
// Reset confirmation after 3 seconds
useEffect(() => {
if (!confirmReboot) return;
const timer = setTimeout(() => setConfirmReboot(false), 3000);
return () => clearTimeout(timer);
}, [confirmReboot]);
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Actions</h3>
</div>
<div className="p-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
Send Advert
</Button>
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
Sync Clock
</Button>
<Button
variant={confirmReboot ? 'destructive' : 'outline'}
size="sm"
onClick={handleReboot}
disabled={consoleLoading}
>
{confirmReboot ? 'Confirm Reboot' : 'Reboot'}
</Button>
</div>
</div>
);
}
function ConsolePane({
history,
loading,
onSend,
}: {
history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
loading: boolean;
onSend: (command: string) => Promise<void>;
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new entries
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [history]);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = input.trim();
if (!trimmed || loading) return;
setInput('');
await onSend(trimmed);
},
[input, loading, onSend]
);
return (
<div className="border border-border rounded-lg overflow-hidden col-span-full">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Console</h3>
</div>
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
)}
{history.map((entry, i) =>
entry.outgoing ? (
<div key={i} className="text-green-300">
&gt; {entry.command}
</div>
) : (
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
{entry.response}
</div>
)
)}
{loading && <div className="text-muted-foreground animate-pulse">...</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
type="text"
autoComplete="off"
name="console-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
disabled={loading}
className="flex-1 font-mono text-sm"
/>
<Button type="submit" size="sm" disabled={loading || !input.trim()}>
Send
</Button>
</form>
</div>
);
}
// --- Main Dashboard ---
interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
}
export function RepeaterDashboard({
conversation,
contacts,
favorites,
radioLat,
radioLon,
radioName,
onTrace,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
const {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation);
const contact = contacts.find((c) => c.public_key === conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Loading all panes indicator
const anyLoading = Object.values(paneStates).some((s) => s.loading);
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
}}
title="Click to copy"
>
{conversation.id}
</span>
{contact?.last_seen && (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(Last heard: {formatTime(contact.last_seen)})
</span>
)}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{loggedIn && (
<Button
variant="outline"
size="sm"
onClick={loadAll}
disabled={anyLoading}
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
>
{anyLoading ? 'Loading...' : 'Load All'}
</Button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
onClick={onTrace}
title="Direct Trace"
>
&#x1F6CE;
</button>
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
onClick={() => onToggleFavorite('contact', conversation.id)}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? (
<span className="text-amber-400">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
onClick={() => onDeleteContact(conversation.id)}
title="Delete"
>
&#128465;
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{!loggedIn ? (
<RepeaterLogin
repeaterName={conversation.name}
loading={loginLoading}
error={loginError}
onLogin={login}
onLoginAsGuest={loginAsGuest}
/>
) : (
<div className="space-y-4">
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status')}
disabled={anyLoading}
/>
<RadioSettingsPane
data={paneData.radioSettings}
state={paneStates.radioSettings}
onRefresh={() => refreshPane('radioSettings')}
disabled={anyLoading}
advertData={paneData.advertIntervals}
advertState={paneStates.advertIntervals}
onRefreshAdvert={() => refreshPane('advertIntervals')}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() => refreshPane('lppTelemetry')}
disabled={anyLoading}
/>
</div>
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<div className="flex flex-col gap-4">
<OwnerInfoPane
data={paneData.ownerInfo}
state={paneStates.ownerInfo}
onRefresh={() => refreshPane('ownerInfo')}
disabled={anyLoading}
/>
<ActionsPane
onSendAdvert={sendAdvert}
onSyncClock={syncClock}
onReboot={rebootRepeater}
consoleLoading={consoleLoading}
/>
</div>
</div>
{/* Console — full width */}
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
interface RepeaterLoginProps {
repeaterName: string;
loading: boolean;
error: string | null;
onLogin: (password: string) => Promise<void>;
onLoginAsGuest: () => Promise<void>;
}
export function RepeaterLogin({
repeaterName,
loading,
error,
onLogin,
onLoginAsGuest,
}: RepeaterLoginProps) {
const [password, setPassword] = useState('');
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
await onLogin(password.trim());
},
[password, loading, onLogin]
);
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h2 className="text-lg font-semibold">{repeaterName}</h2>
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<Input
type="password"
autoComplete="off"
name="repeater-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
disabled={loading}
autoFocus
/>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : 'Login with Password'}
</Button>
<Button
type="button"
variant="outline"
disabled={loading}
className="w-full"
onClick={onLoginAsGuest}
>
Login as Guest / ACLs
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
export { useRepeaterMode } from './useRepeaterMode';
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';

View File

@@ -1,281 +0,0 @@
/**
* Airtime/duty cycle tracking for repeaters.
*
* When "dutycycle_start" command is issued, this captures baseline telemetry
* and polls every 5 minutes to display rolling airtime/duty cycle statistics.
*/
import { useRef, useCallback, useEffect } from 'react';
import { api } from '../api';
import type { Message, TelemetryResponse } from '../types';
// Baseline telemetry snapshot for airtime tracking
interface AirtimeBaseline {
startTime: number; // epoch seconds
uptime: number;
txAirtime: number;
rxAirtime: number;
sentFlood: number;
sentDirect: number;
recvFlood: number;
recvDirect: number;
conversationId: string;
}
// Polling interval: 5 minutes
const AIRTIME_POLL_INTERVAL_MS = 5 * 60 * 1000;
// Format duration in XhXmXs format
function formatAirtimeDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}h${mins}m${secs}s`;
}
// Get emoji indicator for TX duty cycle percentage
function getTxDutyCycleEmoji(pct: number): string {
if (pct <= 5) return '✅';
if (pct <= 10) return '🟢';
if (pct <= 25) return '🟡';
if (pct <= 50) return '🔴';
return '🚨';
}
// Format airtime statistics comparing current telemetry to baseline
function formatAirtimeStats(baseline: AirtimeBaseline, current: TelemetryResponse): string {
const now = Math.floor(Date.now() / 1000);
const wallDuration = now - baseline.startTime;
// Compute deltas
const deltaUptime = current.uptime_seconds - baseline.uptime;
const deltaTxAirtime = current.airtime_seconds - baseline.txAirtime;
const deltaRxAirtime = current.rx_airtime_seconds - baseline.rxAirtime;
const deltaSentFlood = current.sent_flood - baseline.sentFlood;
const deltaSentDirect = current.sent_direct - baseline.sentDirect;
const deltaRecvFlood = current.recv_flood - baseline.recvFlood;
const deltaRecvDirect = current.recv_direct - baseline.recvDirect;
// Calculate airtime percentages
const txPct = deltaUptime > 0 ? (deltaTxAirtime / deltaUptime) * 100 : 0;
const rxPct = deltaUptime > 0 ? (deltaRxAirtime / deltaUptime) * 100 : 0;
// Estimate flood/direct airtime breakdown based on packet proportions
const totalSent = deltaSentFlood + deltaSentDirect;
const totalRecv = deltaRecvFlood + deltaRecvDirect;
const txFloodPct = totalSent > 0 ? txPct * (deltaSentFlood / totalSent) : 0;
const txDirectPct = totalSent > 0 ? txPct * (deltaSentDirect / totalSent) : 0;
const rxFloodPct = totalRecv > 0 ? rxPct * (deltaRecvFlood / totalRecv) : 0;
const rxDirectPct = totalRecv > 0 ? rxPct * (deltaRecvDirect / totalRecv) : 0;
const txEmoji = getTxDutyCycleEmoji(txPct);
const idlePct = Math.max(0, 100 - txPct - rxPct);
const lines = [
`Airtime/Duty Cycle Statistics`,
`Duration: ${formatAirtimeDuration(wallDuration)} (uptime delta: ${formatAirtimeDuration(deltaUptime)})`,
``,
`${txEmoji} TX Airtime: ${txPct.toFixed(3)}% (${totalSent.toLocaleString()} pkts)`,
` Flood: ${txFloodPct.toFixed(3)}% (${deltaSentFlood.toLocaleString()} pkts)`,
` Direct: ${txDirectPct.toFixed(3)}% (${deltaSentDirect.toLocaleString()} pkts)`,
``,
`RX Airtime: ${rxPct.toFixed(3)}% (${totalRecv.toLocaleString()} pkts)`,
` Flood: ${rxFloodPct.toFixed(3)}% (${deltaRecvFlood.toLocaleString()} pkts)`,
` Direct: ${rxDirectPct.toFixed(3)}% (${deltaRecvDirect.toLocaleString()} pkts)`,
``,
`Idle: ${idlePct.toFixed(3)}%`,
];
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(conversationKey: string, text: string, outgoing: boolean): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now(),
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
interface UseAirtimeTrackingResult {
/** Returns true if this was an airtime command that was handled */
handleAirtimeCommand: (command: string, conversationId: string) => Promise<boolean>;
/** Stop any active airtime tracking */
stopTracking: () => void;
}
export function useAirtimeTracking(
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
): UseAirtimeTrackingResult {
const baselineRef = useRef<AirtimeBaseline | null>(null);
const intervalRef = useRef<number | null>(null);
// Stop tracking and clear interval
const stopTracking = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
baselineRef.current = null;
}, []);
// Poll for airtime stats with one retry on failure
const pollAirtimeStats = useCallback(async () => {
const baseline = baselineRef.current;
if (!baseline) return;
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
// Try up to 2 times (initial + 1 retry)
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(baseline.conversationId, '');
break; // Success, exit loop
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
// Wait a moment before retry
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
// If tracking was stopped while the request was in-flight (e.g. conversation
// switch called stopTracking), discard the stale response.
if (!baselineRef.current) return;
if (telemetry) {
const statsMessage = createLocalMessage(
baseline.conversationId,
formatAirtimeStats(baseline, telemetry),
false
);
setMessages((prev) => [...prev, statsMessage]);
} else {
const errorMessage = createLocalMessage(
baseline.conversationId,
`Duty cycle poll failed after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
}
}, [setMessages]);
// Handle airtime commands
const handleAirtimeCommand = useCallback(
async (command: string, conversationId: string): Promise<boolean> => {
const cmd = command.trim().toLowerCase();
if (cmd === 'dutycycle_start') {
// Stop any existing tracking
stopTracking();
// Fetch initial telemetry with one retry
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(conversationId, '');
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
if (!telemetry) {
const errorMessage = createLocalMessage(
conversationId,
`Failed to start duty cycle tracking after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
return true;
}
// Store baseline
const now = Math.floor(Date.now() / 1000);
baselineRef.current = {
startTime: now,
uptime: telemetry.uptime_seconds,
txAirtime: telemetry.airtime_seconds,
rxAirtime: telemetry.rx_airtime_seconds,
sentFlood: telemetry.sent_flood,
sentDirect: telemetry.sent_direct,
recvFlood: telemetry.recv_flood,
recvDirect: telemetry.recv_direct,
conversationId,
};
// Add start message
const startMessage = createLocalMessage(
conversationId,
`Airtime/duty cycle statistics gathering begins at ${now}. Logs will follow every 5 minutes. To stop, run dutycycle_stop or navigate away from this conversation.`,
false
);
setMessages((prev) => [...prev, startMessage]);
// Start polling interval
intervalRef.current = window.setInterval(pollAirtimeStats, AIRTIME_POLL_INTERVAL_MS);
return true;
}
if (cmd === 'dutycycle_stop') {
if (baselineRef.current && baselineRef.current.conversationId === conversationId) {
// Do one final poll before stopping
await pollAirtimeStats();
stopTracking();
const stopMessage = createLocalMessage(
conversationId,
'Airtime/duty cycle statistics gathering stopped.',
false
);
setMessages((prev) => [...prev, stopMessage]);
} else {
const notRunningMessage = createLocalMessage(
conversationId,
'Duty cycle tracking is not active.',
false
);
setMessages((prev) => [...prev, notRunningMessage]);
}
return true;
}
return false; // Not an airtime command
},
[setMessages, stopTracking, pollAirtimeStats]
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return {
handleAirtimeCommand,
stopTracking,
};
}

View File

@@ -0,0 +1,298 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { api } from '../api';
import { toast } from '../components/ui/sonner';
import type {
Conversation,
PaneName,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
interface ConsoleEntry {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
}
interface PaneData {
status: RepeaterStatusResponse | null;
neighbors: RepeaterNeighborsResponse | null;
acl: RepeaterAclResponse | null;
radioSettings: RepeaterRadioSettingsResponse | null;
advertIntervals: RepeaterAdvertIntervalsResponse | null;
ownerInfo: RepeaterOwnerInfoResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
status: { ...INITIAL_PANE_STATE },
neighbors: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
radioSettings: { ...INITIAL_PANE_STATE },
advertIntervals: { ...INITIAL_PANE_STATE },
ownerInfo: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
function createInitialPaneData(): PaneData {
return {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
case 'status':
return api.repeaterStatus(publicKey);
case 'neighbors':
return api.repeaterNeighbors(publicKey);
case 'acl':
return api.repeaterAcl(publicKey);
case 'radioSettings':
return api.repeaterRadioSettings(publicKey);
case 'advertIntervals':
return api.repeaterAdvertIntervals(publicKey);
case 'ownerInfo':
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
}
}
export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
consoleLoading: boolean;
login: (password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
refreshPane: (pane: PaneName) => Promise<void>;
loadAll: () => Promise<void>;
sendConsoleCommand: (command: string) => Promise<void>;
sendAdvert: () => Promise<void>;
rebootRepeater: () => Promise<void>;
syncClock: () => Promise<void>;
}
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
// unmount. Initialised from activeConversation because the parent renders
// <RepeaterDashboard key={id}>, so this hook only ever sees one conversation.
const activeIdRef = useRef(activeConversation?.id ?? null);
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
}, [activeConversation]);
const login = useCallback(
async (password: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
setLoginLoading(true);
setLoginError(null);
try {
await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLoggedIn(true);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLoginError(msg);
} finally {
if (activeIdRef.current === conversationId) {
setLoginLoading(false);
}
}
},
[getPublicKey]
);
const loginAsGuest = useCallback(async () => {
await login('');
}, [login]);
const refreshPane = useCallback(
async (pane: PaneName) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
}));
try {
const data = await fetchPaneData(publicKey, pane);
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
}));
return; // Success
} catch (err) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Request failed';
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
// Wait before retrying
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
},
[getPublicKey]
);
const loadAll = useCallback(async () => {
const panes: PaneName[] = [
'status',
'neighbors',
'acl',
'radioSettings',
'advertIntervals',
'ownerInfo',
'lppTelemetry',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
await refreshPane(pane);
}
}, [refreshPane]);
const sendConsoleCommand = useCallback(
async (command: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const now = Math.floor(Date.now() / 1000);
// Add outgoing command entry
setConsoleHistory((prev) => [
...prev,
{ command, response: '', timestamp: now, outgoing: true },
]);
setConsoleLoading(true);
try {
const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command);
if (activeIdRef.current !== conversationId) return;
setConsoleHistory((prev) => [
...prev,
{
command,
response: result.response,
timestamp: result.sender_timestamp ?? now,
outgoing: false,
},
]);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Command failed';
setConsoleHistory((prev) => [
...prev,
{ command, response: `Error: ${msg}`, timestamp: now, outgoing: false },
]);
} finally {
if (activeIdRef.current === conversationId) {
setConsoleLoading(false);
}
}
},
[getPublicKey]
);
const sendAdvert = useCallback(async () => {
await sendConsoleCommand('advert');
}, [sendConsoleCommand]);
const rebootRepeater = useCallback(async () => {
await sendConsoleCommand('reboot');
}, [sendConsoleCommand]);
const syncClock = useCallback(async () => {
const epoch = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`clock ${epoch}`);
}, [sendConsoleCommand]);
return {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
};
}

View File

@@ -1,246 +0,0 @@
import { useState, useCallback, useMemo, useEffect, type RefObject } from 'react';
import { api } from '../api';
import type {
Contact,
Conversation,
Message,
TelemetryResponse,
NeighborInfo,
AclEntry,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { useAirtimeTracking } from './useAirtimeTracking';
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) {
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
// Format telemetry response as human-readable text
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`Telemetry`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
...(telemetry.clock_output ? [`Clock: ${telemetry.clock_output}`] : []),
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
'',
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
'',
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
'',
`TX Queue: ${telemetry.tx_queue_len}`,
`Debug Flags: ${telemetry.full_events}`,
];
return lines.join('\n');
}
// Format neighbors list as human-readable text
function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return 'Neighbors\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`Neighbors (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
}
return lines.join('\n');
}
// Format ACL list as human-readable text
function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return 'ACL\nNo ACL entries';
}
const lines = [`ACL (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
}
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(
conversationKey: string,
text: string,
outgoing: boolean,
idOffset = 0
): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now() - idOffset,
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
interface UseRepeaterModeResult {
repeaterLoggedIn: boolean;
activeContactIsRepeater: boolean;
handleTelemetryRequest: (password: string) => Promise<void>;
handleRepeaterCommand: (command: string) => Promise<void>;
}
export function useRepeaterMode(
activeConversation: Conversation | null,
contacts: Contact[],
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
activeConversationRef: RefObject<Conversation | null>
): UseRepeaterModeResult {
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages);
// Reset login state and stop airtime tracking when conversation changes
useEffect(() => {
setRepeaterLoggedIn(false);
stopTracking();
}, [activeConversation?.id, stopTracking]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
const conversationId = activeConversation.id;
try {
const telemetry = await api.requestTelemetry(conversationId, password);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Create local messages to display the telemetry (not persisted to database)
const telemetryMessage = createLocalMessage(
conversationId,
formatTelemetry(telemetry),
false,
0
);
const neighborsMessage = createLocalMessage(
conversationId,
formatNeighbors(telemetry.neighbors),
false,
1
);
const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2);
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
// Mark as logged in for CLI command mode
setRepeaterLoggedIn(true);
} catch (err) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
0
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, setMessages, activeConversationRef]
);
// Send CLI command to a repeater (after logged in)
const handleRepeaterCommand = useCallback(
async (command: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
const conversationId = activeConversation.id;
// Check for special airtime commands first (handled locally)
const handled = await handleAirtimeCommand(command, conversationId);
if (handled) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(conversationId, `> ${command}`, true, 0);
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(conversationId, command);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Use the actual timestamp from the repeater if available
const responseMessage = createLocalMessage(conversationId, response.response, false, 1);
if (response.sender_timestamp) {
responseMessage.sender_timestamp = response.sender_timestamp;
}
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
1
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[
activeConversation,
activeContactIsRepeater,
repeaterLoggedIn,
setMessages,
handleAirtimeCommand,
activeConversationRef,
]
);
return {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
};
}

View File

@@ -39,8 +39,6 @@ const mocks = vi.hoisted(() => ({
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
},
}));
@@ -75,12 +73,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
}),
getMessageContentKey: () => 'content-key',
};
});

View File

@@ -44,12 +44,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getLocalLabel, setLocalLabel, getContrastTextColor } from '../utils/localLabel';
describe('localLabel utilities', () => {
beforeEach(() => {
localStorage.clear();
});
describe('getLocalLabel', () => {
it('returns default when nothing stored', () => {
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('returns stored label', () => {
localStorage.setItem(
'remoteterm-local-label',
JSON.stringify({ text: 'Dev', color: '#ff0000' })
);
const label = getLocalLabel();
expect(label.text).toBe('Dev');
expect(label.color).toBe('#ff0000');
});
it('handles corrupted JSON gracefully', () => {
localStorage.setItem('remoteterm-local-label', '{bad json');
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('handles partial stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 'Hi' }));
const label = getLocalLabel();
expect(label.text).toBe('Hi');
expect(label.color).toBe('#062d60'); // falls back to default color
});
it('handles non-string values in stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 123, color: true }));
const label = getLocalLabel();
expect(label.text).toBe(''); // non-string falls back
expect(label.color).toBe('#062d60');
});
});
describe('setLocalLabel', () => {
it('stores label to localStorage', () => {
setLocalLabel('Test', '#00ff00');
const raw = localStorage.getItem('remoteterm-local-label');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.text).toBe('Test');
expect(parsed.color).toBe('#00ff00');
});
});
describe('getContrastTextColor', () => {
it('returns white for dark colors', () => {
expect(getContrastTextColor('#000000')).toBe('white');
expect(getContrastTextColor('#062d60')).toBe('white');
expect(getContrastTextColor('#333333')).toBe('white');
});
it('returns black for light colors', () => {
expect(getContrastTextColor('#ffffff')).toBe('black');
expect(getContrastTextColor('#ffff00')).toBe('black');
expect(getContrastTextColor('#00ff00')).toBe('black');
});
it('handles hex with # prefix', () => {
expect(getContrastTextColor('#000000')).toBe('white');
});
it('handles hex without # prefix', () => {
expect(getContrastTextColor('000000')).toBe('white');
expect(getContrastTextColor('ffffff')).toBe('black');
});
});
});

View File

@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
-readonly [K in keyof UseRepeaterDashboardResult]: UseRepeaterDashboardResult[K];
} = {
loggedIn: false,
loginLoading: false,
loginError: null,
paneData: {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
},
consoleHistory: [],
consoleLoading: false,
login: vi.fn(),
loginAsGuest: vi.fn(),
refreshPane: vi.fn(),
loadAll: vi.fn(),
sendConsoleCommand: vi.fn(),
sendAdvert: vi.fn(),
rebootRepeater: vi.fn(),
syncClock: vi.fn(),
};
vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Mock leaflet imports (not needed in test)
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
const REPEATER_KEY = 'aa'.repeat(32);
const conversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
const contacts: Contact[] = [
{
public_key: REPEATER_KEY,
name: 'TestRepeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock hook state
mockHook.loggedIn = false;
mockHook.loginLoading = false;
mockHook.loginError = null;
mockHook.paneData = {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
};
mockHook.consoleHistory = [];
mockHook.consoleLoading = false;
});
it('renders login form when not logged in', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders dashboard panes when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry')).toBeInTheDocument();
expect(screen.getByText('Neighbors')).toBeInTheDocument();
expect(screen.getByText('ACL')).toBeInTheDocument();
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
expect(screen.getByText('Advert Intervals')).toBeInTheDocument(); // sub-section inside Radio Settings
expect(screen.getByText('LPP Sensors')).toBeInTheDocument();
expect(screen.getByText('Owner Info')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.getByText('Console')).toBeInTheDocument();
});
it('shows not fetched placeholder for empty panes', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
// All panes should show <not fetched> since data is null
const notFetched = screen.getAllByText('<not fetched>');
expect(notFetched.length).toBeGreaterThanOrEqual(7); // At least 7 data panes (incl. LPP Sensors)
});
it('shows Load All button when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Load All')).toBeInTheDocument();
});
it('calls loadAll when Load All button is clicked', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
fireEvent.click(screen.getByText('Load All'));
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('shows pane error when fetch fails', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: false, attempt: 3, error: 'Timeout' };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Fetching (attempt 2/3)...')).toBeInTheDocument();
});
it('renders telemetry data when available', () => {
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('4.200V')).toBeInTheDocument();
expect(screen.getByText('-120 dBm')).toBeInTheDocument();
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('renders action buttons', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Send Advert')).toBeInTheDocument();
expect(screen.getByText('Sync Clock')).toBeInTheDocument();
expect(screen.getByText('Reboot')).toBeInTheDocument();
});
it('calls onTrace when trace button clicked', () => {
render(<RepeaterDashboard {...defaultProps} />);
// The trace button has title "Direct Trace"
fireEvent.click(screen.getByTitle('Direct Trace'));
expect(defaultProps.onTrace).toHaveBeenCalledTimes(1);
});
it('console shows placeholder when empty', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Type a CLI command below...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatDuration, formatClockDrift } from '../components/RepeaterDashboard';
describe('formatDuration', () => {
it('formats seconds only', () => {
expect(formatDuration(0)).toBe('0s');
expect(formatDuration(30)).toBe('30s');
expect(formatDuration(59)).toBe('59s');
});
it('formats minutes only', () => {
expect(formatDuration(60)).toBe('1m');
expect(formatDuration(300)).toBe('5m');
expect(formatDuration(3540)).toBe('59m');
});
it('formats hours and minutes', () => {
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(3660)).toBe('1h1m');
expect(formatDuration(7200)).toBe('2h');
expect(formatDuration(7260)).toBe('2h1m');
});
it('formats days', () => {
expect(formatDuration(86400)).toBe('1d');
expect(formatDuration(86400 + 3600)).toBe('1d1h');
expect(formatDuration(86400 + 60)).toBe('1d1m');
expect(formatDuration(86400 + 3600 + 60)).toBe('1d1h1m');
expect(formatDuration(172800)).toBe('2d');
});
it('formats multi-day durations', () => {
expect(formatDuration(3 * 86400 + 12 * 3600 + 30 * 60)).toBe('3d12h30m');
});
});
describe('formatClockDrift', () => {
afterEach(() => {
vi.useRealTimers();
});
it('parses firmware format HH:MM - D/M/YYYY UTC', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('parses firmware format with seconds HH:MM:SS - D/M/YYYY', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-15T08:00:00Z'));
const result = formatClockDrift('08:00:00 - 15/6/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('reports large drift (>24h)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-11T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(true);
expect(result.text).toBe('>24 hours!');
});
it('handles invalid date strings', () => {
const result = formatClockDrift('not a date');
expect(result.text).toBe('(invalid)');
expect(result.isLarge).toBe(false);
});
it('formats multi-unit drift', () => {
vi.useFakeTimers();
// 1h30m5s drift
vi.setSystemTime(new Date('2025-01-09T14:00:05Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('1h30m5s');
});
it('formats minutes and seconds drift', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:35:10Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('5m10s');
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterLogin } from '../components/RepeaterLogin';
describe('RepeaterLogin', () => {
const defaultProps = {
repeaterName: 'TestRepeater',
loading: false,
error: null as string | null,
onLogin: vi.fn(),
onLoginAsGuest: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders repeater name and description', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByText('TestRepeater')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders password input and buttons', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
});
it('calls onLogin with trimmed password on submit', () => {
render(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: ' secret ' } });
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('calls onLoginAsGuest when guest button clicked', () => {
render(<RepeaterLogin {...defaultProps} />);
fireEvent.click(screen.getByText('Login as Guest / ACLs'));
expect(defaultProps.onLoginAsGuest).toHaveBeenCalledTimes(1);
});
it('disables inputs when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeDisabled();
expect(screen.getByText('Logging in...')).toBeDisabled();
expect(screen.getByText('Login as Guest / ACLs')).toBeDisabled();
});
it('shows loading text on submit button', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByText('Logging in...')).toBeInTheDocument();
expect(screen.queryByText('Login with Password')).not.toBeInTheDocument();
});
it('displays error message when present', () => {
render(<RepeaterLogin {...defaultProps} error="Invalid password" />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('does not call onLogin when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
fireEvent.submit(screen.getByText('Logging in...').closest('form')!);
expect(defaultProps.onLogin).not.toHaveBeenCalled();
});
});

View File

@@ -1,126 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAirtimeTracking } from '../hooks/useAirtimeTracking';
import type { Message, TelemetryResponse } from '../types';
function createTelemetry(overrides: Partial<TelemetryResponse> = {}): TelemetryResponse {
return {
pubkey_prefix: 'AABB',
battery_volts: 3.7,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -80,
last_snr_db: 10,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 10,
rx_airtime_seconds: 5,
uptime_seconds: 3600,
sent_flood: 30,
sent_direct: 20,
recv_flood: 60,
recv_direct: 40,
flood_dups: 5,
direct_dups: 2,
full_events: 0,
clock_output: null,
neighbors: [],
acl: [],
...overrides,
};
}
function createDeferred<T>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const mockRequestTelemetry = vi.fn<(...args: unknown[]) => Promise<TelemetryResponse>>();
vi.mock('../api', () => ({
api: {
requestTelemetry: (...args: unknown[]) => mockRequestTelemetry(...args),
},
}));
describe('useAirtimeTracking stale poll guard', () => {
beforeEach(() => {
mockRequestTelemetry.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('discards poll response when tracking was stopped during in-flight request', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start succeeds immediately
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
// Start tracking
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
// setMessages was called with the start message
const startCallCount = setMessages.mock.calls.length;
expect(startCallCount).toBeGreaterThanOrEqual(1);
// Set up a deferred telemetry response for the poll
const deferred = createDeferred<TelemetryResponse>();
mockRequestTelemetry.mockReturnValueOnce(deferred.promise);
// Advance timer to trigger the 5-minute poll
act(() => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// Poll is now in-flight. Stop tracking (simulates conversation switch).
act(() => {
result.current.stopTracking();
});
// Resolve the stale telemetry response
await act(async () => {
deferred.resolve(createTelemetry({ uptime_seconds: 7200 }));
});
// setMessages should NOT have been called with the stale poll result
// Only the start message calls should exist
expect(setMessages.mock.calls.length).toBe(startCallCount);
});
it('appends poll result when tracking is still active', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
const startCallCount = setMessages.mock.calls.length;
// Set up poll response
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry({ uptime_seconds: 7200 }));
// Advance timer to trigger the 5-minute poll
await act(async () => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// setMessages SHOULD have been called with the poll result
expect(setMessages.mock.calls.length).toBeGreaterThan(startCallCount);
});
});

View File

@@ -0,0 +1,289 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import type { Conversation } from '../types';
// Mock the api module
vi.mock('../api', () => ({
api: {
repeaterLogin: vi.fn(),
repeaterStatus: vi.fn(),
repeaterNeighbors: vi.fn(),
repeaterAcl: vi.fn(),
repeaterRadioSettings: vi.fn(),
repeaterAdvertIntervals: vi.fn(),
repeaterOwnerInfo: vi.fn(),
repeaterLppTelemetry: vi.fn(),
sendRepeaterCommand: vi.fn(),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Get mock reference — cast to Record<string, Mock> for type-safe mock method access
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const REPEATER_KEY = 'aa'.repeat(32);
const repeaterConversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
describe('useRepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('starts with logged out state', () => {
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginLoading).toBe(false);
expect(result.current.loginError).toBe(null);
});
it('login sets loggedIn on success', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('secret');
});
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null);
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
});
it('login sets error on failure', async () => {
mockApi.repeaterLogin.mockRejectedValueOnce(new Error('Auth failed'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('bad');
});
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginError).toBe('Auth failed');
});
it('loginAsGuest calls login with empty password', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loginAsGuest();
});
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, '');
expect(result.current.loggedIn).toBe(true);
});
it('refreshPane stores data on success', async () => {
const statusData = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.loading).toBe(false);
expect(result.current.paneStates.status.error).toBe(null);
});
it('refreshPane retries up to 3 times', async () => {
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail2'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail3'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(3);
expect(result.current.paneStates.status.error).toBe('fail3');
expect(result.current.paneData.status).toBe(null);
});
it('refreshPane succeeds on second attempt', async () => {
const statusData = { battery_volts: 3.7 };
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(2);
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.error).toBe(null);
});
it('sendConsoleCommand adds entries to console history', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'ver',
response: 'v2.1.0',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('v2.1.0');
});
it('sendConsoleCommand adds error entry on failure', async () => {
mockApi.sendRepeaterCommand.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('Error: Network error');
expect(result.current.consoleLoading).toBe(false);
});
it('sendAdvert sends "advert" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'advert',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendAdvert();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
});
it('rebootRepeater sends "reboot" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'reboot',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.rebootRepeater();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
});
it('syncClock sends "clock <epoch>" command', async () => {
const fakeNow = 1700000000000;
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'clock 1700000000',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.syncClock();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
vi.restoreAllMocks();
});
it('loadAll calls refreshPane for all panes serially', async () => {
mockApi.repeaterStatus.mockResolvedValueOnce({ battery_volts: 4.0 });
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
firmware_version: 'v1.0',
radio: null,
tx_power: null,
airtime_factor: null,
repeat_enabled: null,
flood_max: null,
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
advert_interval: null,
flood_advert_interval: null,
});
mockApi.repeaterOwnerInfo.mockResolvedValueOnce({
owner_info: null,
guest_password: null,
});
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loadAll();
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
});

View File

@@ -218,8 +218,19 @@ export interface AclEntry {
permission_name: string;
}
export interface TelemetryResponse {
pubkey_prefix: string;
export interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
}
// --- Granular repeater endpoint types ---
export interface RepeaterLoginResponse {
status: string;
}
export interface RepeaterStatusResponse {
battery_volts: number;
tx_queue_len: number;
noise_floor_dbm: number;
@@ -237,15 +248,62 @@ export interface TelemetryResponse {
flood_dups: number;
direct_dups: number;
full_events: number;
neighbors: NeighborInfo[];
acl: AclEntry[];
clock_output: string | null;
}
export interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
export interface RepeaterNeighborsResponse {
neighbors: NeighborInfo[];
}
export interface RepeaterAclResponse {
acl: AclEntry[];
}
export interface RepeaterRadioSettingsResponse {
firmware_version: string | null;
radio: string | null;
tx_power: string | null;
airtime_factor: string | null;
repeat_enabled: string | null;
flood_max: string | null;
name: string | null;
lat: string | null;
lon: string | null;
clock_utc: string | null;
}
export interface RepeaterAdvertIntervalsResponse {
advert_interval: string | null;
flood_advert_interval: string | null;
}
export interface RepeaterOwnerInfoResponse {
owner_info: string | null;
guest_password: string | null;
}
export interface LppSensor {
channel: number;
type_name: string;
value: number | Record<string, number>;
}
export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[];
}
export type PaneName =
| 'status'
| 'neighbors'
| 'acl'
| 'radioSettings'
| 'advertIntervals'
| 'ownerInfo'
| 'lppTelemetry';
export interface PaneState {
loading: boolean;
attempt: number;
error: string | null;
}
export interface TraceResponse {

File diff suppressed because it is too large Load Diff