Improve perf with reduced fetching, more chunking, and window-level prefetch

This commit is contained in:
Jack Kingsman
2026-02-13 00:43:07 -08:00
parent b14ad71eca
commit 908a479fa6
9 changed files with 199 additions and 91 deletions

View File

@@ -3,9 +3,10 @@
import logging
import time
from fastapi import APIRouter, Query
from fastapi import APIRouter
from app.models import UnreadCounts
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository, MessageRepository
logger = logging.getLogger(__name__)
@@ -13,14 +14,18 @@ router = APIRouter(prefix="/read-state", tags=["read-state"])
@router.get("/unreads", response_model=UnreadCounts)
async def get_unreads(
name: str | None = Query(default=None, description="User's name for @mention detection"),
) -> UnreadCounts:
async def get_unreads() -> UnreadCounts:
"""Get unread counts, mention flags, and last message times for all conversations.
Computes unread counts server-side using last_read_at timestamps on
channels and contacts, avoiding the need to fetch bulk messages.
The radio's own name is sourced directly from the connected radio
for @mention detection.
"""
name: str | None = None
mc = radio_manager.meshcore
if mc and mc.self_info:
name = mc.self_info.get("name") or None
data = await MessageRepository.get_unread_counts(name)
return UnreadCounts(**data)

View File

@@ -17,6 +17,13 @@
</head>
<body>
<div id="root"></div>
<script>
// Start critical data fetches before React loads — shaves ~1-2s off startup.
// React hooks consume the promises via window.__prefetch.
window.__prefetch = {
unreads: fetch('/api/read-state/unreads').then(function(r) { return r.json(); }),
};
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -23,19 +23,23 @@ import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import {
SettingsModal,
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
} from './components/SettingsModal';
} from './components/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { CrackerPanel } from './components/CrackerPanel';
// Lazy-load heavy components (Leaflet, force-directed graph) to reduce initial bundle
// Lazy-load heavy components to reduce initial bundle
const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
const VisualizerView = lazy(() =>
import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView }))
);
const SettingsModal = lazy(() =>
import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal }))
);
const CrackerPanel = lazy(() =>
import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
);
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import {
@@ -103,6 +107,10 @@ export function App() {
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
if (showCracker) crackerMounted.current = true;
// Favorites are now stored server-side in appSettings.
// Stable empty array prevents a new reference every render when there are none.
const emptyFavorites = useRef<Favorite[]>([]).current;
@@ -150,7 +158,7 @@ export function App() {
incrementUnread,
markAllRead,
trackNewMessage,
} = useUnreadCounts(channels, contacts, activeConversation, config?.name);
} = useUnreadCounts(channels, contacts, activeConversation);
const {
repeaterLoggedIn,
@@ -1217,52 +1225,70 @@ export function App() {
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<SettingsModal
open={showSettings}
pageMode
externalSidebarNav
desktopSection={settingsSection}
config={config}
health={health}
appSettings={appSettings}
onClose={handleCloseSettingsView}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
onAdvertise={handleAdvertise}
onHealthRefresh={handleHealthRefresh}
onRefreshAppSettings={fetchAppSettings}
/>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground">
Loading settings...
</div>
}
>
<SettingsModal
open={showSettings}
pageMode
externalSidebarNav
desktopSection={settingsSection}
config={config}
health={health}
appSettings={appSettings}
onClose={handleCloseSettingsView}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
onAdvertise={handleAdvertise}
onHealthRefresh={handleHealthRefresh}
onRefreshAppSettings={fetchAppSettings}
/>
</Suspense>
</div>
</div>
)}
</main>
</div>
{/* Global Cracker Panel - always rendered to maintain state */}
{/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
<div
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
)}
>
<CrackerPanel
packets={rawPackets}
channels={channels}
visible={showCracker}
onChannelCreate={async (name, key) => {
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}}
onRunningChange={setCrackerRunning}
/>
{crackerMounted.current && (
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading cracker...
</div>
}
>
<CrackerPanel
packets={rawPackets}
channels={channels}
visible={showCracker}
onChannelCreate={async (name, key) => {
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}}
onRunningChange={setCrackerRunning}
/>
</Suspense>
)}
</div>
<NewMessageModal

View File

@@ -185,10 +185,7 @@ export const api = {
}),
// Read State
getUnreads: (name?: string) => {
const params = name ? `?name=${encodeURIComponent(name)}` : '';
return fetchJson<UnreadCounts>(`/read-state/unreads${params}`);
},
getUnreads: () => fetchJson<UnreadCounts>('/read-state/unreads'),
markAllRead: () =>
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
method: 'POST',

View File

@@ -1598,12 +1598,14 @@ export function PacketVisualizer({
</label>
<div className="flex items-center gap-2">
<label
htmlFor="observation-window"
className="text-muted-foreground"
title="How long to wait for duplicate packets via different paths before animating"
>
Observation window:
</label>
<input
id="observation-window"
type="number"
min="1"
max="60"

View File

@@ -46,23 +46,13 @@ const RADIO_PRESETS: RadioPreset[] = [
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 },
];
export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'identity',
'connectivity',
'database',
'bot',
];
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database',
bot: '🤖 Bot',
};
// Import for local use + re-export so existing imports from this file still work
import {
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_LABELS,
type SettingsSection,
} from './settingsConstants';
export { SETTINGS_SECTION_ORDER, SETTINGS_SECTION_LABELS, type SettingsSection };
interface SettingsModalBaseProps {
open: boolean;

View File

@@ -0,0 +1,17 @@
export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'identity',
'connectivity',
'database',
'bot',
];
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database',
bot: '🤖 Bot',
};

View File

@@ -6,7 +6,13 @@ import {
getStateKey,
type ConversationTimes,
} from '../utils/conversationState';
import type { Channel, Contact, Conversation, Message } from '../types';
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
// Consume the prefetched unreads promise started in index.html (if available).
// This lets the fetch run while React JS is still downloading/parsing.
const prefetchedUnreads: Promise<UnreadCounts> | undefined = (
window as unknown as { __prefetch?: { unreads?: Promise<UnreadCounts> } }
).__prefetch?.unreads;
export interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
@@ -21,47 +27,53 @@ export interface UseUnreadCountsResult {
export function useUnreadCounts(
channels: Channel[],
contacts: Contact[],
activeConversation: Conversation | null,
myName: string | null = null
activeConversation: Conversation | null
): UseUnreadCountsResult {
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Keep myName in a ref so callbacks always have current value
const myNameRef = useRef(myName);
useEffect(() => {
myNameRef.current = myName;
}, [myName]);
// Apply unreads data to state
const applyUnreads = useCallback((data: UnreadCounts) => {
setUnreadCounts(data.counts);
setMentions(data.mentions);
if (Object.keys(data.last_message_times).length > 0) {
for (const [key, ts] of Object.entries(data.last_message_times)) {
setLastMessageTime(key, ts);
}
setLastMessageTimes(getLastMessageTimes());
}
}, []);
// Fetch unreads from the server-side endpoint
const fetchUnreads = useCallback(async () => {
try {
const data = await api.getUnreads(myNameRef.current ?? undefined);
// Replace (not merge) — server counts are authoritative
setUnreadCounts(data.counts);
setMentions(data.mentions);
if (Object.keys(data.last_message_times).length > 0) {
// Update in-memory cache and state
for (const [key, ts] of Object.entries(data.last_message_times)) {
setLastMessageTime(key, ts);
}
setLastMessageTimes(getLastMessageTimes());
}
applyUnreads(await api.getUnreads());
} catch (err) {
console.error('Failed to fetch unreads:', err);
}
}, []);
}, [applyUnreads]);
// Fetch when the number of channels/contacts changes (e.g. initial load,
// sync, create/delete). Using .length avoids refiring on every WebSocket
// contact-update that merely mutates an existing entry's fields.
// On mount, consume the prefetched promise (started in index.html before
// React loaded) or fall back to a fresh fetch.
// Re-fetch when channel/contact count changes mid-session (new sync, cracker
// channel created, etc.) but skip the initial 0→N load to avoid double calls.
const channelsLen = channels.length;
const contactsLen = contacts.length;
const prevLens = useRef({ channels: 0, contacts: 0 });
useEffect(() => {
if (channelsLen === 0 && contactsLen === 0) return;
if (prefetchedUnreads) {
prefetchedUnreads.then(applyUnreads).catch(() => fetchUnreads());
} else {
fetchUnreads();
}
}, [fetchUnreads, applyUnreads]);
useEffect(() => {
const prev = prevLens.current;
prevLens.current = { channels: channelsLen, contacts: contactsLen };
// Skip the initial load (0→N); only refetch on mid-session count changes
if (prev.channels === 0 && prev.contacts === 0) return;
fetchUnreads();
}, [channelsLen, contactsLen, fetchUnreads]);

View File

@@ -500,7 +500,7 @@ class TestReadStateEndpoints:
@pytest.mark.asyncio
async def test_get_unreads_no_name_skips_mentions(self, test_db):
"""GET /unreads without name param returns counts but no mention flags."""
"""Unreads without a radio name returns counts but no mention flags."""
chan_key = "CHAN1KEY1CHAN1KEY1CHAN1KEY1CHAN1KEY1"
await ChannelRepository.upsert(key=chan_key, name="Public")
await ChannelRepository.update_last_read_at(chan_key, 0)
@@ -518,6 +518,58 @@ class TestReadStateEndpoints:
assert result["counts"][f"channel-{chan_key}"] == 1
assert len(result["mentions"]) == 0
@pytest.mark.asyncio
async def test_unreads_endpoint_sources_name_from_radio(self, test_db, client):
"""GET /unreads sources the user's name from the radio for mention detection."""
chan_key = "MENTIONENDPOINT1MENTIONENDPOINT1"
await ChannelRepository.upsert(key=chan_key, name="Public")
await ChannelRepository.update_last_read_at(chan_key, 0)
await MessageRepository.create(
msg_type="CHAN",
text="hey @[RadioUser] check this",
received_at=1001,
conversation_key=chan_key,
sender_timestamp=1001,
)
# Mock radio_manager.meshcore to return a name
mock_mc = MagicMock()
mock_mc.self_info = {"name": "RadioUser"}
with patch("app.routers.read_state.radio_manager") as mock_rm:
mock_rm.meshcore = mock_mc
response = await client.get("/api/read-state/unreads")
assert response.status_code == 200
data = response.json()
assert data["counts"][f"channel-{chan_key}"] == 1
assert data["mentions"][f"channel-{chan_key}"] is True
@pytest.mark.asyncio
async def test_unreads_endpoint_no_radio_skips_mentions(self, test_db, client):
"""GET /unreads with no radio connected still returns counts without mentions."""
chan_key = "NORADIOENDPOINT1NORADIOENDPOINT1"
await ChannelRepository.upsert(key=chan_key, name="Public")
await ChannelRepository.update_last_read_at(chan_key, 0)
await MessageRepository.create(
msg_type="CHAN",
text="hey @[Someone] check this",
received_at=1001,
conversation_key=chan_key,
sender_timestamp=1001,
)
# Mock radio_manager.meshcore as None (disconnected)
with patch("app.routers.read_state.radio_manager") as mock_rm:
mock_rm.meshcore = None
response = await client.get("/api/read-state/unreads")
assert response.status_code == 200
data = response.json()
assert data["counts"][f"channel-{chan_key}"] == 1
assert len(data["mentions"]) == 0
@pytest.mark.asyncio
async def test_unreads_reset_after_mark_read(self, test_db):
"""Marking a conversation as read zeroes its unread count; new messages after count again."""