mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Update status bar and boot up more quickly with actual radio status
This commit is contained in:
+26
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -34,6 +35,22 @@ setup_logging()
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Manage database and radio connection lifecycle."""
|
"""Manage database and radio connection lifecycle."""
|
||||||
@@ -47,13 +64,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
await ensure_default_channels()
|
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)
|
# Always start connection monitor (even if initial connection failed)
|
||||||
await radio_manager.start_connection_monitor()
|
await radio_manager.start_connection_monitor()
|
||||||
|
|
||||||
@@ -65,9 +75,18 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to start fanout modules: %s", 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
|
yield
|
||||||
|
|
||||||
logger.info("Shutting down")
|
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 fanout_manager.stop_all()
|
||||||
await radio_manager.stop_connection_monitor()
|
await radio_manager.stop_connection_monitor()
|
||||||
await stop_message_polling()
|
await stop_message_polling()
|
||||||
|
|||||||
+13
-1
@@ -14,6 +14,7 @@ router = APIRouter(tags=["health"])
|
|||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
radio_connected: bool
|
radio_connected: bool
|
||||||
|
radio_initializing: bool = False
|
||||||
connection_info: str | None
|
connection_info: str | None
|
||||||
database_size_mb: float
|
database_size_mb: float
|
||||||
oldest_undecrypted_timestamp: int | None
|
oldest_undecrypted_timestamp: int | None
|
||||||
@@ -45,9 +46,20 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 {
|
return {
|
||||||
"status": "ok" if radio_connected else "degraded",
|
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||||
"radio_connected": radio_connected,
|
"radio_connected": radio_connected,
|
||||||
|
"radio_initializing": radio_initializing,
|
||||||
"connection_info": connection_info,
|
"connection_info": connection_info,
|
||||||
"database_size_mb": db_size_mb,
|
"database_size_mb": db_size_mb,
|
||||||
"oldest_undecrypted_timestamp": oldest_ts,
|
"oldest_undecrypted_timestamp": oldest_ts,
|
||||||
|
|||||||
+12
-3
@@ -107,7 +107,6 @@ export function App() {
|
|||||||
health,
|
health,
|
||||||
setHealth,
|
setHealth,
|
||||||
config,
|
config,
|
||||||
setConfig,
|
|
||||||
prevHealthRef,
|
prevHealthRef,
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
handleSaveConfig,
|
handleSaveConfig,
|
||||||
@@ -233,6 +232,12 @@ export function App() {
|
|||||||
const prev = prevHealthRef.current;
|
const prev = prevHealthRef.current;
|
||||||
prevHealthRef.current = data;
|
prevHealthRef.current = data;
|
||||||
setHealth(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
|
// Show toast on connection status change
|
||||||
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
||||||
@@ -243,13 +248,17 @@ export function App() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
// Refresh config after reconnection (may have changed after reboot)
|
// Refresh config after reconnection (may have changed after reboot)
|
||||||
api.getRadioConfig().then(setConfig).catch(console.error);
|
fetchConfig();
|
||||||
} else {
|
} else {
|
||||||
toast.error('Radio disconnected', {
|
toast.error('Radio disconnected', {
|
||||||
description: 'Check radio connection and power',
|
description: 'Check radio connection and power',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initializationCompleted) {
|
||||||
|
fetchConfig();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: { message: string; details?: string }) => {
|
onError: (error: { message: string; details?: string }) => {
|
||||||
toast.error(error.message, {
|
toast.error(error.message, {
|
||||||
@@ -376,9 +385,9 @@ export function App() {
|
|||||||
incrementUnread,
|
incrementUnread,
|
||||||
updateMessageAck,
|
updateMessageAck,
|
||||||
checkMention,
|
checkMention,
|
||||||
|
fetchConfig,
|
||||||
prevHealthRef,
|
prevHealthRef,
|
||||||
setHealth,
|
setHealth,
|
||||||
setConfig,
|
|
||||||
activeConversationRef,
|
activeConversationRef,
|
||||||
hasNewerMessagesRef,
|
hasNewerMessagesRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export function StatusBar({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: StatusBarProps) {
|
}: StatusBarProps) {
|
||||||
const connected = health?.radio_connected ?? false;
|
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 [reconnecting, setReconnecting] = useState(false);
|
||||||
|
|
||||||
const handleReconnect = async () => {
|
const handleReconnect = async () => {
|
||||||
@@ -67,23 +73,19 @@ export function StatusBar({
|
|||||||
RemoteTerm
|
RemoteTerm
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div
|
<div className="flex items-center gap-1.5" role="status" aria-label={statusLabel}>
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
role="status"
|
|
||||||
aria-label={connected ? 'Connected' : 'Disconnected'}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2 h-2 rounded-full transition-colors',
|
'w-2 h-2 rounded-full transition-colors',
|
||||||
connected
|
initializing
|
||||||
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
? 'bg-warning'
|
||||||
: 'bg-status-disconnected'
|
: connected
|
||||||
|
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
||||||
|
: 'bg-status-disconnected'
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="hidden lg:inline text-muted-foreground">
|
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>
|
||||||
{connected ? 'Connected' : 'Disconnected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config && (
|
{config && (
|
||||||
@@ -106,7 +108,7 @@ export function StatusBar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!connected && (
|
{!connected && !initializing && (
|
||||||
<button
|
<button
|
||||||
onClick={handleReconnect}
|
onClick={handleReconnect}
|
||||||
disabled={reconnecting}
|
disabled={reconnecting}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export function useRadioControl() {
|
|||||||
|
|
||||||
const handleReboot = useCallback(async () => {
|
const handleReboot = useCallback(async () => {
|
||||||
await api.rebootRadio();
|
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 pollToken = ++rebootPollTokenRef.current;
|
||||||
const pollUntilReconnected = async () => {
|
const pollUntilReconnected = async () => {
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
const healthData = {
|
const healthData = {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
radio_connected: true,
|
radio_connected: true,
|
||||||
|
radio_initializing: false,
|
||||||
connection_info: 'Serial: /dev/ttyUSB0',
|
connection_info: 'Serial: /dev/ttyUSB0',
|
||||||
database_size_mb: 1.2,
|
database_size_mb: 1.2,
|
||||||
oldest_undecrypted_timestamp: null,
|
oldest_undecrypted_timestamp: null,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const mockedApi = vi.mocked(api);
|
|||||||
const baseHealth: HealthStatus = {
|
const baseHealth: HealthStatus = {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
radio_connected: true,
|
radio_connected: true,
|
||||||
|
radio_initializing: false,
|
||||||
connection_info: 'Serial: /dev/ttyUSB0',
|
connection_info: 'Serial: /dev/ttyUSB0',
|
||||||
database_size_mb: 1.2,
|
database_size_mb: 1.2,
|
||||||
oldest_undecrypted_timestamp: null,
|
oldest_undecrypted_timestamp: null,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const baseConfig: RadioConfig = {
|
|||||||
const baseHealth: HealthStatus = {
|
const baseHealth: HealthStatus = {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
radio_connected: true,
|
radio_connected: true,
|
||||||
|
radio_initializing: false,
|
||||||
connection_info: 'Serial: /dev/ttyUSB0',
|
connection_info: 'Serial: /dev/ttyUSB0',
|
||||||
database_size_mb: 1.2,
|
database_size_mb: 1.2,
|
||||||
oldest_undecrypted_timestamp: null,
|
oldest_undecrypted_timestamp: null,
|
||||||
|
|||||||
@@ -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 = {
|
const healthData = {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
radio_connected: true,
|
radio_connected: true,
|
||||||
|
radio_initializing: false,
|
||||||
connection_info: 'TCP: 1.2.3.4:4000',
|
connection_info: 'TCP: 1.2.3.4:4000',
|
||||||
database_size_mb: 1.5,
|
database_size_mb: 1.5,
|
||||||
oldest_undecrypted_timestamp: null,
|
oldest_undecrypted_timestamp: null,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface FanoutStatusEntry {
|
|||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
status: string;
|
status: string;
|
||||||
radio_connected: boolean;
|
radio_connected: boolean;
|
||||||
|
radio_initializing: boolean;
|
||||||
connection_info: string | null;
|
connection_info: string | null;
|
||||||
database_size_mb: number;
|
database_size_mb: number;
|
||||||
oldest_undecrypted_timestamp: number | null;
|
oldest_undecrypted_timestamp: number | null;
|
||||||
|
|||||||
@@ -43,13 +43,19 @@ class TestHealthFanoutStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_status_ok_when_connected(self, test_db):
|
async def test_health_status_ok_when_connected(self, test_db):
|
||||||
"""Health status is 'ok' when radio is connected."""
|
"""Health status is 'ok' when radio is connected."""
|
||||||
with patch(
|
with (
|
||||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
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")
|
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
|
||||||
|
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
assert data["radio_connected"] is True
|
assert data["radio_connected"] is True
|
||||||
|
assert data["radio_initializing"] is False
|
||||||
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -62,4 +68,22 @@ class TestHealthFanoutStatus:
|
|||||||
|
|
||||||
assert data["status"] == "degraded"
|
assert data["status"] == "degraded"
|
||||||
assert data["radio_connected"] is False
|
assert data["radio_connected"] is False
|
||||||
|
assert data["radio_initializing"] is False
|
||||||
assert data["connection_info"] is None
|
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
|
||||||
|
|||||||
@@ -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