mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 04:16:05 +02:00
Compare commits
24 Commits
3.6.7
...
testbranch
| Author | SHA1 | Date | |
|---|---|---|---|
| a2547d7f62 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c |
+13
-3
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
|||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
message_id INTEGER,
|
message_id INTEGER,
|
||||||
payload_hash BLOB,
|
payload_hash BLOB,
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||||
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
|||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||||
UNIQUE(public_key, path_hex, path_len),
|
UNIQUE(public_key, path_hex, path_len),
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||||
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
|
|||||||
first_seen INTEGER NOT NULL,
|
first_seen INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
UNIQUE(public_key, name),
|
UNIQUE(public_key, name),
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||||
@@ -132,6 +132,12 @@ class Database:
|
|||||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||||
|
|
||||||
|
# Foreign key enforcement: must be set per-connection (not persisted).
|
||||||
|
# Disabled during schema init and migrations to avoid issues with
|
||||||
|
# historical table-rebuild migrations that may temporarily violate
|
||||||
|
# constraints, then re-enabled for all subsequent application queries.
|
||||||
|
await self._connection.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
|
||||||
await self._connection.executescript(SCHEMA)
|
await self._connection.executescript(SCHEMA)
|
||||||
await self._connection.commit()
|
await self._connection.commit()
|
||||||
logger.debug("Database schema initialized")
|
logger.debug("Database schema initialized")
|
||||||
@@ -141,6 +147,10 @@ class Database:
|
|||||||
|
|
||||||
await run_migrations(self._connection)
|
await run_migrations(self._connection)
|
||||||
|
|
||||||
|
# Enable FK enforcement for all application queries from this point on.
|
||||||
|
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
logger.debug("Foreign key enforcement enabled")
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
if self._connection:
|
if self._connection:
|
||||||
await self._connection.close()
|
await self._connection.close()
|
||||||
|
|||||||
+222
-2
@@ -367,6 +367,28 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 47)
|
await set_version(conn, 47)
|
||||||
applied += 1
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 48: Add discovery_blocked_types column to app_settings
|
||||||
|
if version < 48:
|
||||||
|
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
|
||||||
|
await _migrate_048_discovery_blocked_types(conn)
|
||||||
|
await set_version(conn, 48)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 49: Enable foreign key enforcement — rebuild tables with
|
||||||
|
# CASCADE / SET NULL and clean up any orphaned rows first.
|
||||||
|
if version < 49:
|
||||||
|
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
|
||||||
|
await _migrate_049_foreign_key_cascade(conn)
|
||||||
|
await set_version(conn, 49)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 50: Repeater telemetry history table
|
||||||
|
if version < 50:
|
||||||
|
logger.info("Applying migration 50: repeater telemetry history table")
|
||||||
|
await _migrate_050_repeater_telemetry_history(conn)
|
||||||
|
await set_version(conn, 50)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
if applied > 0:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -829,7 +851,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
max_radio_contacts INTEGER DEFAULT 200,
|
max_radio_contacts INTEGER DEFAULT 200,
|
||||||
favorites TEXT DEFAULT '[]',
|
favorites TEXT DEFAULT '[]',
|
||||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||||
last_message_times TEXT DEFAULT '{}',
|
last_message_times TEXT DEFAULT '{}',
|
||||||
preferences_migrated INTEGER DEFAULT 0
|
preferences_migrated INTEGER DEFAULT 0
|
||||||
@@ -841,7 +863,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
||||||
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
|
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2909,3 +2931,201 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add discovery_blocked_types column to app_settings.
|
||||||
|
|
||||||
|
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
|
||||||
|
3=Room, 4=Sensor) whose advertisements should not create new contacts.
|
||||||
|
Empty list means all types are accepted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "duplicate column" in error_msg:
|
||||||
|
logger.debug("discovery_blocked_types column already exists, skipping")
|
||||||
|
elif "no such table" in error_msg:
|
||||||
|
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
|
||||||
|
|
||||||
|
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
|
||||||
|
Orphaned child rows are cleaned up before the rebuild to ensure the
|
||||||
|
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Back up the database before table rebuilds (skip for in-memory DBs).
|
||||||
|
cursor = await conn.execute("PRAGMA database_list")
|
||||||
|
db_row = await cursor.fetchone()
|
||||||
|
db_path = db_row[2] if db_row else ""
|
||||||
|
if db_path and db_path != ":memory:" and Path(db_path).exists():
|
||||||
|
backup_path = db_path + ".pre-fk-migration.bak"
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
src = Path(db_path + suffix)
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(str(src), backup_path + suffix)
|
||||||
|
logger.info("Database backed up to %s before FK migration", backup_path)
|
||||||
|
|
||||||
|
# --- Phase 1: clean orphans (guard each table's existence) ---
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||||
|
|
||||||
|
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_advert_paths "
|
||||||
|
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||||
|
)
|
||||||
|
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_name_history "
|
||||||
|
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||||
|
)
|
||||||
|
if "raw_packets" in existing_tables and "messages" in existing_tables:
|
||||||
|
# Guard: message_id column may not exist on very old schemas
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_cols = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "message_id" in raw_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
|
||||||
|
"AND message_id NOT IN (SELECT id FROM messages)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Cleaned orphaned child rows before FK rebuild")
|
||||||
|
|
||||||
|
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
|
||||||
|
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
|
||||||
|
raw_has_message_id = False
|
||||||
|
if "raw_packets" in existing_tables:
|
||||||
|
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
|
||||||
|
|
||||||
|
if raw_has_message_id:
|
||||||
|
# Dynamically build column list based on what the old table actually has,
|
||||||
|
# since very old schemas may lack payload_hash (added in migration 28).
|
||||||
|
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
old_cols = [row[1] for row in await col_cursor3.fetchall()]
|
||||||
|
|
||||||
|
new_col_defs = [
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"timestamp INTEGER NOT NULL",
|
||||||
|
"data BLOB NOT NULL",
|
||||||
|
"message_id INTEGER",
|
||||||
|
]
|
||||||
|
copy_cols = ["id", "timestamp", "data", "message_id"]
|
||||||
|
if "payload_hash" in old_cols:
|
||||||
|
new_col_defs.append("payload_hash BLOB")
|
||||||
|
copy_cols.append("payload_hash")
|
||||||
|
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
|
||||||
|
|
||||||
|
cols_sql = ", ".join(new_col_defs)
|
||||||
|
copy_sql = ", ".join(copy_cols)
|
||||||
|
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
|
||||||
|
await conn.execute(
|
||||||
|
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE raw_packets")
|
||||||
|
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||||
|
)
|
||||||
|
if "payload_hash" in old_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
|
||||||
|
|
||||||
|
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
|
||||||
|
if "contact_advert_paths" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact_advert_paths_fk (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
path_hex TEXT NOT NULL,
|
||||||
|
path_len INTEGER NOT NULL,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(public_key, path_hex, path_len),
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
|
||||||
|
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE contact_advert_paths")
|
||||||
|
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||||
|
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
|
||||||
|
|
||||||
|
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
|
||||||
|
if "contact_name_history" in existing_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact_name_history_fk (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
first_seen INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
UNIQUE(public_key, name),
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
|
||||||
|
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE contact_name_history")
|
||||||
|
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
|
||||||
|
"ON contact_name_history(public_key, last_seen DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots.
|
||||||
|
|
||||||
|
Uses ON DELETE CASCADE so contact deletion automatically cleans up rows.
|
||||||
|
"""
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_history_key_ts "
|
||||||
|
"ON repeater_telemetry_history(public_key, timestamp DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|||||||
+16
-1
@@ -530,6 +530,14 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
full_events: int = Field(description="Full event queue count")
|
||||||
|
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||||
|
default_factory=list, description="Recent telemetry history snapshots"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryHistoryEntry(BaseModel):
|
||||||
|
timestamp: int
|
||||||
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
class RepeaterNodeInfoResponse(BaseModel):
|
class RepeaterNodeInfoResponse(BaseModel):
|
||||||
@@ -805,7 +813,7 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list, description="List of favorited conversations"
|
default_factory=list, description="List of favorited conversations"
|
||||||
)
|
)
|
||||||
auto_decrypt_dm_on_advert: bool = Field(
|
auto_decrypt_dm_on_advert: bool = Field(
|
||||||
default=False,
|
default=True,
|
||||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||||
)
|
)
|
||||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
||||||
@@ -840,6 +848,13 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Display names whose messages are hidden from the UI",
|
description="Display names whose messages are hidden from the UI",
|
||||||
)
|
)
|
||||||
|
discovery_blocked_types: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description=(
|
||||||
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts; existing contacts are still updated"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FanoutConfig(BaseModel):
|
class FanoutConfig(BaseModel):
|
||||||
|
|||||||
+24
-8
@@ -462,14 +462,19 @@ async def _process_advertisement(
|
|||||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep recent unique advert paths for all contacts.
|
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||||
await ContactAdvertPathRepository.record_observation(
|
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||||
public_key=advert.public_key.lower(),
|
if existing is None and contact_type > 0:
|
||||||
path_hex=new_path_hex,
|
from app.repository import AppSettingsRepository
|
||||||
timestamp=timestamp,
|
|
||||||
max_paths=10,
|
settings = await AppSettingsRepository.get()
|
||||||
hop_count=new_path_len,
|
if contact_type in settings.discovery_blocked_types:
|
||||||
)
|
logger.debug(
|
||||||
|
"Skipping new contact %s: type %d is in discovery_blocked_types",
|
||||||
|
advert.public_key[:12],
|
||||||
|
contact_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=advert.public_key.lower(),
|
public_key=advert.public_key.lower(),
|
||||||
@@ -482,7 +487,18 @@ async def _process_advertisement(
|
|||||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Upsert the contact BEFORE recording advert paths so the parent row
|
||||||
|
# exists when foreign key enforcement is enabled.
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
|
|
||||||
|
# Keep recent unique advert paths for all contacts.
|
||||||
|
await ContactAdvertPathRepository.record_observation(
|
||||||
|
public_key=advert.public_key.lower(),
|
||||||
|
path_hex=new_path_hex,
|
||||||
|
timestamp=timestamp,
|
||||||
|
max_paths=10,
|
||||||
|
hop_count=new_path_len,
|
||||||
|
)
|
||||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=advert.public_key,
|
public_key=advert.public_key,
|
||||||
log=logger,
|
log=logger,
|
||||||
|
|||||||
+35
-4
@@ -29,7 +29,10 @@ from app.repository import (
|
|||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
from app.services.contact_reconciliation import (
|
||||||
|
promote_prefix_contacts_for_contact,
|
||||||
|
reconcile_contact_messages,
|
||||||
|
)
|
||||||
from app.services.messages import create_fallback_channel_message
|
from app.services.messages import create_fallback_channel_message
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.websocket import broadcast_error, broadcast_event
|
from app.websocket import broadcast_error, broadcast_event
|
||||||
@@ -63,13 +66,25 @@ async def _reconcile_contact_messages_background(
|
|||||||
public_key: str,
|
public_key: str,
|
||||||
contact_name: str | None,
|
contact_name: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run contact/message reconciliation outside the radio critical path."""
|
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
|
||||||
try:
|
try:
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
await reconcile_contact_messages(
|
await reconcile_contact_messages(
|
||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
contact_name=contact_name,
|
contact_name=contact_name,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
if promoted_keys:
|
||||||
|
contact = await ContactRepository.get_by_key(public_key.lower())
|
||||||
|
if contact is not None:
|
||||||
|
for old_key in promoted_keys:
|
||||||
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": contact.model_dump()},
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Background contact reconciliation failed for %s: %s",
|
"Background contact reconciliation failed for %s: %s",
|
||||||
@@ -179,6 +194,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
|
|||||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_radio_capacity(configured: int) -> int:
|
||||||
|
"""Return the effective radio contact capacity.
|
||||||
|
|
||||||
|
Uses the lower of the user-configured ``max_radio_contacts`` and the
|
||||||
|
hardware limit reported by the radio at connect time. The existing
|
||||||
|
80% refill ratio already reserves headroom for the radio to
|
||||||
|
organically add contacts it hears via adverts, so no additional
|
||||||
|
reduction is applied here.
|
||||||
|
"""
|
||||||
|
capacity = max(1, configured)
|
||||||
|
hw_limit = radio_manager.max_contacts
|
||||||
|
if hw_limit is not None:
|
||||||
|
capacity = min(capacity, hw_limit)
|
||||||
|
return max(1, capacity)
|
||||||
|
|
||||||
|
|
||||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||||
capacity = max(1, max_contacts)
|
capacity = max(1, max_contacts)
|
||||||
@@ -193,7 +224,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
|||||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
app_settings = await AppSettingsRepository.get()
|
||||||
capacity = app_settings.max_radio_contacts
|
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||||
|
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts()
|
||||||
@@ -1301,7 +1332,7 @@ async def stop_background_contact_reconciliation() -> None:
|
|||||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||||
"""Return the contacts that would be loaded onto the radio right now."""
|
"""Return the contacts that would be loaded onto the radio right now."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
app_settings = await AppSettingsRepository.get()
|
||||||
max_contacts = app_settings.max_radio_contacts
|
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||||
selected_contacts: list[Contact] = []
|
selected_contacts: list[Contact] = []
|
||||||
selected_keys: set[str] = set()
|
selected_keys: set[str] = set()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
|||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
from app.repository.messages import MessageRepository
|
from app.repository.messages import MessageRepository
|
||||||
from app.repository.raw_packets import RawPacketRepository
|
from app.repository.raw_packets import RawPacketRepository
|
||||||
|
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,5 +21,6 @@ __all__ = [
|
|||||||
"FanoutConfigRepository",
|
"FanoutConfigRepository",
|
||||||
"MessageRepository",
|
"MessageRepository",
|
||||||
"RawPacketRepository",
|
"RawPacketRepository",
|
||||||
|
"RepeaterTelemetryRepository",
|
||||||
"StatisticsRepository",
|
"StatisticsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
+63
-51
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,6 +13,8 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||||
"""Raised when a public key prefix matches multiple contacts."""
|
"""Raised when a public key prefix matches multiple contacts."""
|
||||||
@@ -484,7 +487,6 @@ class ContactRepository:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
promoted_keys: list[str] = []
|
promoted_keys: list[str] = []
|
||||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
old_key = row["public_key"]
|
old_key = row["public_key"]
|
||||||
@@ -501,60 +503,70 @@ class ContactRepository:
|
|||||||
(old_key,),
|
(old_key,),
|
||||||
)
|
)
|
||||||
match_row = await match_cursor.fetchone()
|
match_row = await match_cursor.fetchone()
|
||||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
match_count = match_row["match_count"] if match_row is not None else 0
|
||||||
|
if match_count != 1:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
|
||||||
|
old_key,
|
||||||
|
match_count,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await migrate_child_rows(old_key, normalized_full_key)
|
await migrate_child_rows(old_key, normalized_full_key)
|
||||||
|
|
||||||
if full_exists:
|
# Merge timestamp metadata from the old prefix contact into the
|
||||||
await db.conn.execute(
|
# full-key contact (which all callers guarantee already exists),
|
||||||
"""
|
# then delete the prefix placeholder.
|
||||||
UPDATE contacts
|
await db.conn.execute(
|
||||||
SET last_seen = CASE
|
"""
|
||||||
WHEN contacts.last_seen IS NULL THEN ?
|
UPDATE contacts
|
||||||
WHEN ? IS NULL THEN contacts.last_seen
|
SET last_seen = CASE
|
||||||
WHEN ? > contacts.last_seen THEN ?
|
WHEN contacts.last_seen IS NULL THEN ?
|
||||||
ELSE contacts.last_seen
|
WHEN ? IS NULL THEN contacts.last_seen
|
||||||
END,
|
WHEN ? > contacts.last_seen THEN ?
|
||||||
last_contacted = CASE
|
ELSE contacts.last_seen
|
||||||
WHEN contacts.last_contacted IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.last_contacted
|
last_contacted = CASE
|
||||||
WHEN ? > contacts.last_contacted THEN ?
|
WHEN contacts.last_contacted IS NULL THEN ?
|
||||||
ELSE contacts.last_contacted
|
WHEN ? IS NULL THEN contacts.last_contacted
|
||||||
END,
|
WHEN ? > contacts.last_contacted THEN ?
|
||||||
first_seen = CASE
|
ELSE contacts.last_contacted
|
||||||
WHEN contacts.first_seen IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.first_seen
|
first_seen = CASE
|
||||||
WHEN ? < contacts.first_seen THEN ?
|
WHEN contacts.first_seen IS NULL THEN ?
|
||||||
ELSE contacts.first_seen
|
WHEN ? IS NULL THEN contacts.first_seen
|
||||||
END,
|
WHEN ? < contacts.first_seen THEN ?
|
||||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
ELSE contacts.first_seen
|
||||||
WHERE public_key = ?
|
END,
|
||||||
""",
|
last_read_at = CASE
|
||||||
(
|
WHEN contacts.last_read_at IS NULL THEN ?
|
||||||
row["last_seen"],
|
WHEN ? IS NULL THEN contacts.last_read_at
|
||||||
row["last_seen"],
|
WHEN ? > contacts.last_read_at THEN ?
|
||||||
row["last_seen"],
|
ELSE contacts.last_read_at
|
||||||
row["last_seen"],
|
END
|
||||||
row["last_contacted"],
|
WHERE public_key = ?
|
||||||
row["last_contacted"],
|
""",
|
||||||
row["last_contacted"],
|
(
|
||||||
row["last_contacted"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_contacted"],
|
||||||
row["last_read_at"],
|
row["last_contacted"],
|
||||||
normalized_full_key,
|
row["last_contacted"],
|
||||||
),
|
row["last_contacted"],
|
||||||
)
|
row["first_seen"],
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
row["first_seen"],
|
||||||
else:
|
row["first_seen"],
|
||||||
await db.conn.execute(
|
row["first_seen"],
|
||||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
row["last_read_at"],
|
||||||
(normalized_full_key, old_key),
|
row["last_read_at"],
|
||||||
)
|
row["last_read_at"],
|
||||||
full_exists = True
|
row["last_read_at"],
|
||||||
|
normalized_full_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||||
|
|
||||||
promoted_keys.append(old_key)
|
promoted_keys.append(old_key)
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,11 @@ class MessageRepository:
|
|||||||
"""
|
"""
|
||||||
lower_key = full_key.lower()
|
lower_key = full_key.lower()
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""UPDATE messages SET conversation_key = ?
|
"""UPDATE messages SET conversation_key = ?,
|
||||||
|
sender_key = CASE
|
||||||
|
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
|
||||||
|
AND ? LIKE sender_key || '%'
|
||||||
|
THEN ? ELSE sender_key END
|
||||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||||
AND ? LIKE conversation_key || '%'
|
AND ? LIKE conversation_key || '%'
|
||||||
AND (
|
AND (
|
||||||
@@ -166,7 +170,7 @@ class MessageRepository:
|
|||||||
WHERE length(public_key) = 64
|
WHERE length(public_key) = 64
|
||||||
AND public_key LIKE messages.conversation_key || '%'
|
AND public_key LIKE messages.conversation_key || '%'
|
||||||
) = 1""",
|
) = 1""",
|
||||||
(lower_key, lower_key),
|
(lower_key, lower_key, lower_key, lower_key),
|
||||||
)
|
)
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
@@ -572,6 +576,9 @@ class MessageRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_by_id(message_id: int) -> None:
|
async def delete_by_id(message_id: int) -> None:
|
||||||
"""Delete a message row by ID."""
|
"""Delete a message row by ID."""
|
||||||
|
await db.conn.execute(
|
||||||
|
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
|
||||||
|
)
|
||||||
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum age for telemetry history entries (30 days)
|
||||||
|
_MAX_AGE_SECONDS = 30 * 86400
|
||||||
|
|
||||||
|
# Maximum entries to keep per repeater (sanity cap)
|
||||||
|
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class RepeaterTelemetryRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def record(
|
||||||
|
public_key: str,
|
||||||
|
timestamp: int,
|
||||||
|
data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Insert a telemetry history row and prune stale entries."""
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO repeater_telemetry_history
|
||||||
|
(public_key, timestamp, data)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(public_key, timestamp, json.dumps(data)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prune entries older than 30 days
|
||||||
|
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||||
|
await db.conn.execute(
|
||||||
|
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||||
|
(public_key, cutoff),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ? AND id NOT IN (
|
||||||
|
SELECT id FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.conn.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||||
|
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timestamp, data
|
||||||
|
FROM repeater_telemetry_history
|
||||||
|
WHERE public_key = ? AND timestamp >= ?
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
""",
|
||||||
|
(public_key, since_timestamp),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"data": json.loads(row["data"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
@@ -29,7 +29,7 @@ class AppSettingsRepository:
|
|||||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||||
advert_interval, last_advert_time, flood_scope,
|
advert_interval, last_advert_time, flood_scope,
|
||||||
blocked_keys, blocked_names
|
blocked_keys, blocked_names, discovery_blocked_types
|
||||||
FROM app_settings WHERE id = 1
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -81,6 +81,14 @@ class AppSettingsRepository:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
blocked_names = []
|
blocked_names = []
|
||||||
|
|
||||||
|
# Parse discovery_blocked_types JSON
|
||||||
|
discovery_blocked_types: list[int] = []
|
||||||
|
if row["discovery_blocked_types"]:
|
||||||
|
try:
|
||||||
|
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
discovery_blocked_types = []
|
||||||
|
|
||||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||||
sort_order = row["sidebar_sort_order"]
|
sort_order = row["sidebar_sort_order"]
|
||||||
if sort_order not in ("recent", "alpha"):
|
if sort_order not in ("recent", "alpha"):
|
||||||
@@ -98,6 +106,7 @@ class AppSettingsRepository:
|
|||||||
flood_scope=row["flood_scope"] or "",
|
flood_scope=row["flood_scope"] or "",
|
||||||
blocked_keys=blocked_keys,
|
blocked_keys=blocked_keys,
|
||||||
blocked_names=blocked_names,
|
blocked_names=blocked_names,
|
||||||
|
discovery_blocked_types=discovery_blocked_types,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -113,6 +122,7 @@ class AppSettingsRepository:
|
|||||||
flood_scope: str | None = None,
|
flood_scope: str | None = None,
|
||||||
blocked_keys: list[str] | None = None,
|
blocked_keys: list[str] | None = None,
|
||||||
blocked_names: list[str] | None = None,
|
blocked_names: list[str] | None = None,
|
||||||
|
discovery_blocked_types: list[int] | None = None,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
updates = []
|
updates = []
|
||||||
@@ -163,6 +173,10 @@ class AppSettingsRepository:
|
|||||||
updates.append("blocked_names = ?")
|
updates.append("blocked_names = ?")
|
||||||
params.append(json.dumps(blocked_names))
|
params.append(json.dumps(blocked_names))
|
||||||
|
|
||||||
|
if discovery_blocked_types is not None:
|
||||||
|
updates.append("discovery_blocked_types = ?")
|
||||||
|
params.append(json.dumps(discovery_blocked_types))
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||||
await db.conn.execute(query, params)
|
await db.conn.execute(query, params)
|
||||||
|
|||||||
+224
-47
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.channel_constants import (
|
from app.channel_constants import (
|
||||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
|||||||
is_public_channel_key,
|
is_public_channel_key,
|
||||||
is_public_channel_name,
|
is_public_channel_name,
|
||||||
)
|
)
|
||||||
|
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||||
|
from app.packet_processor import create_message_from_decrypted
|
||||||
from app.region_scope import normalize_region_scope
|
from app.region_scope import normalize_region_scope
|
||||||
from app.repository import ChannelRepository, MessageRepository
|
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||||
from app.websocket import broadcast_event
|
from app.websocket import broadcast_event, broadcast_success
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||||
@@ -31,12 +34,154 @@ class CreateChannelRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||||
|
channel_names: list[str] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="List of hashtag room names. Leading # is optional per entry.",
|
||||||
|
)
|
||||||
|
try_historical: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||||
|
created_channels: list[Channel]
|
||||||
|
existing_count: int
|
||||||
|
invalid_names: list[str]
|
||||||
|
decrypt_started: bool = False
|
||||||
|
decrypt_total_packets: int = 0
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||||
flood_scope_override: str = Field(
|
flood_scope_override: str = Field(
|
||||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_channel_identity(
|
||||||
|
requested_name: str,
|
||||||
|
request_key: str | None = None,
|
||||||
|
) -> tuple[str, str, bool]:
|
||||||
|
is_hashtag = requested_name.startswith("#")
|
||||||
|
|
||||||
|
if is_public_channel_name(requested_name):
|
||||||
|
if request_key:
|
||||||
|
try:
|
||||||
|
key_bytes = bytes.fromhex(request_key)
|
||||||
|
if len(key_bytes) != 16:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||||
|
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||||
|
)
|
||||||
|
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||||
|
|
||||||
|
if request_key and not is_hashtag:
|
||||||
|
try:
|
||||||
|
key_bytes = bytes.fromhex(request_key)
|
||||||
|
if len(key_bytes) != 16:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||||
|
key_hex = key_bytes.hex().upper()
|
||||||
|
if is_public_channel_key(key_hex):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||||
|
)
|
||||||
|
return key_hex, requested_name, False
|
||||||
|
|
||||||
|
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||||
|
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||||
|
trimmed = name.strip()
|
||||||
|
if not trimmed:
|
||||||
|
return None
|
||||||
|
normalized = trimmed.lstrip("#").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if len(normalized) > 31:
|
||||||
|
return None
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||||
|
return None
|
||||||
|
return f"#{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_historical_channel_decryption_for_channels(
|
||||||
|
channels: list[tuple[bytes, str, str]],
|
||||||
|
) -> None:
|
||||||
|
packets = await RawPacketRepository.get_all_undecrypted()
|
||||||
|
total = len(packets)
|
||||||
|
decrypted_count = 0
|
||||||
|
matched_channel_names: set[str] = set()
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||||
|
total,
|
||||||
|
len(channels),
|
||||||
|
)
|
||||||
|
|
||||||
|
for packet_id, packet_data, packet_timestamp in packets:
|
||||||
|
packet_info = parse_packet(packet_data)
|
||||||
|
path_hex = packet_info.path.hex() if packet_info else None
|
||||||
|
path_len = packet_info.path_length if packet_info else None
|
||||||
|
|
||||||
|
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||||
|
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_id = await create_message_from_decrypted(
|
||||||
|
packet_id=packet_id,
|
||||||
|
channel_key=channel_key_hex,
|
||||||
|
channel_name=channel_name,
|
||||||
|
sender=result.sender,
|
||||||
|
message_text=result.message,
|
||||||
|
timestamp=result.timestamp,
|
||||||
|
received_at=packet_timestamp,
|
||||||
|
path=path_hex,
|
||||||
|
path_len=path_len,
|
||||||
|
realtime=False,
|
||||||
|
)
|
||||||
|
if msg_id is not None:
|
||||||
|
decrypted_count += 1
|
||||||
|
matched_channel_names.add(channel_name)
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||||
|
decrypted_count,
|
||||||
|
total,
|
||||||
|
len(matched_channel_names),
|
||||||
|
)
|
||||||
|
|
||||||
|
if decrypted_count > 0:
|
||||||
|
broadcast_success(
|
||||||
|
"Bulk historical decrypt complete",
|
||||||
|
(
|
||||||
|
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||||
|
f"across {len(matched_channel_names)} room"
|
||||||
|
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[Channel])
|
@router.get("", response_model=list[Channel])
|
||||||
async def list_channels() -> list[Channel]:
|
async def list_channels() -> list[Channel]:
|
||||||
"""List all channels from the database."""
|
"""List all channels from the database."""
|
||||||
@@ -69,50 +214,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
automatically when sending a message (see messages.py send_channel_message).
|
automatically when sending a message (see messages.py send_channel_message).
|
||||||
"""
|
"""
|
||||||
requested_name = request.name
|
requested_name = request.name
|
||||||
is_hashtag = requested_name.startswith("#")
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||||
|
|
||||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
|
||||||
# and the well-known Public key cannot be renamed to something else.
|
|
||||||
if is_public_channel_name(requested_name):
|
|
||||||
if request.key:
|
|
||||||
try:
|
|
||||||
key_bytes = bytes.fromhex(request.key)
|
|
||||||
if len(key_bytes) != 16:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
||||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
|
||||||
)
|
|
||||||
key_hex = PUBLIC_CHANNEL_KEY
|
|
||||||
channel_name = PUBLIC_CHANNEL_NAME
|
|
||||||
is_hashtag = False
|
|
||||||
elif request.key and not is_hashtag:
|
|
||||||
try:
|
|
||||||
key_bytes = bytes.fromhex(request.key)
|
|
||||||
if len(key_bytes) != 16:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
|
||||||
key_hex = key_bytes.hex().upper()
|
|
||||||
if is_public_channel_key(key_hex):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
|
||||||
)
|
|
||||||
channel_name = requested_name
|
|
||||||
else:
|
|
||||||
# Derive key from name hash (same as meshcore library does)
|
|
||||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
|
||||||
key_hex = key_bytes.hex().upper()
|
|
||||||
channel_name = requested_name
|
|
||||||
|
|
||||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||||
|
|
||||||
@@ -132,6 +234,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||||
|
async def bulk_create_hashtag_channels(
|
||||||
|
request: BulkCreateHashtagChannelsRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
response: Response,
|
||||||
|
) -> BulkCreateHashtagChannelsResponse:
|
||||||
|
created_channels: list[Channel] = []
|
||||||
|
existing_count = 0
|
||||||
|
invalid_names: list[str] = []
|
||||||
|
decrypt_started = False
|
||||||
|
decrypt_total_packets = 0
|
||||||
|
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||||
|
|
||||||
|
for raw_name in request.channel_names:
|
||||||
|
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||||
|
if normalized_name is None:
|
||||||
|
invalid_names.append(raw_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||||
|
existing = await ChannelRepository.get_by_key(key_hex)
|
||||||
|
if existing is not None:
|
||||||
|
existing_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
await ChannelRepository.upsert(
|
||||||
|
key=key_hex,
|
||||||
|
name=channel_name,
|
||||||
|
is_hashtag=is_hashtag,
|
||||||
|
on_radio=False,
|
||||||
|
)
|
||||||
|
stored = await ChannelRepository.get_by_key(key_hex)
|
||||||
|
if stored is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Channel was created but could not be reloaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_channels.append(stored)
|
||||||
|
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||||
|
_broadcast_channel_update(stored)
|
||||||
|
|
||||||
|
if request.try_historical and decrypt_targets:
|
||||||
|
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||||
|
if decrypt_total_packets > 0:
|
||||||
|
background_tasks.add_task(
|
||||||
|
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||||
|
)
|
||||||
|
decrypt_started = True
|
||||||
|
response.status_code = status.HTTP_202_ACCEPTED
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||||
|
if created_channels
|
||||||
|
else "No new rooms were added"
|
||||||
|
)
|
||||||
|
if request.try_historical and decrypt_targets:
|
||||||
|
if decrypt_started:
|
||||||
|
message += (
|
||||||
|
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||||
|
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message += "; no undecrypted packets were available"
|
||||||
|
|
||||||
|
return BulkCreateHashtagChannelsResponse(
|
||||||
|
created_channels=created_channels,
|
||||||
|
existing_count=existing_count,
|
||||||
|
invalid_names=invalid_names,
|
||||||
|
decrypt_started=decrypt_started,
|
||||||
|
decrypt_total_packets=decrypt_total_packets,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{key}/mark-read")
|
@router.post("/{key}/mark-read")
|
||||||
async def mark_channel_read(key: str) -> dict:
|
async def mark_channel_read(key: str) -> dict:
|
||||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||||
|
|||||||
+50
-3
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -31,7 +33,7 @@ from app.repository import (
|
|||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import (
|
from app.services.contact_reconciliation import (
|
||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
reconcile_contact_messages,
|
record_contact_name_and_reconcile,
|
||||||
)
|
)
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
@@ -277,12 +279,18 @@ async def create_contact(
|
|||||||
# Check if contact already exists
|
# Check if contact already exists
|
||||||
existing = await ContactRepository.get_by_key(request.public_key)
|
existing = await ContactRepository.get_by_key(request.public_key)
|
||||||
if existing:
|
if existing:
|
||||||
# Update name if provided
|
# Update name if provided and record name history
|
||||||
if request.name:
|
if request.name:
|
||||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||||
if refreshed is not None:
|
if refreshed is not None:
|
||||||
existing = refreshed
|
existing = refreshed
|
||||||
|
await record_contact_name_and_reconcile(
|
||||||
|
public_key=request.public_key,
|
||||||
|
contact_name=request.name,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
|
|
||||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=request.public_key,
|
public_key=request.public_key,
|
||||||
@@ -317,9 +325,10 @@ async def create_contact(
|
|||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
await reconcile_contact_messages(
|
await record_contact_name_and_reconcile(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
contact_name=request.name,
|
contact_name=request.name,
|
||||||
|
timestamp=int(time.time()),
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,6 +356,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
|||||||
return {"status": "ok", "public_key": contact.public_key}
|
return {"status": "ok", "public_key": contact.public_key}
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
public_keys: list[str] = Field(description="Public keys to delete")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk-delete")
|
||||||
|
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||||
|
"""Delete multiple contacts from the database (and radio if present)."""
|
||||||
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
|
# Resolve all contacts first
|
||||||
|
contacts_to_delete: list[Contact] = []
|
||||||
|
for key in request.public_keys:
|
||||||
|
contact = await ContactRepository.get_by_key(key.lower())
|
||||||
|
if contact:
|
||||||
|
contacts_to_delete.append(contact)
|
||||||
|
|
||||||
|
# Remove from radio in a single locked operation (blocks until radio is free)
|
||||||
|
if radio_manager.is_connected and contacts_to_delete:
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
|
||||||
|
for contact in contacts_to_delete:
|
||||||
|
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||||
|
if radio_contact:
|
||||||
|
await mc.commands.remove_contact(radio_contact)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Radio removal during bulk delete failed: %s", e)
|
||||||
|
|
||||||
|
# Delete from database and broadcast events
|
||||||
|
deleted = 0
|
||||||
|
for contact in contacts_to_delete:
|
||||||
|
await ContactRepository.delete(contact.public_key)
|
||||||
|
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{public_key}")
|
@router.delete("/{public_key}")
|
||||||
async def delete_contact(public_key: str) -> dict:
|
async def delete_contact(public_key: str) -> dict:
|
||||||
"""Delete a contact from the database (and radio if present)."""
|
"""Delete a contact from the database (and radio if present)."""
|
||||||
|
|||||||
+138
-19
@@ -1,17 +1,21 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import get_recent_log_lines, settings
|
from app.config import get_recent_log_lines, settings
|
||||||
|
from app.models import AppSettings
|
||||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||||
from app.repository import MessageRepository, StatisticsRepository
|
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||||
from app.routers.health import HealthResponse, build_health_data
|
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||||
from app.services.radio_runtime import radio_runtime
|
from app.services.radio_runtime import radio_runtime
|
||||||
from app.version_info import get_app_build_info, git_output
|
from app.version_info import get_app_build_info, git_output
|
||||||
|
|
||||||
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DebugSystemInfo(BaseModel):
|
||||||
|
os: str
|
||||||
|
arch: str
|
||||||
|
arch_bits: int
|
||||||
|
total_ram_mb: int
|
||||||
|
|
||||||
|
|
||||||
class DebugApplicationInfo(BaseModel):
|
class DebugApplicationInfo(BaseModel):
|
||||||
version: str
|
version: str
|
||||||
version_source: str
|
version_source: str
|
||||||
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
setup_in_progress: bool
|
setup_in_progress: bool
|
||||||
setup_complete: bool
|
setup_complete: bool
|
||||||
channels_with_incoming_messages: int
|
channels_with_incoming_messages: int
|
||||||
max_channels: int
|
|
||||||
path_hash_mode: int
|
|
||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
|
|||||||
class DebugRadioProbe(BaseModel):
|
class DebugRadioProbe(BaseModel):
|
||||||
performed: bool
|
performed: bool
|
||||||
errors: list[str] = Field(default_factory=list)
|
errors: list[str] = Field(default_factory=list)
|
||||||
multi_acks_enabled: bool | None = None
|
|
||||||
self_info: dict[str, Any] | None = None
|
self_info: dict[str, Any] | None = None
|
||||||
device_info: dict[str, Any] | None = None
|
device_info: dict[str, Any] | None = None
|
||||||
stats_core: dict[str, Any] | None = None
|
stats_core: dict[str, Any] | None = None
|
||||||
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
|
|||||||
total_outgoing: int
|
total_outgoing: int
|
||||||
|
|
||||||
|
|
||||||
|
class DebugHealthSummary(BaseModel):
|
||||||
|
radio_state: str
|
||||||
|
database_size_mb: float
|
||||||
|
oldest_undecrypted_timestamp: int | None
|
||||||
|
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||||
|
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||||
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugAppSettings(BaseModel):
|
||||||
|
max_radio_contacts: int
|
||||||
|
auto_decrypt_dm_on_advert: bool
|
||||||
|
advert_interval: int
|
||||||
|
flood_scope: str
|
||||||
|
blocked_keys_count: int
|
||||||
|
blocked_names_count: int
|
||||||
|
|
||||||
|
|
||||||
class DebugSnapshotResponse(BaseModel):
|
class DebugSnapshotResponse(BaseModel):
|
||||||
captured_at: str
|
captured_at: str
|
||||||
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
health: HealthResponse
|
health: DebugHealthSummary
|
||||||
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
database: DebugDatabaseInfo
|
database: DebugDatabaseInfo
|
||||||
radio_probe: DebugRadioProbe
|
radio_probe: DebugRadioProbe
|
||||||
logs: list[str]
|
logs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_info() -> DebugSystemInfo:
|
||||||
|
try:
|
||||||
|
# os.sysconf is available on Linux/macOS
|
||||||
|
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||||
|
page_count = os.sysconf("SC_PHYS_PAGES")
|
||||||
|
total_ram_mb = (page_size * page_count) // (1024 * 1024)
|
||||||
|
except (AttributeError, ValueError, OSError):
|
||||||
|
total_ram_mb = 0
|
||||||
|
|
||||||
|
return DebugSystemInfo(
|
||||||
|
os=f"{platform.system()} {platform.release()}",
|
||||||
|
arch=platform.machine(),
|
||||||
|
arch_bits=struct.calcsize("P") * 8,
|
||||||
|
total_ram_mb=total_ram_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_application_info() -> DebugApplicationInfo:
|
def _build_application_info() -> DebugApplicationInfo:
|
||||||
build_info = get_app_build_info()
|
build_info = get_app_build_info()
|
||||||
dirty_output = git_output("status", "--porcelain")
|
dirty_output = git_output("status", "--porcelain")
|
||||||
@@ -158,6 +203,68 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||||
|
return DebugAppSettings(
|
||||||
|
max_radio_contacts=app_settings.max_radio_contacts,
|
||||||
|
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
|
||||||
|
advert_interval=app_settings.advert_interval,
|
||||||
|
flood_scope=app_settings.flood_scope,
|
||||||
|
blocked_keys_count=len(app_settings.blocked_keys),
|
||||||
|
blocked_names_count=len(app_settings.blocked_names),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_debug_radio_state(
|
||||||
|
*,
|
||||||
|
radio_connected: bool,
|
||||||
|
connection_desired: bool,
|
||||||
|
setup_in_progress: bool,
|
||||||
|
setup_complete: bool,
|
||||||
|
is_reconnecting: bool,
|
||||||
|
) -> str:
|
||||||
|
if not connection_desired:
|
||||||
|
return "paused"
|
||||||
|
if radio_connected and (setup_in_progress or not setup_complete):
|
||||||
|
return "initializing"
|
||||||
|
if radio_connected:
|
||||||
|
return "connected"
|
||||||
|
if is_reconnecting:
|
||||||
|
return "connecting"
|
||||||
|
return "disconnected"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_debug_health_summary(
|
||||||
|
health_data: dict[str, Any], *, radio_state: str
|
||||||
|
) -> DebugHealthSummary:
|
||||||
|
def _fanout_last_error(status: Any) -> str | None:
|
||||||
|
if isinstance(status, dict):
|
||||||
|
value = status.get("last_error")
|
||||||
|
else:
|
||||||
|
value = getattr(status, "last_error", None)
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
fanouts_with_errors = {
|
||||||
|
config_id: status
|
||||||
|
for config_id, status in health_data["fanout_statuses"].items()
|
||||||
|
if _fanout_last_error(status)
|
||||||
|
}
|
||||||
|
return DebugHealthSummary(
|
||||||
|
radio_state=radio_state,
|
||||||
|
database_size_mb=health_data["database_size_mb"],
|
||||||
|
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
|
||||||
|
fanouts_with_errors=fanouts_with_errors,
|
||||||
|
bots_disabled_source=health_data["bots_disabled_source"],
|
||||||
|
basic_auth_enabled=health_data["basic_auth_enabled"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
sanitized = dict(self_info or {})
|
||||||
|
sanitized.pop("adv_lat", None)
|
||||||
|
sanitized.pop("adv_lon", None)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
async def _build_contact_audit(
|
async def _build_contact_audit(
|
||||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||||
) -> DebugContactAudit:
|
) -> DebugContactAudit:
|
||||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
return DebugRadioProbe(
|
return DebugRadioProbe(
|
||||||
performed=True,
|
performed=True,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||||
if mc.self_info is not None
|
|
||||||
else None,
|
|
||||||
self_info=dict(mc.self_info or {}),
|
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
stats_core=stats_core,
|
stats_core=stats_core,
|
||||||
stats_radio=stats_radio,
|
stats_radio=stats_radio,
|
||||||
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
connection_info = radio_runtime.connection_info
|
||||||
|
connection_desired = radio_runtime.connection_desired
|
||||||
|
setup_in_progress = radio_runtime.is_setup_in_progress
|
||||||
|
setup_complete = radio_runtime.is_setup_complete
|
||||||
|
radio_connected = radio_runtime.is_connected
|
||||||
|
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
|
||||||
|
|
||||||
|
health_data = await build_health_data(radio_connected, connection_info)
|
||||||
|
app_settings = await AppSettingsRepository.get()
|
||||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
radio_probe = await _probe_radio()
|
radio_probe = await _probe_radio()
|
||||||
channels_with_incoming_messages = (
|
channels_with_incoming_messages = (
|
||||||
await MessageRepository.count_channels_with_incoming_messages()
|
await MessageRepository.count_channels_with_incoming_messages()
|
||||||
)
|
)
|
||||||
|
radio_state = _derive_debug_radio_state(
|
||||||
|
radio_connected=radio_connected,
|
||||||
|
connection_desired=connection_desired,
|
||||||
|
setup_in_progress=setup_in_progress,
|
||||||
|
setup_complete=setup_complete,
|
||||||
|
is_reconnecting=is_reconnecting,
|
||||||
|
)
|
||||||
return DebugSnapshotResponse(
|
return DebugSnapshotResponse(
|
||||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
system=_build_system_info(),
|
||||||
application=_build_application_info(),
|
application=_build_application_info(),
|
||||||
health=HealthResponse(**health_data),
|
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||||
|
settings=_build_debug_app_settings(app_settings),
|
||||||
runtime=DebugRuntimeInfo(
|
runtime=DebugRuntimeInfo(
|
||||||
connection_info=radio_runtime.connection_info,
|
connection_info=connection_info,
|
||||||
connection_desired=radio_runtime.connection_desired,
|
connection_desired=connection_desired,
|
||||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
setup_in_progress=setup_in_progress,
|
||||||
setup_complete=radio_runtime.is_setup_complete,
|
setup_complete=setup_complete,
|
||||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||||
max_channels=radio_runtime.max_channels,
|
|
||||||
path_hash_mode=radio_runtime.path_hash_mode,
|
|
||||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
|
|||||||
+14
-3
@@ -24,7 +24,10 @@ from app.models import (
|
|||||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
from app.services.contact_reconciliation import (
|
||||||
|
promote_prefix_contacts_for_contact,
|
||||||
|
reconcile_contact_messages,
|
||||||
|
)
|
||||||
from app.services.radio_commands import (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
PathHashModeUnsupportedError,
|
||||||
@@ -214,11 +217,19 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
public_key=result.public_key,
|
public_key=result.public_key,
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
await reconcile_contact_messages(
|
||||||
|
public_key=result.public_key,
|
||||||
|
contact_name=result.name,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
created = await ContactRepository.get_by_key(result.public_key)
|
created = await ContactRepository.get_by_key(result.public_key)
|
||||||
if created is not None:
|
if created is not None:
|
||||||
broadcast_event("contact", created.model_dump())
|
broadcast_event("contact", created.model_dump())
|
||||||
for old_key in promoted_keys:
|
for old_key in promoted_keys:
|
||||||
broadcast_event("contact_deleted", {"public_key": old_key})
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
@@ -21,8 +22,9 @@ from app.models import (
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
)
|
)
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||||
|
|
||||||
return RepeaterStatusResponse(
|
response = RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
tx_queue_len=status.get("tx_queue_len", 0),
|
tx_queue_len=status.get("tx_queue_len", 0),
|
||||||
noise_floor_dbm=status.get("noise_floor", 0),
|
noise_floor_dbm=status.get("noise_floor", 0),
|
||||||
@@ -128,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Record to telemetry history as a JSON blob (best-effort)
|
||||||
|
now = int(time.time())
|
||||||
|
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||||
|
try:
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=contact.public_key,
|
||||||
|
timestamp=now,
|
||||||
|
data=status_dict,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to record telemetry history: %s", e)
|
||||||
|
|
||||||
|
# Fetch recent history and embed in response
|
||||||
|
try:
|
||||||
|
since = now - 30 * 86400 # last 30 days
|
||||||
|
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||||
|
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{public_key}/repeater/telemetry-history",
|
||||||
|
response_model=list[TelemetryHistoryEntry],
|
||||||
|
)
|
||||||
|
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||||
|
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_repeater(contact)
|
||||||
|
|
||||||
|
since = int(time.time()) - 30 * 86400
|
||||||
|
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||||
|
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Display names whose messages are hidden from the UI",
|
description="Display names whose messages are hidden from the UI",
|
||||||
)
|
)
|
||||||
|
discovery_blocked_types: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockKeyRequest(BaseModel):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -122,6 +129,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
if update.blocked_names is not None:
|
if update.blocked_names is not None:
|
||||||
kwargs["blocked_names"] = update.blocked_names
|
kwargs["blocked_names"] = update.blocked_names
|
||||||
|
|
||||||
|
# Discovery blocked types
|
||||||
|
if update.discovery_blocked_types is not None:
|
||||||
|
# Only allow valid contact type codes (1-4)
|
||||||
|
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||||
|
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
||||||
|
|
||||||
# Flood scope
|
# Flood scope
|
||||||
flood_scope_changed = False
|
flood_scope_changed = False
|
||||||
if update.flood_scope is not None:
|
if update.flood_scope is not None:
|
||||||
|
|||||||
+42
-6
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { takePrefetchOrFetch } from './prefetch';
|
import { takePrefetchOrFetch } from './prefetch';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
|
|||||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { Conversation, Message, RawPacket } from './types';
|
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||||
import { CONTACT_TYPE_ROOM } from './types';
|
import { CONTACT_TYPE_ROOM } from './types';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
@@ -85,6 +85,8 @@ export function App() {
|
|||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
useState<NewMessagePrefillRequest | null>(null);
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
|
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||||
|
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -190,6 +192,7 @@ export function App() {
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
} = useContactsAndChannels({
|
} = useContactsAndChannels({
|
||||||
@@ -421,16 +424,25 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[fetchUndecryptedCount, setChannels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenNewMessage = useCallback(() => {
|
const handleOpenNewMessage = useCallback(
|
||||||
setNewMessagePrefillRequest(null);
|
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||||
openNewMessageModal();
|
setNewMessagePrefillRequest(null);
|
||||||
}, [openNewMessageModal]);
|
setShowBulkAddChannelTab(event?.altKey === true);
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseNewMessage = useCallback(() => {
|
const handleCloseNewMessage = useCallback(() => {
|
||||||
setNewMessagePrefillRequest(null);
|
setNewMessagePrefillRequest(null);
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
closeNewMessageModal();
|
closeNewMessageModal();
|
||||||
}, [closeNewMessageModal]);
|
}, [closeNewMessageModal]);
|
||||||
|
|
||||||
|
const handleCloseBulkAddResults = useCallback(() => {
|
||||||
|
setBulkAddResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChannelReferenceClick = useCallback(
|
const handleChannelReferenceClick = useCallback(
|
||||||
(channelName: string) => {
|
(channelName: string) => {
|
||||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||||
@@ -444,11 +456,20 @@ export function App() {
|
|||||||
hashtagName: channelName.slice(1),
|
hashtagName: channelName.slice(1),
|
||||||
nonce: (previous?.nonce ?? 0) + 1,
|
nonce: (previous?.nonce ?? 0) + 1,
|
||||||
}));
|
}));
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
openNewMessageModal();
|
openNewMessageModal();
|
||||||
},
|
},
|
||||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBulkAddChannels = useCallback(
|
||||||
|
async (channelNames: string[], tryHistorical: boolean) => {
|
||||||
|
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
setBulkAddResult(result);
|
||||||
|
},
|
||||||
|
[handleBulkCreateHashtagChannels]
|
||||||
|
);
|
||||||
|
|
||||||
const statusProps = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -471,6 +492,11 @@ export function App() {
|
|||||||
favorites,
|
favorites,
|
||||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
legacySortOrder: appSettings?.sidebar_sort_order,
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||||
|
blockedNames: appSettings?.blocked_names ?? [],
|
||||||
|
};
|
||||||
|
const bulkAddChannelResultModalProps = {
|
||||||
|
result: bulkAddResult,
|
||||||
};
|
};
|
||||||
const conversationPaneProps = {
|
const conversationPaneProps = {
|
||||||
activeConversation,
|
activeConversation,
|
||||||
@@ -555,6 +581,11 @@ export function App() {
|
|||||||
blockedNames: appSettings?.blocked_names,
|
blockedNames: appSettings?.blocked_names,
|
||||||
onToggleBlockedKey: handleBlockKey,
|
onToggleBlockedKey: handleBlockKey,
|
||||||
onToggleBlockedName: handleBlockName,
|
onToggleBlockedName: handleBlockName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
||||||
|
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||||
|
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const crackerProps = {
|
const crackerProps = {
|
||||||
packets: rawPackets,
|
packets: rawPackets,
|
||||||
@@ -563,10 +594,12 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab,
|
||||||
prefillRequest: newMessagePrefillRequest,
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||||
};
|
};
|
||||||
const contactInfoPaneProps = {
|
const contactInfoPaneProps = {
|
||||||
contactKey: infoPaneContactKey,
|
contactKey: infoPaneContactKey,
|
||||||
@@ -630,6 +663,7 @@ export function App() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
localLabel={localLabel}
|
localLabel={localLabel}
|
||||||
showNewMessage={showNewMessage}
|
showNewMessage={showNewMessage}
|
||||||
|
showBulkAddResults={bulkAddResult !== null}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
settingsSection={settingsSection}
|
settingsSection={settingsSection}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
@@ -640,6 +674,7 @@ export function App() {
|
|||||||
onToggleSettingsView={handleToggleSettingsView}
|
onToggleSettingsView={handleToggleSettingsView}
|
||||||
onCloseSettingsView={handleCloseSettingsView}
|
onCloseSettingsView={handleCloseSettingsView}
|
||||||
onCloseNewMessage={handleCloseNewMessage}
|
onCloseNewMessage={handleCloseNewMessage}
|
||||||
|
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||||
onLocalLabelChange={setLocalLabel}
|
onLocalLabelChange={setLocalLabel}
|
||||||
statusProps={statusProps}
|
statusProps={statusProps}
|
||||||
sidebarProps={sidebarProps}
|
sidebarProps={sidebarProps}
|
||||||
@@ -648,6 +683,7 @@ export function App() {
|
|||||||
settingsProps={settingsProps}
|
settingsProps={settingsProps}
|
||||||
crackerProps={crackerProps}
|
crackerProps={crackerProps}
|
||||||
newMessageModalProps={newMessageModalProps}
|
newMessageModalProps={newMessageModalProps}
|
||||||
|
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||||
contactInfoPaneProps={contactInfoPaneProps}
|
contactInfoPaneProps={contactInfoPaneProps}
|
||||||
channelInfoPaneProps={channelInfoPaneProps}
|
channelInfoPaneProps={channelInfoPaneProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
@@ -34,6 +35,7 @@ import type {
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -149,6 +151,12 @@ export const api = {
|
|||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
bulkDeleteContacts: (publicKeys: string[]) =>
|
||||||
|
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -184,6 +192,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, key }),
|
body: JSON.stringify({ name, key }),
|
||||||
}),
|
}),
|
||||||
|
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
|
||||||
|
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
|
||||||
|
}),
|
||||||
deleteChannel: (key: string) =>
|
deleteChannel: (key: string) =>
|
||||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||||
@@ -402,6 +415,8 @@ export const api = {
|
|||||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
repeaterTelemetryHistory: (publicKey: string) =>
|
||||||
|
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||||
roomLogin: (publicKey: string, password: string) =>
|
roomLogin: (publicKey: string, password: string) =>
|
||||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { ConversationPane } from './ConversationPane';
|
import { ConversationPane } from './ConversationPane';
|
||||||
import { NewMessageModal } from './NewMessageModal';
|
import { NewMessageModal } from './NewMessageModal';
|
||||||
|
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||||
import { ContactInfoPane } from './ContactInfoPane';
|
import { ContactInfoPane } from './ContactInfoPane';
|
||||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||||
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
|||||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||||
|
type BulkAddChannelResultModalProps = Omit<
|
||||||
|
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||||
|
'open' | 'onClose'
|
||||||
|
>;
|
||||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
localLabel: LocalLabel;
|
localLabel: LocalLabel;
|
||||||
showNewMessage: boolean;
|
showNewMessage: boolean;
|
||||||
|
showBulkAddResults: boolean;
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -50,6 +56,7 @@ interface AppShellProps {
|
|||||||
onToggleSettingsView: () => void;
|
onToggleSettingsView: () => void;
|
||||||
onCloseSettingsView: () => void;
|
onCloseSettingsView: () => void;
|
||||||
onCloseNewMessage: () => void;
|
onCloseNewMessage: () => void;
|
||||||
|
onCloseBulkAddResults: () => void;
|
||||||
onLocalLabelChange: (label: LocalLabel) => void;
|
onLocalLabelChange: (label: LocalLabel) => void;
|
||||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||||
sidebarProps: SidebarProps;
|
sidebarProps: SidebarProps;
|
||||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
|||||||
>;
|
>;
|
||||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||||
newMessageModalProps: NewMessageModalProps;
|
newMessageModalProps: NewMessageModalProps;
|
||||||
|
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||||
contactInfoPaneProps: ContactInfoPaneProps;
|
contactInfoPaneProps: ContactInfoPaneProps;
|
||||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||||
}
|
}
|
||||||
@@ -68,6 +76,7 @@ interface AppShellProps {
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
localLabel,
|
localLabel,
|
||||||
showNewMessage,
|
showNewMessage,
|
||||||
|
showBulkAddResults,
|
||||||
showSettings,
|
showSettings,
|
||||||
settingsSection,
|
settingsSection,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
@@ -79,6 +88,7 @@ export function AppShell({
|
|||||||
onToggleSettingsView,
|
onToggleSettingsView,
|
||||||
onCloseSettingsView,
|
onCloseSettingsView,
|
||||||
onCloseNewMessage,
|
onCloseNewMessage,
|
||||||
|
onCloseBulkAddResults,
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
statusProps,
|
statusProps,
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
@@ -87,6 +97,7 @@ export function AppShell({
|
|||||||
settingsProps,
|
settingsProps,
|
||||||
crackerProps,
|
crackerProps,
|
||||||
newMessageModalProps,
|
newMessageModalProps,
|
||||||
|
bulkAddChannelResultModalProps,
|
||||||
contactInfoPaneProps,
|
contactInfoPaneProps,
|
||||||
channelInfoPaneProps,
|
channelInfoPaneProps,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
@@ -306,6 +317,11 @@ export function AppShell({
|
|||||||
open={showNewMessage}
|
open={showNewMessage}
|
||||||
onClose={onCloseNewMessage}
|
onClose={onCloseNewMessage}
|
||||||
/>
|
/>
|
||||||
|
<BulkAddChannelResultModal
|
||||||
|
{...bulkAddChannelResultModalProps}
|
||||||
|
open={showBulkAddResults}
|
||||||
|
onClose={onCloseBulkAddResults}
|
||||||
|
/>
|
||||||
|
|
||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
|
||||||
|
import { getConversationHash } from '../utils/urlHash';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
|
||||||
|
interface BulkAddChannelResultModalProps {
|
||||||
|
open: boolean;
|
||||||
|
result: BulkCreateHashtagChannelsResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChannelHref(channel: Channel): string {
|
||||||
|
const hash = getConversationHash({
|
||||||
|
type: 'channel',
|
||||||
|
id: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
});
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkAddChannelResultModal({
|
||||||
|
open,
|
||||||
|
result,
|
||||||
|
onClose,
|
||||||
|
}: BulkAddChannelResultModalProps) {
|
||||||
|
const createdChannels = result?.created_channels ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{result?.message ?? 'Review the newly added rooms below.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{result && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
|
||||||
|
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Already Present
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdChannels.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ctrl+click any room to open it in a new tab.
|
||||||
|
</p>
|
||||||
|
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||||
|
<ul className="divide-y divide-border/70">
|
||||||
|
{createdChannels.map((channel) => (
|
||||||
|
<li key={channel.key}>
|
||||||
|
<a
|
||||||
|
href={getChannelHref(channel)}
|
||||||
|
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||||
|
>
|
||||||
|
{channel.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.invalid_names.length > 0 && (
|
||||||
|
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||||
|
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
import { Ban, Search, Star } from 'lucide-react';
|
import { Ban, Search, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -35,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -158,6 +159,7 @@ export function ContactInfoPane({
|
|||||||
contact !== null &&
|
contact !== null &&
|
||||||
!isPrefixOnlyResolvedContact &&
|
!isPrefixOnlyResolvedContact &&
|
||||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||||
|
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
@@ -440,7 +442,7 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onSearchMessagesByKey && (
|
{!isRepeater && onSearchMessagesByKey && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,40 +455,60 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nearest Repeaters */}
|
{/* Nearest Repeaters (Hops) — last 7 days only */}
|
||||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
{analytics &&
|
||||||
<div className="px-5 py-3 border-b border-border">
|
(() => {
|
||||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
<div className="space-y-1">
|
const recent = analytics.nearest_repeaters.filter(
|
||||||
{analytics.nearest_repeaters.map((r) => (
|
(r) => r.last_seen >= sevenDaysAgo
|
||||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
);
|
||||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
if (recent.length === 0) return null;
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
return (
|
||||||
{r.path_len === 0
|
<div className="px-5 py-3 border-b border-border">
|
||||||
? 'direct'
|
<SectionLabel>Nearest Repeaters — Hops (last 7 days)</SectionLabel>
|
||||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
<div className="space-y-1">
|
||||||
· {r.heard_count}x
|
{recent.map((r) => (
|
||||||
</span>
|
<div
|
||||||
|
key={r.public_key}
|
||||||
|
className="flex justify-between items-center text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{r.path_len === 0
|
||||||
|
? 'direct'
|
||||||
|
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||||
|
· {r.heard_count}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
|
{/* Geographically nearest repeaters (repeaters only) */}
|
||||||
|
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||||
|
<NearbyRepeatersSection
|
||||||
|
contact={contact}
|
||||||
|
contacts={contacts}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advert Paths */}
|
{/* Advert Paths */}
|
||||||
{analytics && analytics.advert_paths.length > 0 && (
|
{analytics && analytics.advert_paths.length > 0 && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{analytics.advert_paths.map((p) => (
|
{analytics.advert_paths.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.path + p.first_seen}
|
key={p.path + p.first_seen}
|
||||||
className="flex justify-between items-center text-sm"
|
className="flex justify-between items-start gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="font-mono text-xs break-all">
|
||||||
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,17 +540,21 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageStatsSection
|
{!isRepeater && (
|
||||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
<>
|
||||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
<MessageStatsSection
|
||||||
/>
|
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||||
|
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveChannelsSection
|
<MostActiveChannelsSection
|
||||||
channels={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
@@ -826,6 +852,60 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NearbyRepeatersSection({
|
||||||
|
contact,
|
||||||
|
contacts,
|
||||||
|
distanceUnit,
|
||||||
|
}: {
|
||||||
|
contact: Contact;
|
||||||
|
contacts: Contact[];
|
||||||
|
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
|
||||||
|
}) {
|
||||||
|
const nearby = useMemo(() => {
|
||||||
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
|
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
|
||||||
|
for (const other of contacts) {
|
||||||
|
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
|
||||||
|
if (
|
||||||
|
other.public_key === contact.public_key ||
|
||||||
|
other.type !== CONTACT_TYPE_REPEATER ||
|
||||||
|
!isValidLocation(other.lat, other.lon) ||
|
||||||
|
heardAt < sevenDaysAgo
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
|
||||||
|
if (dist !== null) {
|
||||||
|
results.push({
|
||||||
|
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
|
||||||
|
publicKey: other.public_key,
|
||||||
|
distance: dist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.distance - b.distance);
|
||||||
|
return results.slice(0, 5);
|
||||||
|
}, [contact.public_key, contact.lat, contact.lon, contacts]);
|
||||||
|
|
||||||
|
if (nearby.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<SectionLabel>Nearest Repeaters — Geo (last 7 days)</SectionLabel>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nearby.map((r) => (
|
||||||
|
<div key={r.publicKey} className="flex justify-between items-center text-sm">
|
||||||
|
<span className="truncate">{r.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{formatDistance(r.distance, distanceUnit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Checkbox } from './ui/checkbox';
|
import { Checkbox } from './ui/checkbox';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
|
|
||||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||||
|
|
||||||
|
interface BulkParseResult {
|
||||||
|
channelNames: string[];
|
||||||
|
invalidNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
showBulkAddChannelTab?: boolean;
|
||||||
prefillRequest?: {
|
prefillRequest?: {
|
||||||
tab: 'hashtag';
|
tab: 'hashtag';
|
||||||
hashtagName: string;
|
hashtagName: string;
|
||||||
@@ -29,53 +35,121 @@ interface NewMessageModalProps {
|
|||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHashtagName(channelName: string): string | null {
|
||||||
|
if (!channelName) {
|
||||||
|
return 'Channel name is required';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||||
|
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
||||||
|
const tokens = rawText
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const invalidNames: string[] = [];
|
||||||
|
const channelNames: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const stripped = token.replace(/^#+/, '');
|
||||||
|
const validationError = validateHashtagName(stripped);
|
||||||
|
if (validationError) {
|
||||||
|
invalidNames.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
||||||
|
const channelName = `#${normalized}`;
|
||||||
|
if (seen.has(channelName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(channelName);
|
||||||
|
channelNames.push(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channelNames, invalidNames };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab = false,
|
||||||
prefillRequest = null,
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
onCreateHashtagChannel,
|
onCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels,
|
||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
const [tryHistorical, setTryHistorical] = useState(false);
|
const [tryHistorical, setTryHistorical] = useState(false);
|
||||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !prefillRequest) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(prefillRequest.tab);
|
if (prefillRequest) {
|
||||||
setName(prefillRequest.hashtagName);
|
setTab(prefillRequest.tab);
|
||||||
setContactKey('');
|
setName(prefillRequest.hashtagName);
|
||||||
setChannelKey('');
|
setContactKey('');
|
||||||
setTryHistorical(false);
|
setChannelKey('');
|
||||||
setPermitCapitals(false);
|
setBulkChannelText('');
|
||||||
setError('');
|
setTryHistorical(false);
|
||||||
setLoading(false);
|
setPermitCapitals(false);
|
||||||
requestAnimationFrame(() => {
|
setError('');
|
||||||
hashtagInputRef.current?.focus();
|
setLoading(false);
|
||||||
});
|
requestAnimationFrame(() => {
|
||||||
}, [open, prefillRequest]);
|
hashtagInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBulkAddChannelTab) {
|
||||||
|
setTab('bulk-hashtag');
|
||||||
|
setName('');
|
||||||
|
setContactKey('');
|
||||||
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
|
setTryHistorical(false);
|
||||||
|
setPermitCapitals(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bulkTextareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab('new-contact');
|
||||||
|
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -87,7 +161,6 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
@@ -102,10 +175,24 @@ export function NewMessageModal({
|
|||||||
setError(validationError);
|
setError(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||||
|
} else {
|
||||||
|
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
||||||
|
bulkChannelText,
|
||||||
|
permitCapitals
|
||||||
|
);
|
||||||
|
if (channelNames.length === 0) {
|
||||||
|
setError('Enter at least one valid room name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (invalidNames.length > 0) {
|
||||||
|
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,16 +205,6 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateHashtagName = (channelName: string): string | null => {
|
|
||||||
if (!channelName) {
|
|
||||||
return 'Channel name is required';
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
|
||||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAndAddAnother = async () => {
|
const handleCreateAndAddAnother = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
@@ -139,7 +216,6 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||||
setName('');
|
setName('');
|
||||||
@@ -166,28 +242,36 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||||
|
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(value) => {
|
||||||
setTab(v as Tab);
|
setTab(value as Tab);
|
||||||
resetForm();
|
resetForm();
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList
|
||||||
|
className={
|
||||||
|
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||||
|
}
|
||||||
|
>
|
||||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||||
@@ -239,7 +323,7 @@ export function NewMessageModal({
|
|||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
setChannelKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
@@ -268,20 +352,55 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1">
|
<div className="mt-3 space-y-1">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={permitCapitals}
|
checked={permitCapitals}
|
||||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-input accent-primary"
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground pl-7">
|
<p className="pl-7 text-xs text-muted-foreground">
|
||||||
Not recommended; most companions normalize to lowercase
|
Not recommended; most companions normalize to lowercase
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||||
|
<textarea
|
||||||
|
ref={bulkTextareaRef}
|
||||||
|
id="bulk-hashtag-names"
|
||||||
|
aria-label="Bulk channel names"
|
||||||
|
value={bulkChannelText}
|
||||||
|
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||||
|
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||||
|
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||||
|
stripped automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={permitCapitals}
|
||||||
|
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
|
</label>
|
||||||
|
<p className="pl-7 text-xs text-muted-foreground">
|
||||||
|
Not recommended; most companions normalize to lowercase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{showHistoricalOption && (
|
{showHistoricalOption && (
|
||||||
@@ -289,7 +408,7 @@ export function NewMessageModal({
|
|||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="try-historical"
|
htmlFor="try-historical"
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
className="cursor-pointer text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||||
{undecryptedCount !== 1 ? 's' : ''}
|
{undecryptedCount !== 1 ? 's' : ''}
|
||||||
@@ -301,7 +420,7 @@ export function NewMessageModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{tryHistorical && (
|
{tryHistorical && (
|
||||||
<p className="text-xs text-muted-foreground text-right">
|
<p className="text-right text-xs text-muted-foreground">
|
||||||
Messages will stream in as they decrypt in the background
|
Messages will stream in as they decrypt in the background
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -330,7 +449,13 @@ export function NewMessageModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleCreate} disabled={loading}>
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
{loading ? 'Creating...' : 'Create'}
|
{loading
|
||||||
|
? tab === 'bulk-hashtag'
|
||||||
|
? 'Adding...'
|
||||||
|
: 'Creating...'
|
||||||
|
: tab === 'bulk-hashtag'
|
||||||
|
? 'Add Channels'
|
||||||
|
: 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -406,9 +406,12 @@ interface HopNodeProps {
|
|||||||
distanceUnit: DistanceUnit;
|
distanceUnit: DistanceUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||||
|
|
||||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||||
const isAmbiguous = hop.matches.length > 1;
|
const isAmbiguous = hop.matches.length > 1;
|
||||||
const isUnknown = hop.matches.length === 0;
|
const isUnknown = hop.matches.length === 0;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
// Calculate distance from previous location for a contact
|
// Calculate distance from previous location for a contact
|
||||||
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
||||||
@@ -447,27 +450,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
|||||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||||
) : isAmbiguous ? (
|
) : isAmbiguous ? (
|
||||||
<div>
|
<div>
|
||||||
{hop.matches.map((contact) => {
|
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
|
||||||
const dist = getDistanceForContact(contact);
|
(contact) => {
|
||||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
const dist = getDistanceForContact(contact);
|
||||||
return (
|
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||||
<div key={contact.public_key} className="font-medium truncate">
|
return (
|
||||||
{contact.name || contact.public_key.slice(0, 12)}
|
<div key={contact.public_key} className="font-medium truncate">
|
||||||
{dist !== null && (
|
{contact.name || contact.public_key.slice(0, 12)}
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
{dist !== null && (
|
||||||
- {formatDistance(dist, distanceUnit)}
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
</span>
|
- {formatDistance(dist, distanceUnit)}
|
||||||
)}
|
</span>
|
||||||
{hasLocation && (
|
)}
|
||||||
<CoordinateLink
|
{hasLocation && (
|
||||||
lat={contact.lat!}
|
<CoordinateLink
|
||||||
lon={contact.lon!}
|
lat={contact.lat!}
|
||||||
publicKey={contact.public_key}
|
lon={contact.lon!}
|
||||||
/>
|
publicKey={contact.public_key}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary hover:underline cursor-pointer"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
import type {
|
||||||
|
Contact,
|
||||||
|
Conversation,
|
||||||
|
Favorite,
|
||||||
|
PathDiscoveryResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
|
} from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
|||||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
|
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
|
|
||||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||||
@@ -45,6 +53,7 @@ interface RepeaterDashboardProps {
|
|||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterDashboard({
|
export function RepeaterDashboard({
|
||||||
@@ -62,6 +71,7 @@ export function RepeaterDashboard({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
|
onOpenContactInfo,
|
||||||
}: RepeaterDashboardProps) {
|
}: RepeaterDashboardProps) {
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||||
@@ -88,6 +98,24 @@ export function RepeaterDashboard({
|
|||||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
useRememberedServerPassword('repeater', conversation.id);
|
useRememberedServerPassword('repeater', conversation.id);
|
||||||
|
|
||||||
|
// Telemetry history: preload from stored data, refresh from live status
|
||||||
|
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loggedIn) return;
|
||||||
|
api
|
||||||
|
.repeaterTelemetryHistory(conversation.id)
|
||||||
|
.then(setTelemetryHistory)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [loggedIn, conversation.id]);
|
||||||
|
|
||||||
|
// When a live status fetch returns embedded telemetry_history, replace local state
|
||||||
|
useEffect(() => {
|
||||||
|
const liveHistory = paneData.status?.telemetry_history;
|
||||||
|
if (liveHistory && liveHistory.length > 0) {
|
||||||
|
setTelemetryHistory(liveHistory);
|
||||||
|
}
|
||||||
|
}, [paneData.status?.telemetry_history]);
|
||||||
|
|
||||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||||
await login(nextPassword);
|
await login(nextPassword);
|
||||||
@@ -115,9 +143,24 @@ export function RepeaterDashboard({
|
|||||||
<span className="flex min-w-0 flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||||
{conversation.name}
|
{onOpenContactInfo ? (
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`View info for ${conversation.name}`}
|
||||||
|
onClick={() => onOpenContactInfo(conversation.id)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{conversation.name}</span>
|
||||||
|
<Info
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{conversation.name}</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -274,6 +317,7 @@ export function RepeaterDashboard({
|
|||||||
onRefresh={() => refreshPane('status')}
|
onRefresh={() => refreshPane('status')}
|
||||||
disabled={anyLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
|
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||||
<RadioSettingsPane
|
<RadioSettingsPane
|
||||||
data={paneData.radioSettings}
|
data={paneData.radioSettings}
|
||||||
state={paneStates.radioSettings}
|
state={paneStates.radioSettings}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
Contact,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -47,6 +48,8 @@ interface SettingsModalBaseProps {
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsModalProps = SettingsModalBaseProps &
|
export type SettingsModalProps = SettingsModalBaseProps &
|
||||||
@@ -80,6 +83,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames,
|
blockedNames,
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts,
|
||||||
} = props;
|
} = props;
|
||||||
const externalSidebarNav = props.externalSidebarNav === true;
|
const externalSidebarNav = props.externalSidebarNav === true;
|
||||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||||
@@ -239,6 +244,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames={blockedNames}
|
blockedNames={blockedNames}
|
||||||
onToggleBlockedKey={onToggleBlockedKey}
|
onToggleBlockedKey={onToggleBlockedKey}
|
||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedName={onToggleBlockedName}
|
||||||
|
contacts={contacts}
|
||||||
|
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||||
className={sectionContentClass}
|
className={sectionContentClass}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
onSelectConversation: (conversation: Conversation) => void;
|
||||||
onNewMessage: () => void;
|
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
lastMessageTimes: ConversationTimes;
|
lastMessageTimes: ConversationTimes;
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
/** Tracks which conversations have unread messages that mention the user */
|
||||||
@@ -110,6 +110,8 @@ interface SidebarProps {
|
|||||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
/** Legacy global sort order, used only to seed per-section local preferences. */
|
||||||
legacySortOrder?: SortOrder;
|
legacySortOrder?: SortOrder;
|
||||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||||
|
blockedKeys?: string[];
|
||||||
|
blockedNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitialSectionSortState = {
|
type InitialSectionSortState = {
|
||||||
@@ -153,7 +155,16 @@ export function Sidebar({
|
|||||||
favorites,
|
favorites,
|
||||||
legacySortOrder,
|
legacySortOrder,
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys = [],
|
||||||
|
blockedNames = [],
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const isContactBlocked = useCallback(
|
||||||
|
(c: Contact) =>
|
||||||
|
blockedKeys.includes(c.public_key.toLowerCase()) ||
|
||||||
|
(c.name != null && blockedNames.includes(c.name)),
|
||||||
|
[blockedKeys, blockedNames]
|
||||||
|
);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
||||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
||||||
@@ -398,38 +409,32 @@ export function Sidebar({
|
|||||||
[sortedChannels, query]
|
[sortedChannels, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredNonRepeaterContacts = useMemo(
|
const filteredNonRepeaterContacts = useMemo(() => {
|
||||||
() =>
|
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedNonRepeaterContacts.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedNonRepeaterContacts,
|
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
|
||||||
[sortedNonRepeaterContacts, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRooms = useMemo(
|
const filteredRooms = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRooms.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRooms,
|
}, [sortedRooms, query, isContactBlocked]);
|
||||||
[sortedRooms, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRepeaters = useMemo(
|
const filteredRepeaters = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRepeaters.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRepeaters,
|
}, [sortedRepeaters, query, isContactBlocked]);
|
||||||
[sortedRepeaters, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expand sections while searching; restore prior collapse state when search ends.
|
// Expand sections while searching; restore prior collapse state when search ends.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -654,8 +659,9 @@ export function Sidebar({
|
|||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
data-active={active ? 'true' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
active && 'bg-accent border-l-primary'
|
active && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -664,10 +670,10 @@ export function Sidebar({
|
|||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TelemetryHistoryEntry } from '../../types';
|
||||||
|
|
||||||
|
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||||
|
|
||||||
|
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
|
||||||
|
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||||
|
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||||
|
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||||
|
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||||
|
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||||
|
return `${(seconds / 86400).toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||||
|
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||||
|
|
||||||
|
const config = METRIC_CONFIG[metric];
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
return entries.map((e) => {
|
||||||
|
const d = e.data;
|
||||||
|
return {
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
battery_volts: d.battery_volts,
|
||||||
|
noise_floor_dbm: d.noise_floor_dbm,
|
||||||
|
packets_received: d.packets_received,
|
||||||
|
packets_sent: d.packets_sent,
|
||||||
|
uptime_seconds: d.uptime_seconds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||||
|
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Metric selector */}
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMetric(m)}
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||||
|
metric === m
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{METRIC_CONFIG[m].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No history yet. Fetch status above to record data points.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
type="number"
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatTime}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
}}
|
||||||
|
labelFormatter={(ts) => formatTime(Number(ts))}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(value: any, name: any) => {
|
||||||
|
const numVal = typeof value === 'number' ? value : Number(value);
|
||||||
|
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||||
|
const suffix =
|
||||||
|
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
||||||
|
const label =
|
||||||
|
metric === 'packets'
|
||||||
|
? name === 'packets_received'
|
||||||
|
? 'Received'
|
||||||
|
: 'Sent'
|
||||||
|
: config.label;
|
||||||
|
return [`${display}${suffix}`, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{dataKeys.map((key, i) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="linear"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||||
|
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{
|
||||||
|
r: 4,
|
||||||
|
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: 'hsl(var(--popover))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { getContactDisplayName } from '../../utils/pubkey';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
|
||||||
|
import { toast } from '../ui/sonner';
|
||||||
|
import type { Contact } from '../../types';
|
||||||
|
|
||||||
|
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||||
|
0: 'Unknown',
|
||||||
|
1: 'Client',
|
||||||
|
2: 'Repeater',
|
||||||
|
3: 'Room',
|
||||||
|
4: 'Sensor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateISO(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeToUnix(datetimeStr: string): number {
|
||||||
|
const d = new Date(datetimeStr);
|
||||||
|
return Math.floor(d.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkDeleteContactsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contacts: Contact[];
|
||||||
|
onDeleted: (deletedKeys: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkDeleteContactsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
contacts,
|
||||||
|
onDeleted,
|
||||||
|
}: BulkDeleteContactsModalProps) {
|
||||||
|
const [step, setStep] = useState<'select' | 'confirm'>('select');
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const resetAndClose = useCallback(() => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
setStartDate('');
|
||||||
|
setEndDate('');
|
||||||
|
setTypeFilter('all');
|
||||||
|
lastClickedKeyRef.current = null;
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
const start = datetimeToUnix(startDate);
|
||||||
|
list = list.filter((c) => (c.first_seen ?? 0) >= start);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
const end = datetimeToUnix(endDate);
|
||||||
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [contacts, typeFilter, startDate, endDate]);
|
||||||
|
|
||||||
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
|
const keys = filteredContacts.map((c) => c.public_key);
|
||||||
|
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
|
||||||
|
const curIdx = keys.indexOf(key);
|
||||||
|
if (lastIdx >= 0 && curIdx >= 0) {
|
||||||
|
const from = Math.min(lastIdx, curIdx);
|
||||||
|
const to = Math.max(lastIdx, curIdx);
|
||||||
|
const rangeKeys = keys.slice(from, to + 1);
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const k of rangeKeys) next.add(k);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
lastClickedKeyRef.current = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
lastClickedKeyRef.current = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectNone = () => {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedContacts = useMemo(
|
||||||
|
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
|
||||||
|
[contacts, selectedKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
|
||||||
|
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
|
||||||
|
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
|
||||||
|
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
|
||||||
|
|
||||||
|
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
|
||||||
|
const minDate =
|
||||||
|
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
|
||||||
|
const maxDate =
|
||||||
|
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const keysToDelete = [...selectedKeys];
|
||||||
|
const result = await api.bulkDeleteContacts(keysToDelete);
|
||||||
|
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
|
||||||
|
onDeleted(keysToDelete);
|
||||||
|
resetAndClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Bulk delete failed:', err);
|
||||||
|
toast.error('Bulk delete failed', {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'select'
|
||||||
|
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
|
||||||
|
: 'Review the contacts that will be permanently deleted.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'select' && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
|
<select
|
||||||
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="1">Clients</option>
|
||||||
|
<option value="2">Repeaters</option>
|
||||||
|
<option value="3">Room Servers</option>
|
||||||
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
|
||||||
|
Select none
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
|
{(startDate || endDate) && ' (filtered)'}
|
||||||
|
{' · '}
|
||||||
|
{selectedKeys.size} selected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
|
{filteredContacts.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No contacts match the selected date range.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-3 py-1.5 w-8" />
|
||||||
|
<th className="px-3 py-1.5">Name</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||||
|
<th className="px-3 py-1.5">Key</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredContacts.map((c) => (
|
||||||
|
<tr
|
||||||
|
key={c.public_key}
|
||||||
|
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||||
|
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.has(c.public_key)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleToggle(
|
||||||
|
c.public_key,
|
||||||
|
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 truncate max-w-[10rem]">
|
||||||
|
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||||
|
{c.public_key.slice(0, 12)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={resetAndClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
|
||||||
|
disabled={selectedKeys.size === 0}
|
||||||
|
onClick={() => setStep('confirm')}
|
||||||
|
>
|
||||||
|
Proceed to confirmation ({selectedKeys.size})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'confirm' && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-3 py-1.5">Name</th>
|
||||||
|
<th className="px-3 py-1.5">Type</th>
|
||||||
|
<th className="px-3 py-1.5">Key</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedContacts.map((c) => (
|
||||||
|
<tr key={c.public_key} className="border-t border-border">
|
||||||
|
<td className="px-3 py-1.5 truncate max-w-[12rem]">
|
||||||
|
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||||
|
{c.public_key.slice(0, 12)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full h-auto py-3 text-wrap"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting
|
||||||
|
? 'Deleting...'
|
||||||
|
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
|
||||||
|
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
|
||||||
|
repeaterCount > 0 &&
|
||||||
|
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
|
||||||
|
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
|
||||||
|
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { Separator } from '../ui/separator';
|
|||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import { formatTime } from '../../utils/messageParser';
|
import { formatTime } from '../../utils/messageParser';
|
||||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||||
|
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||||
|
|
||||||
export function SettingsDatabaseSection({
|
export function SettingsDatabaseSection({
|
||||||
appSettings,
|
appSettings,
|
||||||
@@ -17,6 +18,8 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames = [],
|
blockedNames = [],
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts = [],
|
||||||
|
onBulkDeleteContacts,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
appSettings: AppSettings;
|
appSettings: AppSettings;
|
||||||
@@ -27,18 +30,23 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [retentionDays, setRetentionDays] = useState('14');
|
const [retentionDays, setRetentionDays] = useState('14');
|
||||||
const [cleaning, setCleaning] = useState(false);
|
const [cleaning, setCleaning] = useState(false);
|
||||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||||
|
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||||
|
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
const handleCleanup = async () => {
|
||||||
@@ -92,7 +100,15 @@ export function SettingsDatabaseSection({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
|
||||||
|
const currentBlocked = appSettings.discovery_blocked_types ?? [];
|
||||||
|
if (
|
||||||
|
discoveryBlockedTypes.length !== currentBlocked.length ||
|
||||||
|
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
|
||||||
|
) {
|
||||||
|
update.discovery_blocked_types = discoveryBlockedTypes;
|
||||||
|
}
|
||||||
|
await onSaveAppSettings(update);
|
||||||
toast.success('Database settings saved');
|
toast.success('Database settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save database settings:', err);
|
console.error('Failed to save database settings:', err);
|
||||||
@@ -105,93 +121,93 @@ export function SettingsDatabaseSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
|
{/* ── Database Overview ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<Label className="text-base">Database Overview</Label>
|
||||||
<span className="text-sm text-muted-foreground">Database size</span>
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{health?.oldest_undecrypted_timestamp ? (
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
<span className="text-sm">Database size</span>
|
||||||
<span className="font-medium">
|
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
||||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
</div>
|
||||||
<span className="text-muted-foreground ml-1">
|
<div className="flex justify-between items-center">
|
||||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
<span className="text-sm">Oldest undecrypted packet</span>
|
||||||
days old)
|
{health?.oldest_undecrypted_timestamp ? (
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||||
|
<span className="font-normal text-muted-foreground ml-1">
|
||||||
|
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||||
|
days)
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
|
||||||
<span className="text-muted-foreground">None</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* ── Storage Cleanup ── */}
|
||||||
<Label>Delete Undecrypted Packets</Label>
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Label className="text-base">Storage Cleanup</Label>
|
||||||
Permanently deletes stored raw packets containing DMs and channel messages that have not
|
|
||||||
yet been decrypted. These packets are retained in case you later obtain the correct key —
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
once deleted, these messages can never be recovered or decrypted.
|
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex gap-2 items-end">
|
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||||
<div className="space-y-1">
|
retained in case you later obtain the correct key — once deleted, these messages can
|
||||||
<Label htmlFor="retention-days" className="text-xs">
|
never be recovered.
|
||||||
Older than (days)
|
</p>
|
||||||
</Label>
|
<div className="flex gap-2 items-end">
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
id="retention-days"
|
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
||||||
type="number"
|
Older than (days)
|
||||||
min="1"
|
</Label>
|
||||||
max="365"
|
<Input
|
||||||
value={retentionDays}
|
id="retention-days"
|
||||||
onChange={(e) => setRetentionDays(e.target.value)}
|
type="number"
|
||||||
className="w-24"
|
min="1"
|
||||||
/>
|
max="365"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => setRetentionDays(e.target.value)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCleanup}
|
||||||
|
disabled={cleaning}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{cleaning ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
|
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
||||||
|
chat. This frees space but removes packet-analysis availability for those messages. It
|
||||||
|
does not affect displayed messages or future decryption.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCleanup}
|
onClick={handlePurgeDecryptedRawPackets}
|
||||||
disabled={cleaning}
|
disabled={purgingDecryptedRaw}
|
||||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||||
>
|
>
|
||||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── DM Decryption ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Purge Archival Raw Packets</Label>
|
<Label className="text-base">DM Decryption</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
|
||||||
visible in your chat history.{' '}
|
|
||||||
<em className="text-muted-foreground/80">
|
|
||||||
This will not affect any displayed messages or your ability to do historical decryption,
|
|
||||||
but it will remove packet-analysis availability for those historical messages.
|
|
||||||
</em>{' '}
|
|
||||||
The raw bytes are only useful for manual packet analysis.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handlePurgeDecryptedRawPackets}
|
|
||||||
disabled={purgingDecryptedRaw}
|
|
||||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
|
||||||
>
|
|
||||||
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>DM Decryption</Label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -207,17 +223,87 @@ export function SettingsDatabaseSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||||
|
{busy ? 'Saving...' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Contact Management ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Contact Management</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block discovery of new node types */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Block Discovery of New Node Types</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||||
|
types are still updated. This does not affect contacts added manually or via DM.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[1, 'Block clients'],
|
||||||
|
[2, 'Block repeaters'],
|
||||||
|
[3, 'Block room servers'],
|
||||||
|
[4, 'Block sensors'],
|
||||||
|
] as const
|
||||||
|
).map(([typeCode, label]) => {
|
||||||
|
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||||
|
return (
|
||||||
|
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() =>
|
||||||
|
setDiscoveryBlockedTypes((prev) =>
|
||||||
|
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{discoveryBlockedTypes.length > 0 && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
New{' '}
|
||||||
|
{discoveryBlockedTypes
|
||||||
|
.map((t) =>
|
||||||
|
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||||
|
)
|
||||||
|
.join(', ')}{' '}
|
||||||
|
heard via advertisement will not be added to your contact list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Blocked contacts list */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Blocked Contacts</Label>
|
<Label>Blocked Contacts</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||||
affected. Messages are still stored and will reappear if unblocked.
|
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||||
|
reappear if unblocked.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||||
|
avatar in any channel, or their name within the top status bar with the conversation
|
||||||
|
open.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{blockedKeys.length > 0 && (
|
{blockedKeys.length > 0 && (
|
||||||
@@ -268,15 +354,25 @@ export function SettingsDatabaseSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<Separator />
|
||||||
<div className="text-sm text-destructive" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
{/* Bulk delete */}
|
||||||
{busy ? 'Saving...' : 'Save Settings'}
|
<div className="space-y-3">
|
||||||
</Button>
|
<Label>Bulk Delete Contacts</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||||
|
nodes. Message history will be preserved.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||||
|
Open Bulk Delete
|
||||||
|
</Button>
|
||||||
|
<BulkDeleteContactsModal
|
||||||
|
open={bulkDeleteOpen}
|
||||||
|
onClose={() => setBulkDeleteOpen(false)}
|
||||||
|
contacts={contacts}
|
||||||
|
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,17 +347,20 @@ function PreviewSidebarRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
data-active={active ? 'true' : undefined}
|
||||||
|
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{leading}
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
{leading}
|
||||||
|
</span>
|
||||||
|
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{badge}
|
{badge}
|
||||||
{!badge && (
|
{!badge && (
|
||||||
<span className="text-muted-foreground" aria-hidden="true">
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
<MessageSquare className="h-3.5 w-3.5" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Connection display */}
|
{/* ── Connection ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Connection</Label>
|
<Label className="text-base">Connection</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={`w-2 h-2 rounded-full ${
|
||||||
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Radio Name */}
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Identity ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Identity</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Radio Name</Label>
|
<Label htmlFor="name">Radio Name</Label>
|
||||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="public-key">Public Key</Label>
|
||||||
|
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||||
|
<Input
|
||||||
|
id="private-key"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder="64-character hex private key"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSetPrivateKey}
|
||||||
|
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
||||||
|
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{identityBusy || identityRebooting
|
||||||
|
? 'Setting & Rebooting...'
|
||||||
|
: 'Set Private Key & Reboot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{identityError && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{identityError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Radio Config */}
|
{/* ── Radio Parameters ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Radio Parameters</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preset">Preset</Label>
|
<Label htmlFor="preset">Preset</Label>
|
||||||
<select
|
<select
|
||||||
@@ -518,11 +561,36 @@ export function SettingsRadioSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{config.path_hash_mode_supported && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
||||||
|
<select
|
||||||
|
id="path-hash-mode"
|
||||||
|
value={pathHashMode}
|
||||||
|
onChange={(e) => setPathHashMode(e.target.value)}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="0">1 byte (default)</option>
|
||||||
|
<option value="1">2 bytes</option>
|
||||||
|
<option value="2">3 bytes</option>
|
||||||
|
</select>
|
||||||
|
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||||
|
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||||
|
<p>
|
||||||
|
ALL nodes along a message's route — your radio, every repeater, and the
|
||||||
|
recipient — must be running firmware that supports the selected mode. Messages
|
||||||
|
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Location ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Location</Label>
|
<Label className="text-base">Location</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -585,53 +653,8 @@ export function SettingsRadioSection({
|
|||||||
library.
|
library.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
|
||||||
<Checkbox
|
|
||||||
id="multi-acks-enabled"
|
|
||||||
checked={multiAcksEnabled}
|
|
||||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, the radio sends one extra direct ACK transmission before the normal
|
|
||||||
ACK for received direct messages. This is a firmware-level receive behavior, not a
|
|
||||||
RemoteTerm retry setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.path_hash_mode_supported && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
|
||||||
<select
|
|
||||||
id="path-hash-mode"
|
|
||||||
value={pathHashMode}
|
|
||||||
onChange={(e) => setPathHashMode(e.target.value)}
|
|
||||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<option value="0">1 byte (default)</option>
|
|
||||||
<option value="1">2 bytes</option>
|
|
||||||
<option value="2">3 bytes</option>
|
|
||||||
</select>
|
|
||||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
|
||||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
|
||||||
<p>
|
|
||||||
ALL nodes along a message's route — your radio, every repeater, and the
|
|
||||||
recipient — must be running firmware that supports the selected mode. Messages
|
|
||||||
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-destructive" role="alert">
|
<div className="text-sm text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -657,64 +680,28 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Keys */}
|
{/* ── Messaging ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="public-key">Public Key</Label>
|
<Label className="text-base">Messaging</Label>
|
||||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||||
<Input
|
<Checkbox
|
||||||
id="private-key"
|
id="multi-acks-enabled"
|
||||||
type="password"
|
checked={multiAcksEnabled}
|
||||||
autoComplete="off"
|
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||||
value={privateKey}
|
className="mt-0.5"
|
||||||
onChange={(e) => setPrivateKey(e.target.value)}
|
|
||||||
placeholder="64-character hex private key"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSetPrivateKey}
|
|
||||||
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
|
||||||
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{identityBusy || identityRebooting
|
|
||||||
? 'Setting & Rebooting...'
|
|
||||||
: 'Set Private Key & Reboot'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{identityError && (
|
|
||||||
<div className="text-sm text-destructive" role="alert">
|
|
||||||
{identityError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Flood & Advert Control */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-base">Flood & Advert Control</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
id="advert-interval"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={advertIntervalHours}
|
|
||||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
|
||||||
className="w-28"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
|
||||||
|
for received direct messages. This is a firmware-level receive behavior, not a
|
||||||
|
RemoteTerm retry setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
|
||||||
Recommended: 24 hours or higher.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -746,6 +733,13 @@ export function SettingsRadioSection({
|
|||||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||||
</p>
|
</p>
|
||||||
|
{health?.radio_device_info?.max_contacts != null &&
|
||||||
|
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
|
||||||
|
contacts. The effective cap will be limited to what the radio supports.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{floodError && (
|
{floodError && (
|
||||||
@@ -760,8 +754,28 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Advertising & Discovery ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-base">Hear & Be Heard</Label>
|
<Label className="text-base">Advertising & Discovery</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="advert-interval"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={advertIntervalHours}
|
||||||
|
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||||
|
Recommended: 24 hours or higher.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
|||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
||||||
import type { Channel, Contact, Conversation } from '../types';
|
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
|
||||||
|
|
||||||
interface UseContactsAndChannelsArgs {
|
interface UseContactsAndChannelsArgs {
|
||||||
setActiveConversation: (conv: Conversation | null) => void;
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
|
|||||||
[fetchUndecryptedCountInternal, setActiveConversation]
|
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBulkCreateHashtagChannels = useCallback(
|
||||||
|
async (
|
||||||
|
channelNames: string[],
|
||||||
|
tryHistorical: boolean
|
||||||
|
): Promise<BulkCreateHashtagChannelsResult> => {
|
||||||
|
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
const data = await api.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
|
||||||
|
if (tryHistorical && result.decrypt_started) {
|
||||||
|
fetchUndecryptedCountInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[fetchUndecryptedCountInternal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteChannel = useCallback(
|
const handleDeleteChannel = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,14 @@
|
|||||||
--badge-mention: var(--destructive);
|
--badge-mention: var(--destructive);
|
||||||
--badge-mention-foreground: var(--destructive-foreground);
|
--badge-mention-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
/* Sidebar navigation accents */
|
||||||
|
--sidebar-icon-color: hsl(var(--foreground));
|
||||||
|
--sidebar-icon-hover-color: hsl(var(--foreground));
|
||||||
|
--sidebar-icon-active-color: hsl(var(--foreground));
|
||||||
|
--sidebar-label-color: hsl(var(--muted-foreground));
|
||||||
|
--sidebar-label-hover-color: hsl(var(--foreground));
|
||||||
|
--sidebar-label-active-color: hsl(var(--foreground));
|
||||||
|
|
||||||
/* Error toast */
|
/* Error toast */
|
||||||
--toast-error: 0 30% 14%;
|
--toast-error: 0 30% 14%;
|
||||||
--toast-error-foreground: 0 56% 77%;
|
--toast-error-foreground: 0 56% 77%;
|
||||||
@@ -126,6 +134,50 @@
|
|||||||
animation: message-highlight 2s ease-out forwards;
|
animation: message-highlight 2s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: var(--sidebar-icon-color);
|
||||||
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-icon svg {
|
||||||
|
stroke-width: 2.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-color);
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row:hover .sidebar-tool-icon,
|
||||||
|
.sidebar-action-row:focus-visible .sidebar-tool-icon {
|
||||||
|
color: var(--sidebar-icon-hover-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row:hover .sidebar-tool-label,
|
||||||
|
.sidebar-action-row:focus-visible .sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
|
||||||
|
color: var(--sidebar-icon-active-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
|
||||||
|
color: var(--sidebar-label-active-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Constrain CodeMirror editor width */
|
/* Constrain CodeMirror editor width */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
|
||||||
|
|
||||||
|
describe('BulkAddChannelResultModal', () => {
|
||||||
|
it('renders links only for newly created rooms', () => {
|
||||||
|
render(
|
||||||
|
<BulkAddChannelResultModal
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
result={{
|
||||||
|
created_channels: [
|
||||||
|
{
|
||||||
|
key: 'AA'.repeat(16),
|
||||||
|
name: '#ops',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'BB'.repeat(16),
|
||||||
|
name: '#mesh-room',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
existing_count: 3,
|
||||||
|
invalid_names: ['bad_room'],
|
||||||
|
decrypt_started: true,
|
||||||
|
decrypt_total_packets: 8,
|
||||||
|
message: 'Created 2 rooms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const opsLink = screen.getByRole('link', { name: '#ops' });
|
||||||
|
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
|
||||||
|
|
||||||
|
expect(opsLink.getAttribute('href')).toContain('#channel/');
|
||||||
|
expect(meshLink.getAttribute('href')).toContain('#channel/');
|
||||||
|
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
|
||||||
|
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,32 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links valid channel references when followed by clause punctuation', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
messages={[
|
||||||
|
createMessage({
|
||||||
|
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
contacts={[]}
|
||||||
|
loading={false}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
|
||||||
|
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
|
||||||
|
});
|
||||||
|
|
||||||
it('links valid channel references in direct messages too', async () => {
|
it('links valid channel references in direct messages too', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChannelReferenceClick = vi.fn();
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|||||||
@@ -122,11 +122,21 @@ describe('linked channel references', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid or embedded channel-like text', () => {
|
it('finds linked channel references terminated by clause punctuation', () => {
|
||||||
expect(
|
expect(
|
||||||
findLinkedChannelReferences(
|
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
|
||||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
).toEqual([
|
||||||
)
|
{ label: '#mesh-room', start: 5, end: 15 },
|
||||||
).toEqual([]);
|
{ label: '#ops2', start: 22, end: 27 },
|
||||||
|
{ label: '#alpha-room', start: 37, end: 48 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid or embedded channel-like text', () => {
|
||||||
|
const references = findLinkedChannelReferences(
|
||||||
|
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -44,6 +45,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
|
||||||
{...overrides}
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -111,6 +113,53 @@ describe('NewMessageModal form reset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bulk hashtag tab', () => {
|
||||||
|
it('is only visible when enabled', () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal(true, { showBulkAddChannelTab: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
|
||||||
|
'data-state',
|
||||||
|
'active'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||||
|
'#Ops{enter}mesh-room another-room #Ops'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
|
||||||
|
['#ops', '#mesh-room', '#another-room'],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows invalid bulk room names before submitting', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal(true, { showBulkAddChannelTab: true });
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||||
|
'good-room bad_room'
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||||
|
|
||||||
|
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('new-contact tab', () => {
|
describe('new-contact tab', () => {
|
||||||
it('clears name and key after successful Create', async () => {
|
it('clears name and key after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
|||||||
useRepeaterDashboard: () => mockHook,
|
useRepeaterDashboard: () => mockHook,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
api: {
|
||||||
|
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock sonner toast
|
// Mock sonner toast
|
||||||
vi.mock('../components/ui/sonner', () => ({
|
vi.mock('../components/ui/sonner', () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -418,6 +426,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<RepeaterDashboard {...defaultProps} />);
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
@@ -634,4 +643,61 @@ describe('RepeaterDashboard', () => {
|
|||||||
overrideSpy.mockRestore();
|
overrideSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('telemetry history', () => {
|
||||||
|
it('loads telemetry history on mount when logged in', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows telemetry history pane in logged-in view even before status fetch', () => {
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates history from live status fetch', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||||
|
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
|
||||||
|
historySpy.mockResolvedValue([]);
|
||||||
|
|
||||||
|
mockHook.loggedIn = true;
|
||||||
|
mockHook.paneData.status = {
|
||||||
|
battery_volts: 4.2,
|
||||||
|
tx_queue_len: 0,
|
||||||
|
noise_floor_dbm: -120,
|
||||||
|
last_rssi_dbm: -85,
|
||||||
|
last_snr_db: 7.5,
|
||||||
|
packets_received: 100,
|
||||||
|
packets_sent: 50,
|
||||||
|
airtime_seconds: 600,
|
||||||
|
rx_airtime_seconds: 1200,
|
||||||
|
uptime_seconds: 86400,
|
||||||
|
sent_flood: 10,
|
||||||
|
sent_direct: 40,
|
||||||
|
recv_flood: 30,
|
||||||
|
recv_direct: 70,
|
||||||
|
flood_dups: 1,
|
||||||
|
direct_dups: 0,
|
||||||
|
full_events: 0,
|
||||||
|
telemetry_history: [liveEntry],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
|
|||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
blocked_keys: [],
|
blocked_keys: [],
|
||||||
blocked_names: [],
|
blocked_names: [],
|
||||||
|
discovery_blocked_types: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
@@ -615,10 +616,10 @@ describe('SettingsModal', () => {
|
|||||||
openDatabaseSection();
|
openDatabaseSection();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||||
import type { Contact } from '../types';
|
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
|
||||||
|
|
||||||
// Mock api module
|
// Mock api module
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
|
|||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
createContact: vi.fn(),
|
createContact: vi.fn(),
|
||||||
createChannel: vi.fn(),
|
createChannel: vi.fn(),
|
||||||
|
bulkCreateHashtagChannels: vi.fn(),
|
||||||
deleteContact: vi.fn(),
|
deleteContact: vi.fn(),
|
||||||
deleteChannel: vi.fn(),
|
deleteChannel: vi.fn(),
|
||||||
decryptHistoricalPackets: vi.fn(),
|
decryptHistoricalPackets: vi.fn(),
|
||||||
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
|
|||||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bulk hashtag creation', () => {
|
||||||
|
it('refreshes channels and returns the backend result', async () => {
|
||||||
|
const { api } = await import('../api');
|
||||||
|
const resultPayload: BulkCreateHashtagChannelsResult = {
|
||||||
|
created_channels: [
|
||||||
|
{
|
||||||
|
key: 'AA'.repeat(16),
|
||||||
|
name: '#ops',
|
||||||
|
is_hashtag: true,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
existing_count: 1,
|
||||||
|
invalid_names: [],
|
||||||
|
decrypt_started: true,
|
||||||
|
decrypt_total_packets: 12,
|
||||||
|
message: 'Created 1 room',
|
||||||
|
};
|
||||||
|
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
|
||||||
|
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
|
||||||
|
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
|
||||||
|
|
||||||
|
const { result } = renderUseContactsAndChannels();
|
||||||
|
|
||||||
|
let response: BulkCreateHashtagChannelsResult | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
|
||||||
|
expect(api.getChannels).toHaveBeenCalled();
|
||||||
|
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||||
|
expect(response).toEqual(resultPayload);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,10 +49,6 @@
|
|||||||
--overlay: 220 20% 10%;
|
--overlay: 220 20% 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='light'] .sidebar-tool-label {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||||
:root[data-theme='windows-95'] {
|
:root[data-theme='windows-95'] {
|
||||||
--background: 180 100% 25%;
|
--background: 180 100% 25%;
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ export interface ChannelDetail {
|
|||||||
top_senders_24h: ChannelTopSender[];
|
top_senders_24h: ChannelTopSender[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkCreateHashtagChannelsResult {
|
||||||
|
created_channels: Channel[];
|
||||||
|
existing_count: number;
|
||||||
|
invalid_names: string[];
|
||||||
|
decrypt_started: boolean;
|
||||||
|
decrypt_total_packets: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single path that a message took to reach us */
|
/** A single path that a message took to reach us */
|
||||||
export interface MessagePath {
|
export interface MessagePath {
|
||||||
/** Hex-encoded routing path */
|
/** Hex-encoded routing path */
|
||||||
@@ -332,6 +341,7 @@ export interface AppSettings {
|
|||||||
flood_scope: string;
|
flood_scope: string;
|
||||||
blocked_keys: string[];
|
blocked_keys: string[];
|
||||||
blocked_names: string[];
|
blocked_names: string[];
|
||||||
|
discovery_blocked_types: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
@@ -342,6 +352,7 @@ export interface AppSettingsUpdate {
|
|||||||
flood_scope?: string;
|
flood_scope?: string;
|
||||||
blocked_keys?: string[];
|
blocked_keys?: string[];
|
||||||
blocked_names?: string[];
|
blocked_names?: string[];
|
||||||
|
discovery_blocked_types?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigratePreferencesRequest {
|
export interface MigratePreferencesRequest {
|
||||||
@@ -405,6 +416,12 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryHistoryEntry {
|
||||||
|
timestamp: number;
|
||||||
|
data: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepeaterNeighborsResponse {
|
export interface RepeaterNeighborsResponse {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Channel messages have format "sender: message".
|
* Channel messages have format "sender: message".
|
||||||
*/
|
*/
|
||||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
|
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
|
||||||
|
|
||||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||||
const colonIndex = text.indexOf(': ');
|
const colonIndex = text.indexOf(': ');
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate URL hash from conversation
|
// Generate URL hash from conversation
|
||||||
function getConversationHash(conv: Conversation | null): string {
|
export function getConversationHash(conv: Conversation | null): string {
|
||||||
if (!conv) return '';
|
if (!conv) return '';
|
||||||
if (conv.type === 'raw') return '#raw';
|
if (conv.type === 'raw') return '#raw';
|
||||||
if (conv.type === 'map') return '#map';
|
if (conv.type === 'map') return '#map';
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
|
|||||||
echo -ne "${BLUE}[backend lint]${NC} "
|
echo -ne "${BLUE}[backend lint]${NC} "
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
uv run ruff check app/ tests/ --fix --quiet
|
uv run ruff check app/ tests/ --fix --quiet
|
||||||
uv run ruff format app/ tests/ --check --quiet
|
uv run ruff format app/ tests/ --quiet
|
||||||
echo -e "${GREEN}Passed!${NC}"
|
echo -e "${GREEN}Passed!${NC}"
|
||||||
|
|
||||||
echo -ne "${BLUE}[frontend lint]${NC} "
|
echo -ne "${BLUE}[frontend lint]${NC} "
|
||||||
|
|||||||
+150
-5
@@ -131,6 +131,62 @@ class TestHealthEndpoint:
|
|||||||
class TestDebugEndpoint:
|
class TestDebugEndpoint:
|
||||||
"""Test the debug support snapshot endpoint."""
|
"""Test the debug support snapshot endpoint."""
|
||||||
|
|
||||||
|
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
||||||
|
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
||||||
|
from app.routers.debug import _sanitize_radio_probe_self_info
|
||||||
|
|
||||||
|
sanitized = _sanitize_radio_probe_self_info(
|
||||||
|
{
|
||||||
|
"name": "FlightlessTestNode",
|
||||||
|
"adv_lat": 47.786445,
|
||||||
|
"adv_lon": -122.344011,
|
||||||
|
"radio_freq": 910.525,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sanitized == {
|
||||||
|
"name": "FlightlessTestNode",
|
||||||
|
"radio_freq": 910.525,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_support_snapshot_only_keeps_erroring_fanouts_in_health_summary(self):
|
||||||
|
"""Debug health summary should only include fanouts with non-empty last_error."""
|
||||||
|
from app.routers.debug import _build_debug_health_summary
|
||||||
|
from app.routers.health import FanoutStatusResponse
|
||||||
|
|
||||||
|
summary = _build_debug_health_summary(
|
||||||
|
{
|
||||||
|
"database_size_mb": 1.23,
|
||||||
|
"oldest_undecrypted_timestamp": 123,
|
||||||
|
"fanout_statuses": {
|
||||||
|
"ok-id": {
|
||||||
|
"name": "OK Fanout",
|
||||||
|
"type": "bot",
|
||||||
|
"status": "connected",
|
||||||
|
"last_error": None,
|
||||||
|
},
|
||||||
|
"err-id": {
|
||||||
|
"name": "Broken Fanout",
|
||||||
|
"type": "mqtt_private",
|
||||||
|
"status": "error",
|
||||||
|
"last_error": "broker down",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bots_disabled_source": None,
|
||||||
|
"basic_auth_enabled": False,
|
||||||
|
},
|
||||||
|
radio_state="connected",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary.fanouts_with_errors == {
|
||||||
|
"err-id": FanoutStatusResponse(
|
||||||
|
name="Broken Fanout",
|
||||||
|
type="mqtt_private",
|
||||||
|
status="error",
|
||||||
|
last_error="broker down",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
|
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
|
||||||
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
|
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
|
||||||
@@ -157,8 +213,21 @@ class TestDebugEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
assert "app_info" not in payload["health"]
|
||||||
|
assert "bots_disabled" not in payload["health"]
|
||||||
|
assert "connection_info" not in payload["health"]
|
||||||
|
assert "fanout_statuses" not in payload["health"]
|
||||||
|
assert "radio_connected" not in payload["health"]
|
||||||
|
assert "radio_device_info" not in payload["health"]
|
||||||
|
assert "radio_initializing" not in payload["health"]
|
||||||
|
assert "status" not in payload["health"]
|
||||||
|
assert payload["health"]["fanouts_with_errors"] == {}
|
||||||
|
assert payload["health"]["radio_state"] == "disconnected"
|
||||||
assert payload["radio_probe"]["performed"] is False
|
assert payload["radio_probe"]["performed"] is False
|
||||||
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
|
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
|
||||||
|
assert "multi_acks_enabled" not in payload["radio_probe"]
|
||||||
|
assert "max_channels" not in payload["runtime"]
|
||||||
|
assert "path_hash_mode" not in payload["runtime"]
|
||||||
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
||||||
assert payload["database"]["total_dms"] == 0
|
assert payload["database"]["total_dms"] == 0
|
||||||
assert payload["database"]["total_channel_messages"] == 0
|
assert payload["database"]["total_channel_messages"] == 0
|
||||||
@@ -213,6 +282,47 @@ class TestDebugEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_support_snapshot_includes_persisted_app_settings(self, test_db, client):
|
||||||
|
"""Debug snapshot should expose the stored app settings row."""
|
||||||
|
pub_key = "ab" * 32
|
||||||
|
await _insert_contact(pub_key, "Alice")
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/settings",
|
||||||
|
json={
|
||||||
|
"max_radio_contacts": 321,
|
||||||
|
"auto_decrypt_dm_on_advert": True,
|
||||||
|
"sidebar_sort_order": "alpha",
|
||||||
|
"advert_interval": 7200,
|
||||||
|
"flood_scope": "US-CA",
|
||||||
|
"blocked_keys": [pub_key],
|
||||||
|
"blocked_names": ["Mallory"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/settings/favorites/toggle",
|
||||||
|
json={"type": "contact", "id": pub_key},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await client.get("/api/debug")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["settings"]["max_radio_contacts"] == 321
|
||||||
|
assert payload["settings"]["auto_decrypt_dm_on_advert"] is True
|
||||||
|
assert payload["settings"]["advert_interval"] == 7200
|
||||||
|
assert payload["settings"]["flood_scope"] == "#US-CA"
|
||||||
|
assert payload["settings"]["blocked_keys_count"] == 1
|
||||||
|
assert payload["settings"]["blocked_names_count"] == 1
|
||||||
|
assert "favorites" not in payload["settings"]
|
||||||
|
assert "blocked_keys" not in payload["settings"]
|
||||||
|
assert "blocked_names" not in payload["settings"]
|
||||||
|
assert "sidebar_sort_order" not in payload["settings"]
|
||||||
|
|
||||||
|
|
||||||
class TestRadioDisconnectedHandler:
|
class TestRadioDisconnectedHandler:
|
||||||
"""Test that RadioDisconnectedError maps to 503."""
|
"""Test that RadioDisconnectedError maps to 503."""
|
||||||
@@ -1057,7 +1167,14 @@ class TestRawPacketRepository:
|
|||||||
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
|
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
|
||||||
# Insert old but decrypted packet (should NOT be deleted)
|
# Insert old but decrypted packet (should NOT be deleted)
|
||||||
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
|
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
|
||||||
await RawPacketRepository.mark_decrypted(old_id, 1)
|
msg_id = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="test_key",
|
||||||
|
text="test",
|
||||||
|
sender_timestamp=old_timestamp,
|
||||||
|
received_at=old_timestamp,
|
||||||
|
)
|
||||||
|
await RawPacketRepository.mark_decrypted(old_id, msg_id)
|
||||||
|
|
||||||
# Prune packets older than 10 days
|
# Prune packets older than 10 days
|
||||||
deleted = await RawPacketRepository.prune_old_undecrypted(10)
|
deleted = await RawPacketRepository.prune_old_undecrypted(10)
|
||||||
@@ -1081,10 +1198,24 @@ class TestRawPacketRepository:
|
|||||||
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
|
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
|
||||||
"""Purge linked raw packets removes only rows with a message_id."""
|
"""Purge linked raw packets removes only rows with a message_id."""
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
|
msg_id_1 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k1",
|
||||||
|
text="t1",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
|
msg_id_2 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k2",
|
||||||
|
text="t2",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
|
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
|
||||||
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
|
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
|
||||||
await RawPacketRepository.mark_decrypted(linked_1, 101)
|
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||||
await RawPacketRepository.mark_decrypted(linked_2, 102)
|
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||||
|
|
||||||
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
|
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
|
||||||
|
|
||||||
@@ -1122,10 +1253,24 @@ class TestMaintenanceEndpoint:
|
|||||||
from app.routers.packets import MaintenanceRequest, run_maintenance
|
from app.routers.packets import MaintenanceRequest, run_maintenance
|
||||||
|
|
||||||
ts = int(time.time())
|
ts = int(time.time())
|
||||||
|
msg_id_1 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k1",
|
||||||
|
text="t1",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
|
msg_id_2 = await MessageRepository.create(
|
||||||
|
msg_type="PRIV",
|
||||||
|
conversation_key="k2",
|
||||||
|
text="t2",
|
||||||
|
sender_timestamp=ts,
|
||||||
|
received_at=ts,
|
||||||
|
)
|
||||||
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
|
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
|
||||||
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
|
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
|
||||||
await RawPacketRepository.mark_decrypted(linked_1, 201)
|
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
|
||||||
await RawPacketRepository.mark_decrypted(linked_2, 202)
|
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
|
||||||
|
|
||||||
request = MaintenanceRequest(purge_linked_raw_packets=True)
|
request = MaintenanceRequest(purge_linked_raw_packets=True)
|
||||||
result = await run_maintenance(request)
|
result = await run_maintenance(request)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Tests for the channels router endpoints."""
|
"""Tests for the channels router endpoints."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from unittest.mock import patch
|
from hashlib import sha256
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -77,6 +78,55 @@ class TestCreateChannel:
|
|||||||
assert channel is not None
|
assert channel is not None
|
||||||
assert channel.flood_scope_override is None
|
assert channel.flood_scope_override is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
|
||||||
|
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
|
||||||
|
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/channels/bulk-hashtag",
|
||||||
|
json={
|
||||||
|
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
|
||||||
|
"try_historical": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert [channel["name"] for channel in data["created_channels"]] == [
|
||||||
|
"#mesh-room",
|
||||||
|
"#another-room",
|
||||||
|
]
|
||||||
|
assert data["existing_count"] == 2
|
||||||
|
assert data["invalid_names"] == ["bad_room"]
|
||||||
|
assert data["decrypt_started"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
|
||||||
|
new=AsyncMock(return_value=7),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.channels._run_historical_channel_decryption_for_channels",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_decrypt,
|
||||||
|
):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/channels/bulk-hashtag",
|
||||||
|
json={
|
||||||
|
"channel_names": ["ops", "mesh-room"],
|
||||||
|
"try_historical": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert data["decrypt_started"] is True
|
||||||
|
assert data["decrypt_total_packets"] == 7
|
||||||
|
mock_decrypt.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
class TestPublicChannelProtection:
|
class TestPublicChannelProtection:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+19
-17
@@ -513,7 +513,9 @@ class TestMigration018:
|
|||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
|
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
|
||||||
assert rows[1]["message_id"] == 42
|
# message_id=42 was orphaned (no matching messages row), so
|
||||||
|
# migration 49's orphan cleanup NULLs it out.
|
||||||
|
assert rows[1]["message_id"] is None
|
||||||
|
|
||||||
# Verify payload_hash unique index still works
|
# Verify payload_hash unique index still works
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@@ -1247,8 +1249,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 9
|
assert applied == 12
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1319,8 +1321,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 9
|
assert applied == 12
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1386,8 +1388,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 3
|
assert applied == 6
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1439,8 +1441,8 @@ class TestMigration040:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 8
|
assert applied == 11
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1501,8 +1503,8 @@ class TestMigration041:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 7
|
assert applied == 10
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1554,8 +1556,8 @@ class TestMigration042:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 6
|
assert applied == 9
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1694,8 +1696,8 @@ class TestMigration046:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 2
|
assert applied == 5
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1788,8 +1790,8 @@ class TestMigration047:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 1
|
assert applied == 4
|
||||||
assert await get_version(conn) == 47
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ class TestDiscoverMesh:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
@@ -454,6 +459,11 @@ class TestDiscoverMesh:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
) as mock_promote,
|
) as mock_promote,
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event") as mock_broadcast,
|
patch("app.routers.radio.broadcast_event") as mock_broadcast,
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
@@ -779,6 +789,11 @@ class TestTracePath:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"app.routers.radio.reconcile_contact_messages",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(0, 0),
|
||||||
|
),
|
||||||
patch("app.routers.radio.broadcast_event"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
||||||
|
|||||||
@@ -884,6 +884,11 @@ class TestSyncAndOffloadContacts:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.promote_prefix_contacts_for_contact",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
|
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
|
||||||
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
|
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Tests for repeater telemetry history: repository CRUD and read-only endpoint."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import CONTACT_TYPE_REPEATER
|
||||||
|
from app.repository import (
|
||||||
|
ContactRepository,
|
||||||
|
RepeaterTelemetryRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
KEY_A = "aa" * 32
|
||||||
|
KEY_B = "bb" * 32
|
||||||
|
|
||||||
|
SAMPLE_STATUS = {
|
||||||
|
"battery_volts": 4.15,
|
||||||
|
"tx_queue_len": 0,
|
||||||
|
"noise_floor_dbm": -100,
|
||||||
|
"last_rssi_dbm": -80,
|
||||||
|
"last_snr_db": 5.0,
|
||||||
|
"packets_received": 100,
|
||||||
|
"packets_sent": 50,
|
||||||
|
"airtime_seconds": 300,
|
||||||
|
"rx_airtime_seconds": 200,
|
||||||
|
"uptime_seconds": 1000,
|
||||||
|
"sent_flood": 10,
|
||||||
|
"sent_direct": 40,
|
||||||
|
"recv_flood": 60,
|
||||||
|
"recv_direct": 40,
|
||||||
|
"flood_dups": 5,
|
||||||
|
"direct_dups": 2,
|
||||||
|
"full_events": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_repeater(public_key: str, name: str = "Repeater"):
|
||||||
|
"""Insert a repeater contact into the test database."""
|
||||||
|
await ContactRepository.upsert(
|
||||||
|
{
|
||||||
|
"public_key": public_key,
|
||||||
|
"name": name,
|
||||||
|
"type": CONTACT_TYPE_REPEATER,
|
||||||
|
"flags": 0,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": None,
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
"last_seen": None,
|
||||||
|
"on_radio": False,
|
||||||
|
"last_contacted": None,
|
||||||
|
"first_seen": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def _db(test_db):
|
||||||
|
"""Set up test DB and patch the repeater_telemetry module's db reference."""
|
||||||
|
from app.repository import repeater_telemetry
|
||||||
|
|
||||||
|
original = repeater_telemetry.db
|
||||||
|
repeater_telemetry.db = test_db
|
||||||
|
try:
|
||||||
|
yield test_db
|
||||||
|
finally:
|
||||||
|
repeater_telemetry.db = original
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepeaterTelemetryRepository:
|
||||||
|
"""Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_and_get_history(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=KEY_A,
|
||||||
|
timestamp=now - 3600,
|
||||||
|
data={**SAMPLE_STATUS, "battery_volts": 4.15},
|
||||||
|
)
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=KEY_A,
|
||||||
|
timestamp=now,
|
||||||
|
data={**SAMPLE_STATUS, "battery_volts": 4.10},
|
||||||
|
)
|
||||||
|
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
|
||||||
|
assert len(history) == 2
|
||||||
|
assert history[0]["data"]["battery_volts"] == 4.15
|
||||||
|
assert history[1]["data"]["battery_volts"] == 4.10
|
||||||
|
assert history[0]["timestamp"] < history[1]["timestamp"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_filters_by_time(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS)
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS)
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_isolates_by_key(self, _db):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
await _insert_repeater(KEY_B)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1}
|
||||||
|
)
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9}
|
||||||
|
)
|
||||||
|
|
||||||
|
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||||
|
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
|
||||||
|
assert len(history_a) == 1
|
||||||
|
assert len(history_b) == 1
|
||||||
|
assert history_a[0]["data"]["battery_volts"] == 4.1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_stored_as_json(self, _db):
|
||||||
|
"""Verify the data column stores valid JSON that round-trips correctly."""
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]["data"] == SAMPLE_STATUS
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelemetryHistoryEndpoint:
|
||||||
|
"""Tests for the read-only GET telemetry-history endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_history_for_repeater(self, _db, client):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
now = int(time.time())
|
||||||
|
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["data"]["battery_volts"] == 4.15
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_empty_list_when_no_history(self, _db, client):
|
||||||
|
await _insert_repeater(KEY_A)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejects_non_repeater(self, _db, client):
|
||||||
|
await ContactRepository.upsert(
|
||||||
|
{
|
||||||
|
"public_key": KEY_A,
|
||||||
|
"name": "Node",
|
||||||
|
"type": 0,
|
||||||
|
"flags": 0,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": None,
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
"last_seen": None,
|
||||||
|
"on_radio": False,
|
||||||
|
"last_contacted": None,
|
||||||
|
"first_seen": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_for_unknown_contact(self, _db, client):
|
||||||
|
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -630,6 +630,7 @@ class TestAppSettingsRepository:
|
|||||||
"flood_scope": "",
|
"flood_scope": "",
|
||||||
"blocked_keys": "[]",
|
"blocked_keys": "[]",
|
||||||
"blocked_names": "[]",
|
"blocked_names": "[]",
|
||||||
|
"discovery_blocked_types": "[]",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||||
|
|||||||
Reference in New Issue
Block a user