Update status bar and boot up more quickly with actual radio status

This commit is contained in:
Jack Kingsman
2026-03-07 23:40:18 -08:00
parent f9eb6ebd98
commit 99eddfc2ef
13 changed files with 197 additions and 26 deletions

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}

View File

@@ -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++) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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();
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View 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)