mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
4 Commits
5f0d042252
...
7151cf3846
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7151cf3846 | ||
|
|
6e5256acce | ||
|
|
7d27567ae9 | ||
|
|
95c874e643 |
@@ -17,6 +17,7 @@ from app.frontend_static import (
|
||||
)
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_background_contact_reconciliation,
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
@@ -95,6 +96,7 @@ async def lifespan(app: FastAPI):
|
||||
pass
|
||||
await fanout_manager.stop_all()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
|
||||
@@ -548,11 +548,14 @@ class RadioManager:
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the radio."""
|
||||
from app.radio_sync import stop_background_contact_reconciliation
|
||||
|
||||
clear_keys()
|
||||
self._reset_reconnect_error_broadcasts()
|
||||
if self._meshcore is None:
|
||||
return
|
||||
|
||||
await stop_background_contact_reconciliation()
|
||||
await self._acquire_operation_lock("disconnect", blocking=True)
|
||||
try:
|
||||
mc = self._meshcore
|
||||
|
||||
@@ -166,6 +166,9 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Startup/background contact reconciliation task handle
|
||||
_contact_reconcile_task: asyncio.Task | None = None
|
||||
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
@@ -266,30 +269,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
||||
# commands.remove_contact() sends the remove command over
|
||||
# the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the
|
||||
# library — there's no public API to clear a single
|
||||
# contact from the cache, and the library only refreshes
|
||||
# it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
||||
# mc.get_contact_by_key_prefix() to check whether a
|
||||
# contact is already loaded on the radio. That method
|
||||
# searches mc._contacts. If we don't evict the removed
|
||||
# contact from the cache here, get_contact_by_key_prefix()
|
||||
# will still find it and skip the add_contact() call —
|
||||
# meaning contacts never get loaded back onto the radio
|
||||
# after offload. The result: no DM ACKs, degraded routing
|
||||
# for potentially minutes until the next periodic sync
|
||||
# refreshes the cache from the (now-empty) radio.
|
||||
#
|
||||
# We access mc._contacts directly because the library
|
||||
# exposes it as a read-only property (mc.contacts) with
|
||||
# no removal API. The dict is keyed by public_key string.
|
||||
mc._contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
@@ -461,28 +441,28 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
"""Run fast startup sync, then background contact reconcile."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
await ContactRepository.clear_on_radio_except([])
|
||||
|
||||
contacts_result = await sync_and_offload_contacts(mc)
|
||||
contacts_result = await sync_contacts_from_radio(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
||||
# Pass mc directly since the caller already holds the radio operation lock
|
||||
# (asyncio.Lock is not reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
start_background_contact_reconciliation(
|
||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||
expected_mc=mc,
|
||||
)
|
||||
|
||||
return {
|
||||
"contacts": contacts_result,
|
||||
"channels": channels_result,
|
||||
"reloaded": reload_result,
|
||||
"contact_reconcile_started": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -1036,6 +1016,270 @@ async def stop_periodic_sync():
|
||||
# Throttling for contact sync to radio
|
||||
_last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||
|
||||
|
||||
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||
"""Keep the library's contact cache consistent after a successful removal."""
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
|
||||
# remove command over the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the library — there's no
|
||||
# public API to clear a single contact from the cache, and the library only
|
||||
# refreshes it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: contact sync and targeted ensure/load paths use
|
||||
# mc.get_contact_by_key_prefix() to check whether a contact is already
|
||||
# loaded on the radio. That method searches mc._contacts. If we don't evict
|
||||
# the removed contact from the cache here, later syncs will still find it
|
||||
# and skip add_contact() calls, leaving the radio without the contact even
|
||||
# though the app thinks it is resident.
|
||||
mc._contacts.pop(public_key, None)
|
||||
|
||||
|
||||
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
|
||||
"""Return radio contacts keyed by normalized lowercase full public key."""
|
||||
normalized: dict[str, dict] = {}
|
||||
for public_key, contact_data in (contacts or {}).items():
|
||||
normalized[str(public_key).lower()] = contact_data
|
||||
return normalized
|
||||
|
||||
|
||||
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
"""Pull contacts from the radio and persist them to the database without removing them."""
|
||||
synced = 0
|
||||
|
||||
try:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to get contacts from radio: %s. "
|
||||
"If you see this repeatedly, the radio may be visible on the "
|
||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||
"another process with the serial port open (other RemoteTerm "
|
||||
"instances, serial monitors, etc.), verify the firmware is "
|
||||
"up-to-date and in client mode (not repeater), or try a "
|
||||
"power cycle.",
|
||||
result,
|
||||
)
|
||||
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
|
||||
|
||||
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||
return {"synced": synced, "radio_contacts": contacts}
|
||||
except Exception as e:
|
||||
logger.error("Error during contact snapshot sync: %s", e)
|
||||
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
|
||||
|
||||
|
||||
async def _reconcile_radio_contacts_in_background(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
) -> None:
|
||||
"""Converge radio contacts toward the desired favorites+recents working set."""
|
||||
radio_contacts = dict(initial_radio_contacts)
|
||||
removed = 0
|
||||
loaded = 0
|
||||
failed = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
|
||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||
break
|
||||
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
}
|
||||
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
||||
missing_contacts = [
|
||||
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||
]
|
||||
|
||||
if not removable_keys and not missing_contacts:
|
||||
logger.info(
|
||||
"Background contact reconcile complete: %d contacts on radio working set",
|
||||
len(radio_contacts),
|
||||
)
|
||||
break
|
||||
|
||||
progressed = False
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"background_contact_reconcile",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
if mc is not expected_mc:
|
||||
logger.info(
|
||||
"Stopping background contact reconcile: radio transport changed"
|
||||
)
|
||||
break
|
||||
|
||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
}
|
||||
|
||||
for public_key in list(radio_contacts):
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in desired_contacts:
|
||||
continue
|
||||
|
||||
remove_payload = (
|
||||
mc.get_contact_by_key_prefix(public_key[:12])
|
||||
or radio_contacts.get(public_key)
|
||||
or {"public_key": public_key}
|
||||
)
|
||||
try:
|
||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error removing contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if remove_result.type == EventType.OK:
|
||||
radio_contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
removed += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to remove contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
remove_result.payload,
|
||||
)
|
||||
|
||||
if budget > 0:
|
||||
for public_key, contact in desired_contacts.items():
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in radio_contacts:
|
||||
continue
|
||||
|
||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||
radio_contacts[public_key] = {"public_key": public_key}
|
||||
continue
|
||||
|
||||
try:
|
||||
add_payload = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error adding contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if add_result.type == EventType.OK:
|
||||
radio_contacts[public_key] = add_payload
|
||||
loaded += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
reason = add_result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to add contact %s during background reconcile: %s%s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
|
||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||
if not progressed:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Background contact reconcile task cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
|
||||
finally:
|
||||
if removed > 0 or loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
|
||||
removed,
|
||||
loaded,
|
||||
failed,
|
||||
)
|
||||
|
||||
|
||||
def start_background_contact_reconciliation(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
) -> None:
|
||||
"""Start or replace the background contact reconcile task for the current radio."""
|
||||
global _contact_reconcile_task
|
||||
|
||||
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
|
||||
_contact_reconcile_task.cancel()
|
||||
|
||||
_contact_reconcile_task = asyncio.create_task(
|
||||
_reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts=initial_radio_contacts,
|
||||
expected_mc=expected_mc,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Started background contact reconcile for %d radio contact(s)",
|
||||
len(initial_radio_contacts),
|
||||
)
|
||||
|
||||
|
||||
async def stop_background_contact_reconciliation() -> None:
|
||||
"""Stop the background contact reconcile task."""
|
||||
global _contact_reconcile_task
|
||||
|
||||
if _contact_reconcile_task and not _contact_reconcile_task.done():
|
||||
_contact_reconcile_task.cancel()
|
||||
try:
|
||||
await _contact_reconcile_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_contact_reconcile_task = None
|
||||
|
||||
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
|
||||
@@ -62,7 +62,7 @@ def _login_rejected_message(label: str) -> str:
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
||||
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
||||
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -24,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -251,6 +252,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||
// so the display reflects the original send order rather than our radio's receipt order.
|
||||
const activeContactIsRoom =
|
||||
activeConversation?.type === 'contact' &&
|
||||
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||
const sortedMessages = useMemo(() => {
|
||||
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||
return [...messages].sort((a, b) => {
|
||||
const aTs = a.sender_timestamp ?? a.received_at;
|
||||
const bTs = b.sender_timestamp ?? b.received_at;
|
||||
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||
});
|
||||
}, [activeContactIsRoom, messages]);
|
||||
|
||||
const {
|
||||
unreadCounts,
|
||||
mentions,
|
||||
@@ -427,7 +443,7 @@ export function App() {
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages,
|
||||
messages: sortedMessages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ServerLoginStatusBanner
|
||||
attempt={lastLoginAttempt}
|
||||
loading={loginLoading}
|
||||
canRetryPassword={password.trim().length > 0}
|
||||
onRetryPassword={() => handleRepeaterLogin(password)}
|
||||
onRetryBlank={handleRepeaterGuestLogin}
|
||||
blankRetryLabel="Retry Existing-Access Login"
|
||||
/>
|
||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import {
|
||||
buildServerLoginAttemptFromError,
|
||||
buildServerLoginAttemptFromResponse,
|
||||
type ServerLoginAttemptState,
|
||||
} from '../utils/serverLoginState';
|
||||
|
||||
interface RoomServerPanelProps {
|
||||
contact: Contact;
|
||||
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
status: null,
|
||||
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setAuthenticated(false);
|
||||
setLastLoginAttempt(null);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
status: null,
|
||||
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const performLogin = useCallback(
|
||||
async (password: string) => {
|
||||
async (nextPassword: string, method: 'password' | 'blank') => {
|
||||
if (loginLoading) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
const result = await api.roomLogin(contact.public_key, nextPassword);
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
|
||||
setAuthenticated(true);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
toast.success('Login confirmed by the room server.');
|
||||
} else {
|
||||
toast.warning('Room login not confirmed', {
|
||||
description: result.message ?? 'Room login was not confirmed',
|
||||
toast.warning("Couldn't confirm room login", {
|
||||
description:
|
||||
result.message ??
|
||||
'No confirmation came back from the room server. You can still open tools and try again.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
|
||||
setAuthenticated(true);
|
||||
setLoginError(message);
|
||||
toast.error('Room login failed', { description: message });
|
||||
toast.error('Room login request failed', {
|
||||
description: `${message}. You can still open tools and retry the login from here.`,
|
||||
});
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (password: string) => {
|
||||
await performLogin(password);
|
||||
persistAfterLogin(password);
|
||||
async (nextPassword: string) => {
|
||||
await performLogin(nextPassword, 'password');
|
||||
persistAfterLogin(nextPassword);
|
||||
},
|
||||
[performLogin, persistAfterLogin]
|
||||
);
|
||||
|
||||
const handleLoginAsGuest = useCallback(async () => {
|
||||
await performLogin('');
|
||||
await performLogin('', 'blank');
|
||||
persistAfterLogin('');
|
||||
}, [performLogin, persistAfterLogin]);
|
||||
|
||||
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||
const showLoginFailureState =
|
||||
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
onLoginAsGuest={handleLoginAsGuest}
|
||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||
passwordPlaceholder="Room server password..."
|
||||
guestLabel="Login with ACL / Guest"
|
||||
guestLabel="Login with Existing Access / Guest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{showLoginFailureState ? (
|
||||
<ServerLoginStatusBanner
|
||||
attempt={lastLoginAttempt}
|
||||
loading={loginLoading}
|
||||
canRetryPassword={password.trim().length > 0}
|
||||
onRetryPassword={() => handleLogin(password)}
|
||||
onRetryBlank={handleLoginAsGuest}
|
||||
blankRetryLabel="Retry Existing-Access Login"
|
||||
showRetryActions={false}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{showLoginFailureState ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleLogin(password)}
|
||||
disabled={loginLoading || password.trim().length === 0}
|
||||
>
|
||||
Retry Password Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Retry Existing-Access Login
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal file
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button } from './ui/button';
|
||||
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
|
||||
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ServerLoginStatusBannerProps {
|
||||
attempt: ServerLoginAttemptState | null;
|
||||
loading: boolean;
|
||||
canRetryPassword: boolean;
|
||||
onRetryPassword: () => Promise<void> | void;
|
||||
onRetryBlank: () => Promise<void> | void;
|
||||
passwordRetryLabel?: string;
|
||||
blankRetryLabel?: string;
|
||||
showRetryActions?: boolean;
|
||||
}
|
||||
|
||||
export function ServerLoginStatusBanner({
|
||||
attempt,
|
||||
loading,
|
||||
canRetryPassword,
|
||||
onRetryPassword,
|
||||
onRetryBlank,
|
||||
passwordRetryLabel = 'Retry Password Login',
|
||||
blankRetryLabel = 'Retry Existing-Access Login',
|
||||
showRetryActions = true,
|
||||
}: ServerLoginStatusBannerProps) {
|
||||
if (attempt?.outcome === 'confirmed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = getServerLoginAttemptTone(attempt);
|
||||
const shouldShowActions = showRetryActions;
|
||||
const toneClassName =
|
||||
tone === 'success'
|
||||
? 'border-success/30 bg-success/10 text-success'
|
||||
: tone === 'warning'
|
||||
? 'border-warning/30 bg-warning/10 text-warning'
|
||||
: tone === 'destructive'
|
||||
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
: 'border-border bg-muted/40 text-foreground';
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
|
||||
</p>
|
||||
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
|
||||
</div>
|
||||
{shouldShowActions ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRetryPassword()}
|
||||
disabled={loading || !canRetryPassword}
|
||||
>
|
||||
{passwordRetryLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRetryBlank()}
|
||||
disabled={loading}
|
||||
>
|
||||
{blankRetryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type ServerLoginKind = 'repeater' | 'room';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
|
||||
type StoredPassword = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
const inMemoryPasswords = new Map<string, StoredPassword>();
|
||||
|
||||
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
||||
}
|
||||
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredPassword(kind, publicKey);
|
||||
if (!stored) {
|
||||
setPassword('');
|
||||
if (stored) {
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const inMemoryStored = inMemoryPasswords.get(storageKey);
|
||||
if (inMemoryStored) {
|
||||
setPassword(inMemoryStored.password);
|
||||
setRememberPassword(false);
|
||||
return;
|
||||
}
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
}, [kind, publicKey]);
|
||||
|
||||
setPassword('');
|
||||
setRememberPassword(false);
|
||||
}, [kind, publicKey, storageKey]);
|
||||
|
||||
const persistAfterLogin = useCallback(
|
||||
(submittedPassword: string) => {
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
|
||||
|
||||
if (!rememberPassword) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword('');
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword(trimmedPassword);
|
||||
},
|
||||
[rememberPassword, storageKey]
|
||||
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
RepeaterLppTelemetryResponse,
|
||||
CommandResponse,
|
||||
} from '../types';
|
||||
import {
|
||||
buildServerLoginAttemptFromError,
|
||||
buildServerLoginAttemptFromResponse,
|
||||
type ServerLoginAttemptState,
|
||||
} from '../utils/serverLoginState';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
@@ -41,6 +46,7 @@ interface PaneData {
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
lastLoginAttempt: cached.lastLoginAttempt,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
lastLoginAttempt: entry.lastLoginAttempt,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
|
||||
loggedIn: boolean;
|
||||
loginLoading: boolean;
|
||||
loginError: string | null;
|
||||
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
|
||||
cachedState?.lastLoginAttempt ?? null
|
||||
);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
}, [
|
||||
consoleHistory,
|
||||
conversationId,
|
||||
loggedIn,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
paneDataRef.current = paneData;
|
||||
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
|
||||
const publicKey = getPublicKey();
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
const method = password.trim().length > 0 ? 'password' : 'blank';
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await api.repeaterLogin(publicKey, password);
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
|
||||
setLoggedIn(true);
|
||||
if (!result.authenticated) {
|
||||
const msg = result.message ?? 'Repeater login was not confirmed';
|
||||
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
|
||||
} catch (err) {
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
|
||||
setLoggedIn(true);
|
||||
setLoginError(msg);
|
||||
toast.error('Login request failed', {
|
||||
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockHook: {
|
||||
loggedIn: false,
|
||||
loginLoading: false,
|
||||
loginError: null,
|
||||
lastLoginAttempt: null,
|
||||
paneData: {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
|
||||
@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||
});
|
||||
const onAuthenticatedChange = vi.fn();
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
||||
expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
|
||||
description:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||
});
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('retains the last password for one-click retry after unlocking the panel', async () => {
|
||||
mockApi.roomLogin
|
||||
.mockResolvedValueOnce({
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message: 'No reply heard',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
authenticated: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Repeater password'), {
|
||||
target: { value: 'secret-room-password' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Login with Password'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Retry Password Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
roomContact.public_key,
|
||||
'secret-room-password'
|
||||
);
|
||||
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
roomContact.public_key,
|
||||
'secret-room-password'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only a success toast after a confirmed login', async () => {
|
||||
mockApi.roomLogin.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
authenticated: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads remembered passwords from localStorage', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
it('restores the last in-memory password when local remember is disabled', () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
expect(result.current.rememberPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('stores passwords after login when remember is enabled', () => {
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(true);
|
||||
result.current.setPassword('room-secret');
|
||||
result.current.persistAfterLogin('room-secret');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin(' hello ');
|
||||
});
|
||||
expect(result.current.password).toBe('room-secret');
|
||||
unmount();
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'hello' })
|
||||
);
|
||||
expect(result.current.password).toBe('hello');
|
||||
});
|
||||
|
||||
it('clears stored passwords when login is done with remember disabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
const { result: remounted } = renderHook(() =>
|
||||
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('new-secret');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
|
||||
expect(result.current.password).toBe('');
|
||||
});
|
||||
|
||||
it('preserves remembered passwords on guest login when remember stays enabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:room:room-key',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
expect(remounted.current.password).toBe('room-secret');
|
||||
expect(remounted.current.rememberPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe(null);
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
|
||||
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
|
||||
});
|
||||
|
||||
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe('Auth failed');
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
|
||||
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
|
||||
description: 'Auth failed',
|
||||
});
|
||||
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe('Network error');
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
|
||||
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
|
||||
description:
|
||||
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',
|
||||
|
||||
107
frontend/src/utils/serverLoginState.ts
Normal file
107
frontend/src/utils/serverLoginState.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { RepeaterLoginResponse } from '../types';
|
||||
|
||||
export type ServerLoginMethod = 'password' | 'blank';
|
||||
|
||||
export type ServerLoginAttemptState =
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'confirmed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: true;
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'not_confirmed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: boolean;
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'request_failed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: false;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export function getServerLoginMethodLabel(
|
||||
method: ServerLoginMethod,
|
||||
blankLabel = 'existing-access'
|
||||
): string {
|
||||
return method === 'password' ? 'password' : blankLabel;
|
||||
}
|
||||
|
||||
export function getServerLoginAttemptTone(
|
||||
attempt: ServerLoginAttemptState | null
|
||||
): 'success' | 'warning' | 'destructive' | 'muted' {
|
||||
if (!attempt) return 'muted';
|
||||
if (attempt.outcome === 'confirmed') return 'success';
|
||||
if (attempt.outcome === 'not_confirmed') return 'warning';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export function buildServerLoginAttemptFromResponse(
|
||||
method: ServerLoginMethod,
|
||||
result: RepeaterLoginResponse,
|
||||
entityLabel: string
|
||||
): ServerLoginAttemptState {
|
||||
const methodLabel = getServerLoginMethodLabel(method);
|
||||
const at = Date.now();
|
||||
const target = `the ${entityLabel}`;
|
||||
|
||||
if (result.authenticated) {
|
||||
return {
|
||||
method,
|
||||
outcome: 'confirmed',
|
||||
summary: `Login confirmed by ${target}.`,
|
||||
details: null,
|
||||
heardBack: true,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status === 'timeout') {
|
||||
return {
|
||||
method,
|
||||
outcome: 'not_confirmed',
|
||||
summary: `We couldn't confirm the login.`,
|
||||
details:
|
||||
result.message ??
|
||||
`No confirmation came back from ${target} after the ${methodLabel} login attempt.`,
|
||||
heardBack: false,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
outcome: 'not_confirmed',
|
||||
summary: `Login was not confirmed.`,
|
||||
details:
|
||||
result.message ??
|
||||
`${target} responded, but did not confirm the ${methodLabel} login attempt.`,
|
||||
heardBack: true,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildServerLoginAttemptFromError(
|
||||
method: ServerLoginMethod,
|
||||
message: string,
|
||||
entityLabel: string
|
||||
): ServerLoginAttemptState {
|
||||
const methodLabel = getServerLoginMethodLabel(method);
|
||||
const target = `the ${entityLabel}`;
|
||||
return {
|
||||
method,
|
||||
outcome: 'request_failed',
|
||||
summary: `We couldn't send the login request.`,
|
||||
details: `${target} never acknowledged the ${methodLabel} login attempt. ${message}`,
|
||||
heardBack: false,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,14 @@ contact/channel sync operations, and default channel management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
from meshcore.events import Event
|
||||
|
||||
import app.radio_sync as radio_sync
|
||||
from app.models import Favorite
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
@@ -36,8 +38,6 @@ from app.repository import (
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_sync_state():
|
||||
"""Reset polling pause state, sync timestamp, and radio_manager before/after each test."""
|
||||
import app.radio_sync as radio_sync
|
||||
|
||||
prev_mc = radio_manager._meshcore
|
||||
prev_lock = radio_manager._operation_lock
|
||||
prev_max_channels = radio_manager.max_channels
|
||||
@@ -45,12 +45,20 @@ def reset_sync_state():
|
||||
prev_slot_by_key = radio_manager._channel_slot_by_key.copy()
|
||||
prev_key_by_slot = radio_manager._channel_key_by_slot.copy()
|
||||
prev_pending_channel_key_by_slot = radio_manager._pending_message_channel_key_by_slot.copy()
|
||||
prev_contact_reconcile_task = radio_sync._contact_reconcile_task
|
||||
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
yield
|
||||
if (
|
||||
radio_sync._contact_reconcile_task is not None
|
||||
and radio_sync._contact_reconcile_task is not prev_contact_reconcile_task
|
||||
and not radio_sync._contact_reconcile_task.done()
|
||||
):
|
||||
radio_sync._contact_reconcile_task.cancel()
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
radio_sync._contact_reconcile_task = prev_contact_reconcile_task
|
||||
radio_manager._meshcore = prev_mc
|
||||
radio_manager._operation_lock = prev_lock
|
||||
radio_manager.max_channels = prev_max_channels
|
||||
@@ -433,7 +441,7 @@ class TestSyncAndOffloadAll:
|
||||
"""Test session-local contact radio residency reset behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clears_stale_contact_on_radio_flags_before_reload(self, test_db):
|
||||
async def test_clears_stale_contact_on_radio_flags_before_background_reconcile(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=True)
|
||||
await _insert_contact(KEY_B, "Bob", on_radio=True)
|
||||
|
||||
@@ -441,8 +449,8 @@ class TestSyncAndOffloadAll:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_contacts",
|
||||
new=AsyncMock(return_value={"synced": 0, "removed": 0}),
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
@@ -450,8 +458,7 @@ class TestSyncAndOffloadAll:
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch(
|
||||
"app.radio_sync.sync_recent_contacts_to_radio",
|
||||
new=AsyncMock(return_value={"loaded": 0, "already_on_radio": 0, "failed": 0}),
|
||||
"app.radio_sync.start_background_contact_reconciliation",
|
||||
),
|
||||
):
|
||||
await sync_and_offload_all(mock_mc)
|
||||
@@ -461,6 +468,30 @@ class TestSyncAndOffloadAll:
|
||||
assert alice is not None and alice.on_radio is False
|
||||
assert bob is not None and bob.on_radio is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_starts_background_contact_reconcile_with_radio_snapshot(self, test_db):
|
||||
mock_mc = MagicMock()
|
||||
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||
):
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||
"""Recent advert fallback only considers non-repeaters."""
|
||||
@@ -1036,6 +1067,98 @@ class TestSyncAndOffloadContacts:
|
||||
assert KEY_A in mock_mc._contacts
|
||||
|
||||
|
||||
class TestBackgroundContactReconcile:
|
||||
"""Test the yielding background contact reconcile loop."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rechecks_desired_set_before_deleting_contact(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
alice = await ContactRepository.get_by_key(KEY_A)
|
||||
bob = await ContactRepository.get_by_key(KEY_B)
|
||||
assert alice is not None
|
||||
assert bob is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||
side_effect=[[bob], [alice, bob], [alice, bob]],
|
||||
),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
|
||||
expected_mc=mock_mc,
|
||||
)
|
||||
|
||||
mock_mc.commands.remove_contact.assert_not_called()
|
||||
mock_mc.commands.add_contact.assert_awaited_once()
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["public_key"] == KEY_B
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
|
||||
extra_key = "cc" * 32
|
||||
await _insert_contact(extra_key, "Carol", last_contacted=1000)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
mock_mc.commands.add_contact = AsyncMock()
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
acquire_count = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
nonlocal acquire_count
|
||||
acquire_count += 1
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch("app.radio_sync.get_contacts_selected_for_radio_sync", return_value=[]),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={
|
||||
KEY_A: {"public_key": KEY_A},
|
||||
KEY_B: {"public_key": KEY_B},
|
||||
extra_key: {"public_key": extra_key},
|
||||
},
|
||||
expected_mc=mock_mc,
|
||||
)
|
||||
|
||||
assert acquire_count == 2
|
||||
assert mock_mc.commands.remove_contact.await_count == 3
|
||||
mock_mc.commands.add_contact.assert_not_called()
|
||||
|
||||
|
||||
class TestSyncAndOffloadChannels:
|
||||
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user