mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-05 21:13:08 +02:00
Improve perf with reduced fetching, more chunking, and window-level prefetch
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
frontend/src/components/settingsConstants.ts
Normal file
17
frontend/src/components/settingsConstants.ts
Normal 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',
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user