mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-08 06:15:02 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2030175e05 | |||
| bb5af5ba82 | |||
| 159df1ec5b | |||
| 8e2e039985 | |||
| 01c86a486e | |||
| 7d5cfdec26 | |||
| 5fe0ac0ad4 | |||
| b98102ccac | |||
| a02c3cae9e | |||
| ca7349a1a8 | |||
| eeaa11b8b0 | |||
| 08eaf090b2 | |||
| 2f43420235 | |||
| af74663518 | |||
| b7981c0450 | |||
| 0f4976b9ee | |||
| 1991f2515b |
@@ -1,3 +1,24 @@
|
||||
## [3.9.0] - 2026-04-06
|
||||
|
||||
* Feature: Add hop counts to hop-width selection options
|
||||
* Feature: Show cached repeater telemetry inline in settings
|
||||
* Feature: Retain recent traces and make them click-to-re-run
|
||||
* Feature: Autofocus channel/DM textbox on desktop
|
||||
* Feature: Favorites on the radio are now imported as favorites
|
||||
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
|
||||
* Bugfix: Don't use sender timestamp for message sequence display
|
||||
* Bugfix: Function on subdomains happily
|
||||
* Misc: Be gentler, room s/cracker/finder/
|
||||
* Misc: Test and frontend correctness & test fixes
|
||||
* Misc: Don't repeat clock sync failure logs
|
||||
* Misc: Make warning in readme clearer about taking over the radio
|
||||
* Misc: Improve readme phrasings
|
||||
* Misc: Better y-axis selection for battery read-out
|
||||
* Misc: Provide clearer warning on docker setup without docker installed
|
||||
* Misc: Default visualizer stale pruning to on/5 minutes
|
||||
* Misc: Migrate favorites to better storage pattern
|
||||
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
|
||||
|
||||
## [3.8.0] - 2026-04-03
|
||||
|
||||
* Feature: Per-channel hop width override
|
||||
|
||||
+31
@@ -1188,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### cmdk (1.1.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paco Coursey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### d3-force (3.0.0) — ISC
|
||||
|
||||
<details>
|
||||
|
||||
@@ -199,6 +199,15 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
|
||||
>
|
||||
> ```powershell
|
||||
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
|
||||
> ```
|
||||
>
|
||||
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
@@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||
|
||||
## Sub-Path Reverse Proxy
|
||||
|
||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
|
||||
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
+4
-1
@@ -299,8 +299,11 @@ def parse_advertisement(
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
# Parse flags — clamp device_role to valid range (0-4); corrupted
|
||||
# advertisements can have junk in the lower nibble.
|
||||
device_role = flags & 0x0F
|
||||
if device_role > 4:
|
||||
device_role = 0
|
||||
has_location = bool(flags & 0x10)
|
||||
has_feature1 = bool(flags & 0x20)
|
||||
has_feature2 = bool(flags & 0x40)
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
@@ -252,6 +253,34 @@ class BaseMqttPublisher(ABC):
|
||||
self._client = None
|
||||
self._last_error = _format_error_detail(e)
|
||||
|
||||
# Windows ProactorEventLoop does not implement add_reader /
|
||||
# add_writer, which paho-mqtt requires. The failure can
|
||||
# surface as a direct NotImplementedError (add_writer in
|
||||
# __aenter__) or as a generic timeout (add_reader fails
|
||||
# inside an event-loop callback, so paho never hears back).
|
||||
# Either way, if we're on Windows with Proactor the root
|
||||
# cause is the same and retrying won't help.
|
||||
_on_proactor = (
|
||||
sys.platform == "win32"
|
||||
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
|
||||
)
|
||||
if _on_proactor:
|
||||
broadcast_error(
|
||||
"MQTT unavailable — Windows event loop incompatible",
|
||||
"The default Windows event loop (ProactorEventLoop) does "
|
||||
"not support MQTT. Add --loop none to your uvicorn "
|
||||
"command and restart. See README.md for details.",
|
||||
)
|
||||
_broadcast_health()
|
||||
logger.error(
|
||||
"%s cannot run: Windows ProactorEventLoop does not "
|
||||
"implement add_reader/add_writer required by paho-mqtt. "
|
||||
"Restart uvicorn with '--loop none' to use "
|
||||
"SelectorEventLoop instead. Giving up (will not retry).",
|
||||
self._integration_label(),
|
||||
)
|
||||
return
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
|
||||
+31
-11
@@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
def _resolve_request_base(request: Request) -> str:
|
||||
"""Resolve the external base URL, honoring common reverse-proxy headers.
|
||||
|
||||
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
|
||||
so callers can append paths directly.
|
||||
|
||||
Recognized headers:
|
||||
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
|
||||
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
|
||||
by the proxy (e.g. ``/meshcore``).
|
||||
"""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
@@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
origin = f"{proto}://{host}"
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
|
||||
prefix = (
|
||||
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
return f"{origin}{prefix}/"
|
||||
|
||||
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
@@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
"""Serve a dynamic web manifest using the active request origin."""
|
||||
origin = _resolve_request_origin(request)
|
||||
"""Serve a dynamic web manifest using the active request base URL."""
|
||||
base = _resolve_request_base(request)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"id": base,
|
||||
"start_url": base,
|
||||
"scope": base,
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"src": f"{base}web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"src": f"{base}web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
|
||||
+37
-1
@@ -1,5 +1,41 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows event-loop advisory for MQTT fanout
|
||||
# ---------------------------------------------------------------------------
|
||||
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
||||
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
|
||||
# We cannot fix this from inside the app — the loop is already created by the
|
||||
# time this module is imported. Log a prominent warning so Windows operators
|
||||
# who want MQTT know to add ``--loop none`` to their uvicorn command.
|
||||
# ---------------------------------------------------------------------------
|
||||
if sys.platform == "win32":
|
||||
import asyncio as _asyncio
|
||||
|
||||
_loop = _asyncio.get_event_loop()
|
||||
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
|
||||
if _is_proactor:
|
||||
print(
|
||||
"\n" + "!" * 78 + "\n"
|
||||
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
||||
"\n"
|
||||
" The running event loop is ProactorEventLoop, which is not\n"
|
||||
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
|
||||
"\n"
|
||||
" If you use MQTT integrations, restart with --loop none:\n"
|
||||
"\n"
|
||||
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
|
||||
" [... other options ...]\n"
|
||||
"\n"
|
||||
" Everything else works fine as-is.\n"
|
||||
"\n" + "!" * 78 + "\n",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
del _loop, _is_proactor
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
+27
-3
@@ -4,6 +4,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
|
||||
# Corrupted radio data can produce values outside this range.
|
||||
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
@@ -59,16 +63,30 @@ class ContactUpsert(BaseModel):
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
|
||||
# can produce values like 111 or 240 that break downstream branching.
|
||||
raw_type = radio_data.get("type", 0)
|
||||
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
|
||||
|
||||
# Null out impossible coordinates — the contact is still ingested,
|
||||
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
|
||||
lat = radio_data.get("adv_lat")
|
||||
lon = radio_data.get("adv_lon")
|
||||
if lat is not None and not (-90 <= lat <= 90):
|
||||
lat = None
|
||||
if lon is not None and not (-180 <= lon <= 180):
|
||||
lon = None
|
||||
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
type=contact_type,
|
||||
flags=radio_data.get("flags", 0),
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
@@ -866,6 +884,11 @@ class NoiseFloorHistoryStats(BaseModel):
|
||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PacketsPerHourBucket(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp at the start of the hour")
|
||||
count: int = Field(description="Number of packets received in that hour")
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -881,6 +904,7 @@ class StatisticsResponse(BaseModel):
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
packets_per_hour_72h: list[PacketsPerHourBucket]
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
|
||||
+9
-3
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
|
||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
@@ -1070,8 +1070,14 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
|
||||
logger.debug("Synced %d contacts from radio snapshot", synced)
|
||||
|
||||
# Import radio-favorited contacts into app favorites
|
||||
radio_fav_keys = [pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01]
|
||||
# Import radio-favorited contacts into app favorites.
|
||||
# Only trust the favorite bit on contacts with a valid type (0-4);
|
||||
# garbled radio data can have junk flags with bit 0 set.
|
||||
radio_fav_keys = [
|
||||
pk
|
||||
for pk, data in contacts.items()
|
||||
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
|
||||
]
|
||||
if radio_fav_keys:
|
||||
try:
|
||||
imported = 0
|
||||
|
||||
@@ -692,9 +692,18 @@ class ContactAdvertPathRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY public_key
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
) AS rn
|
||||
FROM contact_advert_paths
|
||||
)
|
||||
WHERE rn <= ?
|
||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
"""
|
||||
""",
|
||||
(limit_per_contact,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
@@ -705,8 +714,6 @@ class ContactAdvertPathRepository:
|
||||
if paths is None:
|
||||
paths = []
|
||||
grouped[key] = paths
|
||||
if len(paths) >= limit_per_contact:
|
||||
continue
|
||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||
|
||||
return [
|
||||
|
||||
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_72H = 259200
|
||||
SECONDS_7D = 604800
|
||||
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||
|
||||
@@ -274,6 +275,25 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _packets_per_hour_72h() -> list[dict[str, int]]:
|
||||
"""Return packet counts bucketed by hour for the last 72 hours."""
|
||||
now = int(time.time())
|
||||
cutoff = now - SECONDS_72H
|
||||
# Bucket timestamps to the start of each hour
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
|
||||
FROM raw_packets
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY hour_ts
|
||||
ORDER BY hour_ts
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
@@ -350,6 +370,7 @@ class StatisticsRepository:
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
@@ -366,4 +387,5 @@ class StatisticsRepository:
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
"packets_per_hour_72h": packets_per_hour_72h,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
# MESHCORE_TCP_PORT: 5000
|
||||
|
||||
# BLE
|
||||
# BLE in Docker usually needs additional manual compose changes such as
|
||||
|
||||
+12
-12
@@ -9,11 +9,11 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
@@ -42,17 +42,17 @@
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
config: fetchJsonOrThrow('./api/radio/config'),
|
||||
settings: fetchJsonOrThrow('./api/settings'),
|
||||
channels: fetchJsonOrThrow('./api/channels'),
|
||||
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+19
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -3687,6 +3688,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.8.0",
|
||||
"version": "3.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
+33
-1
@@ -25,7 +25,8 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -88,6 +89,7 @@ export function App() {
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -295,6 +297,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Auto-focus the message input on conversation change (desktop only by default)
|
||||
useEffect(() => {
|
||||
if (!activeConversation) return;
|
||||
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
|
||||
// Repeaters show a login form, not a message input
|
||||
if (activeConversation.type === 'contact') {
|
||||
const contact = contacts.find((c) => c.public_key === activeConversation.id);
|
||||
if (contact?.type === CONTACT_TYPE_REPEATER) return;
|
||||
}
|
||||
if (!shouldAutoFocusInput()) return;
|
||||
// Defer to let the input mount/render first
|
||||
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||
// so the display reflects the original send order rather than our radio's receipt order.
|
||||
@@ -457,6 +474,18 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleRepeaterAutoLogin = useCallback(
|
||||
(publicKey: string, displayName: string) => {
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: displayName,
|
||||
});
|
||||
setRepeaterAutoLoginKey(publicKey);
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
@@ -587,6 +616,8 @@ export function App() {
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -720,6 +751,7 @@ export function App() {
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import type {
|
||||
UnreadCounts,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = './api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const hasBody = options?.body !== undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -71,6 +72,7 @@ interface AppShellProps {
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
@@ -100,6 +102,7 @@ export function AppShell({
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
onRepeaterAutoLogin,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
@@ -119,6 +122,14 @@ export function AppShell({
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const handleOpenSettings = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
if (!showSettings) onToggleSettingsView();
|
||||
},
|
||||
[onSettingsSectionChange, onToggleSettingsView, showSettings]
|
||||
);
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -323,6 +334,13 @@ export function AppShell({
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
contacts={sidebarProps.contacts}
|
||||
channels={sidebarProps.channels}
|
||||
onSelectConversation={sidebarProps.onSelectConversation}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Hash,
|
||||
Map,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Route,
|
||||
Search,
|
||||
Sparkles,
|
||||
User,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
SETTINGS_SECTION_LABELS,
|
||||
SETTINGS_SECTION_ORDER,
|
||||
SETTINGS_SECTION_ICONS,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onOpenSettings: (section: SettingsSection) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
interface Searchable {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
interface SearchableContact extends Searchable {
|
||||
contact: Contact;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SearchableChannel extends Searchable {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
interface ToolItem extends Searchable {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
}
|
||||
|
||||
interface SettingItem extends Searchable {
|
||||
section: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOL_ITEMS: ToolItem[] = [
|
||||
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
|
||||
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
|
||||
{
|
||||
id: 'visualizer',
|
||||
name: 'Network Visualizer',
|
||||
icon: Network,
|
||||
type: 'visualizer',
|
||||
searchText: 'network visualizer',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
icon: Search,
|
||||
type: 'search',
|
||||
searchText: 'message search',
|
||||
},
|
||||
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
|
||||
];
|
||||
|
||||
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
|
||||
section,
|
||||
label: SETTINGS_SECTION_LABELS[section],
|
||||
icon: SETTINGS_SECTION_ICONS[section],
|
||||
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
|
||||
}));
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
||||
if (text[ti] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
}
|
||||
|
||||
function filterList<T extends Searchable>(items: T[], query: string): T[] {
|
||||
if (!query) return items.slice(0, MAX_PER_GROUP);
|
||||
const results: T[] = [];
|
||||
for (const item of items) {
|
||||
if (fuzzyMatch(item.searchText, query)) {
|
||||
results.push(item);
|
||||
if (results.length >= MAX_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
contacts,
|
||||
channels,
|
||||
onSelectConversation,
|
||||
onOpenSettings,
|
||||
onRepeaterAutoLogin,
|
||||
}: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const select = useCallback((action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
favContacts,
|
||||
favRepeaters,
|
||||
regularContacts,
|
||||
repeaters,
|
||||
rooms,
|
||||
favChannels,
|
||||
regularChannels,
|
||||
} = useMemo(() => {
|
||||
const fc: SearchableContact[] = [];
|
||||
const fr: SearchableContact[] = [];
|
||||
const rc: SearchableContact[] = [];
|
||||
const rp: SearchableContact[] = [];
|
||||
const rm: SearchableContact[] = [];
|
||||
for (const c of contacts) {
|
||||
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
|
||||
const entry: SearchableContact = {
|
||||
contact: c,
|
||||
displayName,
|
||||
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
|
||||
};
|
||||
if (c.type === CONTACT_TYPE_REPEATER) {
|
||||
(c.favorite ? fr : rp).push(entry);
|
||||
} else if (c.type === CONTACT_TYPE_ROOM) {
|
||||
rm.push(entry);
|
||||
} else {
|
||||
(c.favorite ? fc : rc).push(entry);
|
||||
}
|
||||
}
|
||||
const fch: SearchableChannel[] = [];
|
||||
const rch: SearchableChannel[] = [];
|
||||
for (const ch of channels) {
|
||||
const entry: SearchableChannel = {
|
||||
channel: ch,
|
||||
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
|
||||
};
|
||||
(ch.favorite ? fch : rch).push(entry);
|
||||
}
|
||||
return {
|
||||
favContacts: fc,
|
||||
favRepeaters: fr,
|
||||
regularContacts: rc,
|
||||
repeaters: rp,
|
||||
rooms: rm,
|
||||
favChannels: fch,
|
||||
regularChannels: rch,
|
||||
};
|
||||
}, [contacts, channels]);
|
||||
|
||||
const lq = query.toLowerCase();
|
||||
const fTools = filterList(TOOL_ITEMS, lq);
|
||||
const fSettings = filterList(SETTING_ITEMS, lq);
|
||||
const fFavContacts = filterList(favContacts, lq);
|
||||
const fFavRepeaters = filterList(favRepeaters, lq);
|
||||
const fFavChannels = filterList(favChannels, lq);
|
||||
const fContacts = filterList(regularContacts, lq);
|
||||
const fRepeaters = filterList(repeaters, lq);
|
||||
const fRooms = filterList(rooms, lq);
|
||||
const fChannels = filterList(regularChannels, lq);
|
||||
|
||||
const totalResults =
|
||||
fTools.length +
|
||||
fSettings.length +
|
||||
fFavContacts.length +
|
||||
fFavRepeaters.length +
|
||||
fFavChannels.length +
|
||||
fContacts.length +
|
||||
fRepeaters.length +
|
||||
fRooms.length +
|
||||
fChannels.length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery('');
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
|
||||
{fTools.length > 0 && (
|
||||
<CommandGroup heading="Tools">
|
||||
{fTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<tool.icon className="text-muted-foreground" />
|
||||
<span>{tool.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fSettings.length > 0 && (
|
||||
<CommandGroup heading="Settings">
|
||||
{fSettings.map((item) => (
|
||||
<CommandItem
|
||||
key={item.section}
|
||||
onSelect={() => select(() => onOpenSettings(item.section))}
|
||||
>
|
||||
<item.icon className="text-muted-foreground" />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fFavContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Favorite Contacts"
|
||||
items={fFavContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Favorite Repeaters"
|
||||
items={fFavRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavChannels.length > 0 && (
|
||||
<CommandGroup heading="Favorite Channels">
|
||||
{fFavChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
<Sparkles className="ml-auto h-3 w-3 text-yellow-500" />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Contacts"
|
||||
items={fContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Repeaters"
|
||||
items={fRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRooms.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Rooms"
|
||||
items={fRooms}
|
||||
icon={MessageSquare}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fChannels.length > 0 && (
|
||||
<CommandGroup heading="Channels">
|
||||
{fChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactGroup({
|
||||
heading,
|
||||
items,
|
||||
icon: Icon,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.map(({ contact: c, displayName }) => (
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeaterGroup({
|
||||
heading,
|
||||
items,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
onRepeaterAutoLogin,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.flatMap(({ contact: c, displayName }) => [
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>,
|
||||
<CommandItem
|
||||
key={`${c.public_key}-acl`}
|
||||
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>
|
||||
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
|
||||
</span>
|
||||
</CommandItem>,
|
||||
])}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
onClearRepeaterAutoLogin: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -149,6 +151,8 @@ export function ConversationPane({
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -248,6 +252,8 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
||||
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
|
||||
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Calculate character limits based on conversation type
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
KNOWN_PAYLOAD_TYPES,
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
buildRawPacketStatsSnapshot,
|
||||
type NeighborStat,
|
||||
@@ -24,9 +27,26 @@ import {
|
||||
type RawPacketStatsSessionState,
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
|
||||
|
||||
function getPacketTypeName(
|
||||
packet: RawPacket,
|
||||
decoderOptions?: ReturnType<typeof createDecoderOptions>
|
||||
): string {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
||||
if (!decoded.isValid) return 'Unknown';
|
||||
const name = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
@@ -428,6 +448,48 @@ export function RawPacketFeedView({
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
|
||||
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
|
||||
const packetsWithTypes = useMemo(
|
||||
() =>
|
||||
packets.map((packet) => ({
|
||||
packet,
|
||||
payloadType: getPacketTypeName(packet, decoderOptions),
|
||||
})),
|
||||
[packets, decoderOptions]
|
||||
);
|
||||
|
||||
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
|
||||
|
||||
const filteredPackets = useMemo(() => {
|
||||
if (allTypesEnabled) return packets;
|
||||
return packetsWithTypes
|
||||
.filter(({ payloadType }) => enabledTypes.has(payloadType))
|
||||
.map(({ packet }) => packet);
|
||||
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
|
||||
};
|
||||
|
||||
const handleToggleType = (type: string) => {
|
||||
setEnabledTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnly = (type: string) => {
|
||||
setEnabledTypes(new Set([type]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
@@ -468,38 +530,129 @@ export function RawPacketFeedView({
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="hidden md:block text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
<p className="md:hidden text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
{!mobileFiltersOpen && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
>
|
||||
Show Filters
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{mobileFiltersOpen && (
|
||||
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
||||
<RawPacketList
|
||||
packets={filteredPackets}
|
||||
channels={channels}
|
||||
onPacketClick={setSelectedPacket}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
|
||||
@@ -48,6 +48,8 @@ interface RepeaterDashboardProps {
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
autoLoginAndLoadAll?: boolean;
|
||||
onAutoLoginConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -67,6 +69,8 @@ export function RepeaterDashboard({
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
autoLoginAndLoadAll,
|
||||
onAutoLoginConsumed,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -125,6 +129,15 @@ export function RepeaterDashboard({
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
// Command palette "ACL login + load all" auto-action
|
||||
const autoLoginConsumedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
|
||||
autoLoginConsumedRef.current = true;
|
||||
onAutoLoginConsumed?.();
|
||||
void loginAsGuest().then(() => loadAll());
|
||||
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
|
||||
|
||||
const isFav = contact?.favorite ?? false;
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
@@ -64,7 +65,7 @@ export function RepeaterLogin({
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
autoFocus={shouldAutoFocusInput()}
|
||||
/>
|
||||
|
||||
<label
|
||||
|
||||
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/api/debug"
|
||||
href="./api/debug"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary hover:underline"
|
||||
|
||||
@@ -1666,7 +1666,8 @@ function AppriseConfigEditor({
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URL per line. All URLs receive every matched notification.
|
||||
One URL per line. All URLs receive every matched notification. For Matrix room version 12
|
||||
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getSavedFontScale,
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -48,6 +49,7 @@ export function SettingsLocalSection({
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
@@ -129,85 +131,6 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
@@ -233,33 +156,128 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<Label>UI Tweaks</Label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoFocusInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setAutoFocusInput(v);
|
||||
setAutoFocusInputEnabled(v);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps;
|
||||
the number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,87 @@ function formatTime(ts: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return (
|
||||
d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
);
|
||||
}
|
||||
|
||||
function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
|
||||
// Fill gaps so hours with zero packets still appear on the chart
|
||||
const filled: { timestamp: number; count: number }[] = [];
|
||||
if (buckets.length > 0) {
|
||||
const first = buckets[0].timestamp;
|
||||
const last = buckets[buckets.length - 1].timestamp;
|
||||
const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
|
||||
for (let ts = first; ts <= last; ts += 3600) {
|
||||
filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const data = filled.map((b, i) => ({
|
||||
idx: i,
|
||||
label: formatDateTime(b.timestamp),
|
||||
count: b.count,
|
||||
}));
|
||||
|
||||
// Show ~6 evenly-spaced tick labels
|
||||
const tickCount = Math.min(6, data.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (data.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, data.length - 1]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => data[idx]?.label ?? ''}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => data[Number(idx)]?.label ?? ''}
|
||||
formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#0ea5e9"
|
||||
fill="#0ea5e9"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#0ea5e9', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseFloorChart({
|
||||
samples,
|
||||
}: {
|
||||
@@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packets per Hour (72h) */}
|
||||
{stats.packets_per_hour_72h?.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
|
||||
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Path Hash Width */}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import type { Message } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
|
||||
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
const NOTIFICATION_ICON_PATH = './favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const BASE_FAVICON_PATH = './favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts?limit=100&offset=0');
|
||||
expect(url).toBe('./api/contacts?limit=100&offset=0');
|
||||
});
|
||||
|
||||
it('builds repeater advert path endpoint query', async () => {
|
||||
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getRepeaterAdvertPaths(12);
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendDirectMessage('abc123', 'hello');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages/direct');
|
||||
expect(url).toBe('./api/messages/direct');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
destination: 'abc123',
|
||||
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.updateRadioConfig({ name: 'NewName' });
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/config');
|
||||
expect(url).toBe('./api/radio/config');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
|
||||
});
|
||||
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.setPrivateKey('my-secret-key');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/private-key');
|
||||
expect(url).toBe('./api/radio/private-key');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
|
||||
});
|
||||
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.discoverMesh('repeaters');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/discover');
|
||||
expect(url).toBe('./api/radio/discover');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.deleteContact('pubkey123');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/pubkey123');
|
||||
expect(url).toBe('./api/contacts/pubkey123');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement();
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
|
||||
});
|
||||
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement('zero_hop');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
|
||||
});
|
||||
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/messages?');
|
||||
expect(url).toContain('./api/messages?');
|
||||
expect(url).toContain('limit=50');
|
||||
expect(url).toContain('offset=10');
|
||||
expect(url).toContain('type=PRIV');
|
||||
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getMessages();
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages');
|
||||
expect(url).toBe('./api/messages');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => {
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
|
||||
expect(link).toHaveAttribute('href', '/api/debug');
|
||||
expect(link).toHaveAttribute('href', './api/debug');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -652,6 +652,10 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
packets_per_hour_72h: [
|
||||
{ timestamp: 1711792800, count: 12 },
|
||||
{ timestamp: 1711796400, count: 8 },
|
||||
],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 300,
|
||||
coverage_seconds: 3600,
|
||||
@@ -722,6 +726,7 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 300,
|
||||
coverage_seconds: 0,
|
||||
@@ -749,7 +754,7 @@ describe('SettingsModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Statistics/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/statistics', expect.any(Object));
|
||||
expect(fetchSpy).toHaveBeenCalledWith('./api/statistics', expect.any(Object));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('useBrowserNotifications', () => {
|
||||
);
|
||||
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
|
||||
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
|
||||
icon: '/favicon-256x256.png',
|
||||
icon: './favicon-256x256.png',
|
||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
||||
});
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
@@ -122,7 +122,7 @@ describe('useBrowserNotifications', () => {
|
||||
expect(window.Notification).toHaveBeenCalledTimes(2);
|
||||
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
|
||||
body: 'hello room',
|
||||
icon: '/favicon-256x256.png',
|
||||
icon: './favicon-256x256.png',
|
||||
tag: 'meshcore-message-42',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
|
||||
setContacts: vi.fn(),
|
||||
setChannels: vi.fn(),
|
||||
observeMessage: vi.fn(() => ({ added: true, activeConversation: true })),
|
||||
messageInputRef: { current: { appendText: vi.fn() } },
|
||||
messageInputRef: { current: { appendText: vi.fn(), focus: vi.fn() } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ describe('useFaviconBadge', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('icon')).toBe('./favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
|
||||
});
|
||||
|
||||
rerender({
|
||||
@@ -234,8 +234,8 @@ describe('useFaviconBadge', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('icon')).toBe('./favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -544,6 +544,11 @@ export interface NoiseFloorHistoryStats {
|
||||
samples: NoiseFloorSample[];
|
||||
}
|
||||
|
||||
interface PacketsPerHourBucket {
|
||||
timestamp: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface StatisticsResponse {
|
||||
busiest_channels_24h: BusyChannel[];
|
||||
contact_count: number;
|
||||
@@ -567,5 +572,6 @@ export interface StatisticsResponse {
|
||||
double_byte_pct: number;
|
||||
triple_byte_pct: number;
|
||||
};
|
||||
packets_per_hour_72h: PacketsPerHourBucket[];
|
||||
noise_floor_24h: NoiseFloorHistoryStats;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
const connect = useCallback(() => {
|
||||
// Determine WebSocket URL based on current location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||
// Resolve relative to the page so sub-path reverse proxies work
|
||||
const base = new URL('./api/ws', window.location.href);
|
||||
const wsUrl = `${protocol}//${base.host}${base.pathname}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const KEY = 'remoteterm-auto-focus-input';
|
||||
|
||||
export function getAutoFocusInputEnabled(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw === null || raw !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoFocusInputEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.removeItem(KEY);
|
||||
} else {
|
||||
localStorage.setItem(KEY, 'false');
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when auto-focus should fire: the setting is enabled
|
||||
* AND the viewport is wide enough that focusing won't summon a
|
||||
* mobile keyboard (matches the md: Tailwind breakpoint).
|
||||
*/
|
||||
export function shouldAutoFocusInput(): boolean {
|
||||
return getAutoFocusInputEnabled() && window.innerWidth >= 768;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'ses
|
||||
|
||||
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
|
||||
|
||||
const KNOWN_PAYLOAD_TYPES = [
|
||||
export const KNOWN_PAYLOAD_TYPES = [
|
||||
'Advert',
|
||||
'GroupText',
|
||||
'TextMessage',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -14,7 +14,7 @@ dependencies = [
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.3.2",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.7",
|
||||
"apprise>=1.9.8",
|
||||
"boto3>=1.38.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump the REST OpenAPI spec and WebSocket event schemas to JSON files.
|
||||
|
||||
These artifacts are generated programmatically from the running codebase so
|
||||
they stay in sync with the actual API and WS contracts. They're intended for
|
||||
consumption by external integrations (e.g., Home Assistant) that need a stable
|
||||
reference without reading our source.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=. uv run python3 scripts/build/dump_api_specs.py [output_dir]
|
||||
|
||||
Output (default: references/ha/):
|
||||
openapi.json — Full OpenAPI 3.x spec for all REST endpoints
|
||||
ws_events.json — JSON Schema for each WebSocket event type
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def dump_openapi(output_dir: Path) -> None:
|
||||
from app.main import app
|
||||
|
||||
schema = app.openapi()
|
||||
out = output_dir / "openapi.json"
|
||||
out.write_text(json.dumps(schema, indent=2) + "\n")
|
||||
print(f" openapi.json: {len(schema['paths'])} paths, "
|
||||
f"{len(schema.get('components', {}).get('schemas', {}))} schemas")
|
||||
|
||||
|
||||
def dump_ws_events(output_dir: Path) -> None:
|
||||
from app.events import _PAYLOAD_ADAPTERS
|
||||
|
||||
events: dict = {}
|
||||
for event_type, adapter in _PAYLOAD_ADAPTERS.items():
|
||||
schema = adapter.json_schema()
|
||||
events[event_type] = {
|
||||
"description": _event_descriptions().get(event_type, ""),
|
||||
"payload_schema": schema,
|
||||
}
|
||||
|
||||
wrapper = {
|
||||
"$comment": (
|
||||
"Auto-generated from app/events.py. "
|
||||
"Each WebSocket message is a JSON object: {\"type\": \"<event_type>\", \"data\": <payload>}. "
|
||||
"The client also sends \"ping\" as plain text; the server replies {\"type\": \"pong\"}."
|
||||
),
|
||||
"events": events,
|
||||
}
|
||||
|
||||
out = output_dir / "ws_events.json"
|
||||
out.write_text(json.dumps(wrapper, indent=2) + "\n")
|
||||
print(f" ws_events.json: {len(events)} event types")
|
||||
|
||||
|
||||
def _event_descriptions() -> dict[str, str]:
|
||||
return {
|
||||
"health": "Radio connection status. Sent on WS connect and on every state change.",
|
||||
"message": "New or incoming message (DM or channel). Includes outgoing messages sent by this radio.",
|
||||
"contact": "Contact created or updated (from advertisements, radio sync, or API).",
|
||||
"contact_resolved": "A prefix-only placeholder contact was resolved to a full public key.",
|
||||
"channel": "Channel created or updated.",
|
||||
"contact_deleted": "A contact was removed from the database.",
|
||||
"channel_deleted": "A channel was removed from the database.",
|
||||
"raw_packet": "Every incoming RF packet (pre-decryption). Use observation_id as the dedup key, not id.",
|
||||
"message_acked": "An existing message received an ACK or echo/repeat update.",
|
||||
"error": "Toast-level error notification (e.g., radio setup failure, missing private key).",
|
||||
"success": "Toast-level success notification (e.g., historical decrypt complete).",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("references/ha")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Dumping API specs to {output_dir}/")
|
||||
dump_openapi(output_dir)
|
||||
dump_ws_events(output_dir)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,7 +35,7 @@ SERIAL_HOST_PATH="/dev/ttyACM0"
|
||||
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
|
||||
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
|
||||
TCP_HOST=""
|
||||
TCP_PORT="4000"
|
||||
TCP_PORT="5000"
|
||||
BLE_ADDRESS=""
|
||||
BLE_PIN=""
|
||||
ENABLE_BOTS="N"
|
||||
@@ -311,8 +311,8 @@ case "$TRANSPORT_CHOICE" in
|
||||
echo -e "${RED}TCP host is required.${NC}"
|
||||
read -r -p "TCP host: " TCP_HOST
|
||||
done
|
||||
read -r -p "TCP port (default: 4000): " TCP_PORT
|
||||
TCP_PORT="${TCP_PORT:-4000}"
|
||||
read -r -p "TCP port (default: 5000): " TCP_PORT
|
||||
TCP_PORT="${TCP_PORT:-5000}"
|
||||
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
|
||||
;;
|
||||
3)
|
||||
|
||||
@@ -114,8 +114,8 @@ case "$TRANSPORT_CHOICE" in
|
||||
echo -e "${RED}TCP host is required.${NC}"
|
||||
read -rp "TCP host: " TCP_HOST
|
||||
done
|
||||
read -rp "TCP port (default: 4000): " TCP_PORT
|
||||
TCP_PORT="${TCP_PORT:-4000}"
|
||||
read -rp "TCP port (default: 5000): " TCP_PORT
|
||||
TCP_PORT="${TCP_PORT:-5000}"
|
||||
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
|
||||
;;
|
||||
4)
|
||||
|
||||
@@ -127,6 +127,34 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
assert data["id"] == "https://mesh.example.com:8443/"
|
||||
|
||||
|
||||
def test_webmanifest_includes_forwarded_prefix(tmp_path):
|
||||
app = FastAPI()
|
||||
dist_dir = tmp_path / "frontend" / "dist"
|
||||
dist_dir.mkdir(parents=True)
|
||||
(dist_dir / "index.html").write_text("<html><body>index page</body></html>")
|
||||
|
||||
registered = register_frontend_static_routes(app, dist_dir)
|
||||
assert registered is True
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/site.webmanifest",
|
||||
headers={
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "homeassistant.local:8123",
|
||||
"x-forwarded-prefix": "/api/hassio_ingress/abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
expected_base = "https://homeassistant.local:8123/api/hassio_ingress/abc123/"
|
||||
assert data["start_url"] == expected_base
|
||||
assert data["scope"] == expected_base
|
||||
assert data["id"] == expected_base
|
||||
assert data["icons"][0]["src"] == f"{expected_base}web-app-manifest-192x192.png"
|
||||
|
||||
|
||||
def test_first_available_prefers_dist_over_prebuilt(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
|
||||
@@ -43,6 +43,7 @@ class TestStatisticsEmpty:
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
assert result["packets_per_hour_72h"] == []
|
||||
|
||||
|
||||
class TestStatisticsCounts:
|
||||
@@ -397,6 +398,54 @@ class TestPathHashWidthStats:
|
||||
assert breakdown["triple_byte"] == 1
|
||||
|
||||
|
||||
class TestPacketsPerHour:
|
||||
@pytest.mark.asyncio
|
||||
async def test_buckets_packets_by_hour(self, test_db):
|
||||
"""Packets within 72h are bucketed by hour."""
|
||||
now = int(time.time())
|
||||
hour_start = (now // 3600) * 3600
|
||||
conn = test_db.conn
|
||||
|
||||
# 3 packets in the current hour, 1 in the previous hour
|
||||
for i in range(3):
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(hour_start + i, b"\x01", bytes([i]) * 32),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(hour_start - 1800, b"\x02", b"\xaa" * 32),
|
||||
)
|
||||
# 1 packet outside the 72h window — should be excluded
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(now - 260000, b"\x03", b"\xbb" * 32),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
buckets = result["packets_per_hour_72h"]
|
||||
|
||||
assert len(buckets) == 2
|
||||
by_ts = {b["timestamp"]: b["count"] for b in buckets}
|
||||
assert by_ts[hour_start] == 3
|
||||
assert by_ts[hour_start - 3600] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_when_no_recent_packets(self, test_db):
|
||||
"""Returns empty list when all packets are older than 72h."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(now - 300000, b"\x01", b"\x01" * 32),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
assert result["packets_per_hour_72h"] == []
|
||||
|
||||
|
||||
class TestStatisticsEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
|
||||
|
||||
@@ -56,7 +56,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.9.7"
|
||||
version = "1.9.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -67,9 +67,9 @@ dependencies = [
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/97dc06b3401bb67abcef6e8bef7155f192b75795c2a2aa4d59eb5aa7fa66/apprise-1.9.7.tar.gz", hash = "sha256:2f73cc1e0264fb119fdb9b7cde82e8fde40a0f531ac885d8c6f0cf0f6e13aec2", size = 1937173 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -983,7 +983,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomqtt" },
|
||||
@@ -1022,7 +1022,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aiomqtt", specifier = ">=2.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.19.0" },
|
||||
{ name = "apprise", specifier = ">=1.9.7" },
|
||||
{ name = "apprise", specifier = ">=1.9.8" },
|
||||
{ name = "boto3", specifier = ">=1.38.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
|
||||
Reference in New Issue
Block a user