mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Repeater UI overhaul
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
115
app/models.py
115
app/models.py
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
98
frontend/src/components/NeighborsMiniMap.tsx
Normal file
98
frontend/src/components/NeighborsMiniMap.tsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
971
frontend/src/components/RepeaterDashboard.tsx
Normal file
971
frontend/src/components/RepeaterDashboard.tsx
Normal 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"><not fetched></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">
|
||||
> {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"
|
||||
>
|
||||
🛎
|
||||
</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">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</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"
|
||||
>
|
||||
🗑
|
||||
</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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/RepeaterLogin.tsx
Normal file
74
frontend/src/components/RepeaterLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useRepeaterMode } from './useRepeaterMode';
|
||||
export { useUnreadCounts } from './useUnreadCounts';
|
||||
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
|
||||
export { useRadioControl } from './useRadioControl';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
298
frontend/src/hooks/useRepeaterDashboard.ts
Normal file
298
frontend/src/hooks/useRepeaterDashboard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
81
frontend/src/test/localLabel.test.ts
Normal file
81
frontend/src/test/localLabel.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
270
frontend/src/test/repeaterDashboard.test.tsx
Normal file
270
frontend/src/test/repeaterDashboard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
93
frontend/src/test/repeaterFormatters.test.ts
Normal file
93
frontend/src/test/repeaterFormatters.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
77
frontend/src/test/repeaterLogin.test.tsx
Normal file
77
frontend/src/test/repeaterLogin.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
289
frontend/src/test/useRepeaterDashboard.test.ts
Normal file
289
frontend/src/test/useRepeaterDashboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user