mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Update status bar and boot up more quickly with actual radio status
This commit is contained in:
33
app/main.py
33
app/main.py
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -34,6 +35,22 @@ setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _startup_radio_connect_and_setup() -> None:
|
||||
"""Connect/setup the radio in the background so HTTP serving can start immediately."""
|
||||
try:
|
||||
connected = await radio_manager.reconnect(broadcast_on_success=False)
|
||||
if connected:
|
||||
await radio_manager.post_connect_setup()
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
logger.info("Connected to radio")
|
||||
else:
|
||||
logger.warning("Failed to connect to radio on startup")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage database and radio connection lifecycle."""
|
||||
@@ -47,13 +64,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await ensure_default_channels()
|
||||
|
||||
try:
|
||||
await radio_manager.connect()
|
||||
logger.info("Connected to radio")
|
||||
await radio_manager.post_connect_setup()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
@@ -65,9 +75,18 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start fanout modules: %s", e)
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
if startup_radio_task and not startup_radio_task.done():
|
||||
startup_radio_task.cancel()
|
||||
try:
|
||||
await startup_radio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await fanout_manager.stop_all()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_message_polling()
|
||||
|
||||
@@ -14,6 +14,7 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
@@ -45,9 +46,20 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
|
||||
if not isinstance(setup_in_progress, bool):
|
||||
setup_in_progress = False
|
||||
|
||||
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
|
||||
if not isinstance(setup_complete, bool):
|
||||
setup_complete = radio_connected
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
|
||||
@@ -107,7 +107,6 @@ export function App() {
|
||||
health,
|
||||
setHealth,
|
||||
config,
|
||||
setConfig,
|
||||
prevHealthRef,
|
||||
fetchConfig,
|
||||
handleSaveConfig,
|
||||
@@ -233,6 +232,12 @@ export function App() {
|
||||
const prev = prevHealthRef.current;
|
||||
prevHealthRef.current = data;
|
||||
setHealth(data);
|
||||
const initializationCompleted =
|
||||
prev !== null &&
|
||||
prev.radio_connected &&
|
||||
prev.radio_initializing &&
|
||||
data.radio_connected &&
|
||||
!data.radio_initializing;
|
||||
|
||||
// Show toast on connection status change
|
||||
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
||||
@@ -243,13 +248,17 @@ export function App() {
|
||||
: undefined,
|
||||
});
|
||||
// Refresh config after reconnection (may have changed after reboot)
|
||||
api.getRadioConfig().then(setConfig).catch(console.error);
|
||||
fetchConfig();
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (initializationCompleted) {
|
||||
fetchConfig();
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string; details?: string }) => {
|
||||
toast.error(error.message, {
|
||||
@@ -376,9 +385,9 @@ export function App() {
|
||||
incrementUnread,
|
||||
updateMessageAck,
|
||||
checkMention,
|
||||
fetchConfig,
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
setConfig,
|
||||
activeConversationRef,
|
||||
hasNewerMessagesRef,
|
||||
setActiveConversation,
|
||||
|
||||
@@ -22,6 +22,12 @@ export function StatusBar({
|
||||
onMenuClick,
|
||||
}: StatusBarProps) {
|
||||
const connected = health?.radio_connected ?? false;
|
||||
const initializing = health?.radio_initializing ?? false;
|
||||
const statusLabel = initializing
|
||||
? 'Radio Initializing'
|
||||
: connected
|
||||
? 'Radio OK'
|
||||
: 'Radio Disconnected';
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
@@ -67,23 +73,19 @@ export function StatusBar({
|
||||
RemoteTerm
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
role="status"
|
||||
aria-label={connected ? 'Connected' : 'Disconnected'}
|
||||
>
|
||||
<div className="flex items-center gap-1.5" role="status" aria-label={statusLabel}>
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
connected
|
||||
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
||||
: 'bg-status-disconnected'
|
||||
initializing
|
||||
? 'bg-warning'
|
||||
: connected
|
||||
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
||||
: 'bg-status-disconnected'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline text-muted-foreground">
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
@@ -106,7 +108,7 @@ export function StatusBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected && (
|
||||
{!connected && !initializing && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
|
||||
@@ -45,7 +45,9 @@ export function useRadioControl() {
|
||||
|
||||
const handleReboot = useCallback(async () => {
|
||||
await api.rebootRadio();
|
||||
setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev));
|
||||
setHealth((prev) =>
|
||||
prev ? { ...prev, radio_connected: false, radio_initializing: false } : prev
|
||||
);
|
||||
const pollToken = ++rebootPollTokenRef.current;
|
||||
const pollUntilReconnected = async () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
const healthData = {
|
||||
status: 'connected',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'Serial: /dev/ttyUSB0',
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
|
||||
@@ -27,6 +27,7 @@ const mockedApi = vi.mocked(api);
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'connected',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'Serial: /dev/ttyUSB0',
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
|
||||
@@ -35,6 +35,7 @@ const baseConfig: RadioConfig = {
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'connected',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'Serial: /dev/ttyUSB0',
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
|
||||
50
frontend/src/test/statusBar.test.tsx
Normal file
50
frontend/src/test/statusBar.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StatusBar } from '../components/StatusBar';
|
||||
import type { HealthStatus } from '../types';
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'degraded',
|
||||
radio_connected: false,
|
||||
radio_initializing: false,
|
||||
connection_info: null,
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
};
|
||||
|
||||
describe('StatusBar', () => {
|
||||
it('shows Radio Initializing while setup is still running', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
health={{ ...baseHealth, radio_connected: true, radio_initializing: true }}
|
||||
config={null}
|
||||
onSettingsClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Radio Initializing' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Radio OK when the radio is connected and ready', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
health={{ ...baseHealth, status: 'ok', radio_connected: true }}
|
||||
config={null}
|
||||
onSettingsClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Radio OK' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Radio Disconnected when the radio is unavailable', () => {
|
||||
render(<StatusBar health={baseHealth} config={null} onSettingsClick={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Radio Disconnected' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ describe('useWebSocket dispatch', () => {
|
||||
const healthData = {
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'TCP: 1.2.3.4:4000',
|
||||
database_size_mb: 1.5,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface FanoutStatusEntry {
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
radio_connected: boolean;
|
||||
radio_initializing: boolean;
|
||||
connection_info: string | null;
|
||||
database_size_mb: number;
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
|
||||
@@ -43,13 +43,19 @@ class TestHealthFanoutStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_status_ok_when_connected(self, test_db):
|
||||
"""Health status is 'ok' when radio is connected."""
|
||||
with patch(
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
),
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = True
|
||||
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
|
||||
|
||||
assert data["status"] == "ok"
|
||||
assert data["radio_connected"] is True
|
||||
assert data["radio_initializing"] is False
|
||||
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -62,4 +68,22 @@ class TestHealthFanoutStatus:
|
||||
|
||||
assert data["status"] == "degraded"
|
||||
assert data["radio_connected"] is False
|
||||
assert data["radio_initializing"] is False
|
||||
assert data["connection_info"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_status_degraded_while_radio_initializing(self, test_db):
|
||||
"""Health stays degraded while transport is up but post-connect setup is incomplete."""
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
),
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_setup_in_progress = True
|
||||
mock_rm.is_setup_complete = False
|
||||
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
|
||||
|
||||
assert data["status"] == "degraded"
|
||||
assert data["radio_connected"] is True
|
||||
assert data["radio_initializing"] is True
|
||||
|
||||
48
tests/test_main_startup.py
Normal file
48
tests/test_main_startup.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for app startup/lifespan behavior."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import app, lifespan
|
||||
|
||||
|
||||
class TestStartupLifespan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_does_not_wait_for_radio_setup(self):
|
||||
"""HTTP serving should start before post-connect setup finishes."""
|
||||
setup_started = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def slow_setup():
|
||||
setup_started.set()
|
||||
await release_setup.wait()
|
||||
|
||||
with (
|
||||
patch("app.main.db.connect", new=AsyncMock()),
|
||||
patch("app.main.db.disconnect", new=AsyncMock()),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio.radio_manager.start_connection_monitor", new=AsyncMock()),
|
||||
patch("app.radio.radio_manager.stop_connection_monitor", new=AsyncMock()),
|
||||
patch("app.radio.radio_manager.disconnect", new=AsyncMock()),
|
||||
patch("app.radio.radio_manager.reconnect", new=AsyncMock(return_value=True)),
|
||||
patch(
|
||||
"app.radio.radio_manager.post_connect_setup", new=AsyncMock(side_effect=slow_setup)
|
||||
),
|
||||
patch("app.fanout.manager.fanout_manager.load_from_db", new=AsyncMock()),
|
||||
patch("app.fanout.manager.fanout_manager.stop_all", new=AsyncMock()),
|
||||
patch("app.radio_sync.stop_message_polling", new=AsyncMock()),
|
||||
patch("app.radio_sync.stop_periodic_advert", new=AsyncMock()),
|
||||
patch("app.radio_sync.stop_periodic_sync", new=AsyncMock()),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
cm = lifespan(app)
|
||||
await asyncio.wait_for(cm.__aenter__(), timeout=0.2)
|
||||
|
||||
await asyncio.wait_for(setup_started.wait(), timeout=0.2)
|
||||
startup_task = app.state.startup_radio_task
|
||||
assert startup_task.done() is False
|
||||
|
||||
release_setup.set()
|
||||
await asyncio.wait_for(cm.__aexit__(None, None, None), timeout=0.5)
|
||||
Reference in New Issue
Block a user