mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-18 07:15:49 +02:00
perf: Optimize channel chat loading time
- Add 60s cache for /api/contacts/detailed (reduces 4 USB calls to 0) - Optimize /api/messages/updates to read file once instead of N×2 times - Parallelize initialization: timestamps loaded together, messages and geo cache loaded in parallel - Defer checkForUpdates() to after messages are displayed - Remove blocking checkForUpdates() from loadChannels() - Add cache invalidation for contacts on add/delete operations - Add performance timing logs to browser console Expected improvement: ~10-20s → ~2-3s initial load time Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+96
-28
@@ -24,6 +24,12 @@ _channels_cache = None
|
||||
_channels_cache_timestamp = 0
|
||||
CHANNELS_CACHE_TTL = 30 # seconds
|
||||
|
||||
# Cache for contacts/detailed to reduce USB calls (4 calls per request!)
|
||||
# Contacts change infrequently, 60s cache is safe
|
||||
_contacts_detailed_cache = None
|
||||
_contacts_detailed_cache_timestamp = 0
|
||||
CONTACTS_DETAILED_CACHE_TTL = 60 # seconds
|
||||
|
||||
|
||||
def get_channels_cached(force_refresh=False):
|
||||
"""
|
||||
@@ -66,6 +72,48 @@ def invalidate_channels_cache():
|
||||
logger.debug("Channels cache invalidated")
|
||||
|
||||
|
||||
def get_contacts_detailed_cached(force_refresh=False):
|
||||
"""
|
||||
Get detailed contacts with caching to reduce USB/meshcli calls.
|
||||
This endpoint makes 4 USB calls (one per contact type), so caching is critical.
|
||||
|
||||
Args:
|
||||
force_refresh: If True, bypass cache and fetch fresh data
|
||||
|
||||
Returns:
|
||||
Tuple of (success, contacts_dict, error_message)
|
||||
"""
|
||||
global _contacts_detailed_cache, _contacts_detailed_cache_timestamp
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Return cached data if valid and not forcing refresh
|
||||
if (not force_refresh and
|
||||
_contacts_detailed_cache is not None and
|
||||
(current_time - _contacts_detailed_cache_timestamp) < CONTACTS_DETAILED_CACHE_TTL):
|
||||
logger.debug(f"Returning cached contacts (age: {current_time - _contacts_detailed_cache_timestamp:.1f}s)")
|
||||
return True, _contacts_detailed_cache, None
|
||||
|
||||
# Fetch fresh data (this makes 4 USB calls!)
|
||||
logger.debug("Fetching fresh contacts from meshcli (4 USB calls)")
|
||||
success, contacts, error = cli.get_contacts_with_last_seen()
|
||||
|
||||
if success:
|
||||
_contacts_detailed_cache = contacts
|
||||
_contacts_detailed_cache_timestamp = current_time
|
||||
logger.debug(f"Contacts cached ({len(contacts)} contacts)")
|
||||
|
||||
return success, contacts, error
|
||||
|
||||
|
||||
def invalidate_contacts_cache():
|
||||
"""Invalidate contacts cache (call after contact changes)"""
|
||||
global _contacts_detailed_cache, _contacts_detailed_cache_timestamp
|
||||
_contacts_detailed_cache = None
|
||||
_contacts_detailed_cache_timestamp = 0
|
||||
logger.debug("Contacts cache invalidated")
|
||||
|
||||
|
||||
@api_bp.route('/messages', methods=['GET'])
|
||||
def get_messages():
|
||||
"""
|
||||
@@ -518,6 +566,10 @@ def cleanup_contacts():
|
||||
'error': message
|
||||
})
|
||||
|
||||
# Invalidate contacts cache after deletions
|
||||
if deleted_count > 0:
|
||||
invalidate_contacts_cache()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Cleanup completed: {deleted_count} deleted, {failed_count} failed',
|
||||
@@ -1104,6 +1156,9 @@ def get_messages_updates():
|
||||
Check for new messages across all channels without fetching full message content.
|
||||
Used for intelligent refresh mechanism and unread notifications.
|
||||
|
||||
OPTIMIZED: Reads messages file only ONCE and computes stats for all channels.
|
||||
Previously read the file N*2 times (once per channel, twice if updates).
|
||||
|
||||
Query parameters:
|
||||
last_seen (str): JSON object with last seen timestamps per channel
|
||||
Format: {"0": 1234567890, "1": 1234567891, ...}
|
||||
@@ -1143,44 +1198,52 @@ def get_messages_updates():
|
||||
'error': 'Failed to get channels'
|
||||
}), 500
|
||||
|
||||
# OPTIMIZATION: Read ALL messages ONCE (no channel filter)
|
||||
# Then compute per-channel statistics in memory
|
||||
all_messages = parser.read_messages(
|
||||
limit=None, # Get all messages
|
||||
days=7 # Only last 7 days
|
||||
)
|
||||
|
||||
# Group messages by channel and compute stats
|
||||
channel_stats = {} # channel_idx -> {latest_ts, messages_after_last_seen}
|
||||
for msg in all_messages:
|
||||
ch_idx = msg.get('channel_idx', 0)
|
||||
ts = msg.get('timestamp', 0)
|
||||
|
||||
if ch_idx not in channel_stats:
|
||||
channel_stats[ch_idx] = {
|
||||
'latest_timestamp': 0,
|
||||
'unread_count': 0
|
||||
}
|
||||
|
||||
# Track latest timestamp per channel
|
||||
if ts > channel_stats[ch_idx]['latest_timestamp']:
|
||||
channel_stats[ch_idx]['latest_timestamp'] = ts
|
||||
|
||||
# Count unread messages (newer than last_seen)
|
||||
last_seen_ts = last_seen.get(ch_idx, 0)
|
||||
if ts > last_seen_ts:
|
||||
channel_stats[ch_idx]['unread_count'] += 1
|
||||
|
||||
# Build response
|
||||
updates = []
|
||||
total_unread = 0
|
||||
|
||||
# Check each channel for new messages
|
||||
for channel in channels:
|
||||
channel_idx = channel['index']
|
||||
stats = channel_stats.get(channel_idx, {'latest_timestamp': 0, 'unread_count': 0})
|
||||
|
||||
# Get latest message for this channel
|
||||
messages = parser.read_messages(
|
||||
limit=1,
|
||||
channel_idx=channel_idx,
|
||||
days=7 # Only check recent messages
|
||||
)
|
||||
|
||||
latest_timestamp = 0
|
||||
if messages and len(messages) > 0:
|
||||
latest_timestamp = messages[0]['timestamp']
|
||||
|
||||
# Check if there are updates
|
||||
last_seen_ts = last_seen.get(channel_idx, 0)
|
||||
has_updates = latest_timestamp > last_seen_ts
|
||||
|
||||
# Count unread messages (messages newer than last_seen)
|
||||
unread_count = 0
|
||||
if has_updates:
|
||||
all_messages = parser.read_messages(
|
||||
limit=500,
|
||||
channel_idx=channel_idx,
|
||||
days=7
|
||||
)
|
||||
unread_count = sum(1 for msg in all_messages if msg['timestamp'] > last_seen_ts)
|
||||
total_unread += unread_count
|
||||
has_updates = stats['latest_timestamp'] > last_seen_ts
|
||||
unread_count = stats['unread_count'] if has_updates else 0
|
||||
total_unread += unread_count
|
||||
|
||||
updates.append({
|
||||
'index': channel_idx,
|
||||
'name': channel['name'],
|
||||
'has_updates': has_updates,
|
||||
'latest_timestamp': latest_timestamp,
|
||||
'latest_timestamp': stats['latest_timestamp'],
|
||||
'unread_count': unread_count
|
||||
})
|
||||
|
||||
@@ -1505,8 +1568,9 @@ def get_contacts_detailed_api():
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get detailed contact info from meshcli (includes all fields)
|
||||
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
|
||||
# Get detailed contact info from cache (reduces 4 USB calls to 0 on cache hit)
|
||||
force_refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||
success_detailed, contacts_detailed, error_detailed = get_contacts_detailed_cached(force_refresh)
|
||||
|
||||
if not success_detailed:
|
||||
return jsonify({
|
||||
@@ -1609,6 +1673,8 @@ def delete_contact_api():
|
||||
success, message = cli.delete_contact(selector)
|
||||
|
||||
if success:
|
||||
# Invalidate contacts cache after deletion
|
||||
invalidate_contacts_cache()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message
|
||||
@@ -1748,6 +1814,8 @@ def approve_pending_contact_api():
|
||||
success, message = cli.approve_pending_contact(public_key)
|
||||
|
||||
if success:
|
||||
# Invalidate contacts cache after adding new contact
|
||||
invalidate_contacts_cache()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message or 'Contact approved successfully'
|
||||
|
||||
+40
-19
@@ -245,6 +245,7 @@ async function loadContactsGeoCache() {
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
console.log('mc-webui initialized');
|
||||
const initStart = performance.now();
|
||||
|
||||
// Force viewport recalculation on PWA navigation
|
||||
// This fixes the bottom bar visibility issue when navigating from other pages
|
||||
@@ -254,34 +255,45 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Force reflow to ensure proper layout calculation
|
||||
document.body.offsetHeight;
|
||||
|
||||
// Load last seen timestamps from server
|
||||
await loadLastSeenTimestampsFromServer();
|
||||
await loadDmLastSeenTimestampsFromServer();
|
||||
|
||||
// Restore last selected channel from localStorage
|
||||
// Restore last selected channel from localStorage (sync, fast)
|
||||
const savedChannel = localStorage.getItem('mc_active_channel');
|
||||
if (savedChannel !== null) {
|
||||
currentChannelIdx = parseInt(savedChannel);
|
||||
}
|
||||
|
||||
// Setup event listeners (do this early)
|
||||
// Setup event listeners and emoji picker early (sync, fast)
|
||||
setupEventListeners();
|
||||
|
||||
// Setup emoji picker
|
||||
setupEmojiPicker();
|
||||
|
||||
// CRITICAL: Load channels FIRST before anything else
|
||||
// This ensures channels are available for checkForUpdates()
|
||||
// OPTIMIZATION: Load timestamps in parallel (both are independent API calls)
|
||||
console.log('[init] Loading timestamps in parallel...');
|
||||
await Promise.all([
|
||||
loadLastSeenTimestampsFromServer(),
|
||||
loadDmLastSeenTimestampsFromServer()
|
||||
]);
|
||||
|
||||
// Load channels (required before loading messages)
|
||||
// NOTE: checkForUpdates() was removed from loadChannels() to speed up init
|
||||
console.log('[init] Loading channels...');
|
||||
await loadChannels();
|
||||
|
||||
// Load contacts geo cache BEFORE messages (needed for Map buttons on bubbles)
|
||||
await loadContactsGeoCache();
|
||||
// OPTIMIZATION: Load messages immediately, don't wait for geo cache
|
||||
// Map buttons will appear once geo cache loads (non-blocking UX improvement)
|
||||
console.log('[init] Loading messages (priority) and geo cache (background)...');
|
||||
|
||||
// Now load other data (can run in parallel)
|
||||
// Start these in parallel - messages are critical, geo cache can load async
|
||||
const messagesPromise = loadMessages();
|
||||
const geoCachePromise = loadContactsGeoCache(); // Non-blocking, Map buttons update when ready
|
||||
|
||||
// Also start archive list loading in parallel
|
||||
loadArchiveList();
|
||||
loadMessages();
|
||||
|
||||
// Initial badge updates
|
||||
// Wait for messages to display (this is what the user wants to see ASAP)
|
||||
await messagesPromise;
|
||||
|
||||
console.log(`[init] Messages loaded in ${(performance.now() - initStart).toFixed(0)}ms`);
|
||||
|
||||
// Initial badge updates (fast, sync-ish)
|
||||
updatePendingContactsBadge();
|
||||
loadStatus();
|
||||
|
||||
@@ -299,8 +311,18 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Update notification toggle UI
|
||||
updateNotificationToggleUI();
|
||||
|
||||
// Setup auto-refresh AFTER channels are loaded
|
||||
// DEFERRED: Check for updates AFTER messages are displayed
|
||||
// This updates the unread badges without blocking initial load
|
||||
console.log('[init] Checking for updates (deferred)...');
|
||||
checkForUpdates(); // No await - runs in background
|
||||
|
||||
// Wait for geo cache to complete before setting up auto-refresh
|
||||
await geoCachePromise;
|
||||
|
||||
// Setup auto-refresh AFTER initial load is complete
|
||||
setupAutoRefresh();
|
||||
|
||||
console.log(`[init] Full initialization complete in ${(performance.now() - initStart).toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
// Handle page restoration from cache (PWA back/forward navigation)
|
||||
@@ -1555,9 +1577,8 @@ async function loadChannels() {
|
||||
availableChannels = data.channels;
|
||||
console.log('[loadChannels] Channels loaded:', availableChannels.length);
|
||||
populateChannelSelector(data.channels);
|
||||
|
||||
// Check for unread messages after channels are loaded
|
||||
await checkForUpdates();
|
||||
// NOTE: checkForUpdates() is now called separately after messages are displayed
|
||||
// to avoid blocking the initial page load
|
||||
} else {
|
||||
console.error('[loadChannels] Error loading channels:', data.error || 'No channels returned');
|
||||
// Fallback: ensure at least Public channel exists
|
||||
|
||||
Reference in New Issue
Block a user