mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 17:53:10 +02:00
Improve perf with reduced fetching, more chunking, and window-level prefetch
This commit is contained in:
+65
-39
@@ -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
|
||||
|
||||
+1
-4
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user