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

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