diff --git a/docs/upgrading.md b/docs/upgrading.md index 6a260d3..2023780 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -22,7 +22,11 @@ While on SQLite, all workers share the same database file on the same host (WAL ### Read-Path Query Optimisations -Several read-heavy endpoints had their query patterns optimised (node `is_observer` filtering, dashboard node-count history, and message/dashboard sender-name resolution). These are internal performance improvements with no API or configuration changes — responses are unchanged. The `is_observer` change ships an Alembic migration that is applied automatically on startup (Docker) or via `meshcore-hub db upgrade`. +Several read-heavy endpoints had their query patterns optimised (node `is_observer` filtering, dashboard node-count history, message/dashboard sender-name resolution, and consolidation of the `dashboard/stats` count queries into conditional aggregates). These are internal performance improvements with no API or configuration changes — responses are unchanged. The `is_observer` change ships an Alembic migration that is applied automatically on startup (Docker) or via `meshcore-hub db upgrade`. + +### Dashboard Navigation Responsiveness + +The web dashboard now cancels in-flight API requests when you navigate between pages. Previously, rapidly switching pages could leave slow requests (such as the homepage statistics) running in the background, holding connections and delaying the page you actually opened. This is a front-end behaviour fix only — no configuration or action is required. ### Optional Redis API Cache diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 373d816..4aea29d 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Request -from sqlalchemy import func, or_, select +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.sql.elements import ColumnElement from meshcore_hub.api.auth import RequireRead @@ -92,78 +92,45 @@ def get_stats( model.channel_idx.in_(visible_indices), ) - # Total nodes - total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0 + # Node counts (total + active in the last 24h) in a single pass. + node_row = session.execute( + select( + func.count(), + func.sum(case((Node.last_seen >= yesterday, 1), else_=0)), + ).select_from(Node) + ).one() + total_nodes = node_row[0] or 0 + active_nodes = node_row[1] or 0 - # Active nodes (last 24h) - active_nodes = ( - session.execute( - select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) - ).scalar() - or 0 - ) + # Message counts (total + today + last 7 days), channel-visible only, + # via conditional aggregation so it's one query instead of three. + msg_row = session.execute( + select( + func.count(), + func.sum(case((Message.received_at >= today_start, 1), else_=0)), + func.sum(case((Message.received_at >= seven_days_ago, 1), else_=0)), + ) + .select_from(Message) + .where(_channel_visible_filter()) + ).one() + total_messages = msg_row[0] or 0 + messages_today = msg_row[1] or 0 + messages_7d = msg_row[2] or 0 - # Total messages - total_messages = ( - session.execute( - select(func.count()).select_from(Message).where(_channel_visible_filter()) - ).scalar() - or 0 - ) - - # Messages today - messages_today = ( - session.execute( - select(func.count()) - .select_from(Message) - .where(Message.received_at >= today_start) - .where(_channel_visible_filter()) - ).scalar() - or 0 - ) - - # Total advertisements (flood-only) - total_advertisements = ( - session.execute( - select(func.count()) - .select_from(Advertisement) - .where(_flood_only_filter(Advertisement)) - ).scalar() - or 0 - ) - - # Advertisements in last 24h (flood-only) - advertisements_24h = ( - session.execute( - select(func.count()) - .select_from(Advertisement) - .where(Advertisement.received_at >= yesterday) - .where(_flood_only_filter(Advertisement)) - ).scalar() - or 0 - ) - - # Advertisements in last 7 days (flood-only) - advertisements_7d = ( - session.execute( - select(func.count()) - .select_from(Advertisement) - .where(Advertisement.received_at >= seven_days_ago) - .where(_flood_only_filter(Advertisement)) - ).scalar() - or 0 - ) - - # Messages in last 7 days - messages_7d = ( - session.execute( - select(func.count()) - .select_from(Message) - .where(Message.received_at >= seven_days_ago) - .where(_channel_visible_filter()) - ).scalar() - or 0 - ) + # Advertisement counts (total + last 24h + last 7 days), flood-only, + # again folded into one query. + adv_row = session.execute( + select( + func.count(), + func.sum(case((Advertisement.received_at >= yesterday, 1), else_=0)), + func.sum(case((Advertisement.received_at >= seven_days_ago, 1), else_=0)), + ) + .select_from(Advertisement) + .where(_flood_only_filter(Advertisement)) + ).one() + total_advertisements = adv_row[0] or 0 + advertisements_24h = adv_row[1] or 0 + advertisements_7d = adv_row[2] or 0 # Recent advertisements (last 10, flood-only) recent_ads = ( @@ -265,27 +232,23 @@ def get_stats( member_role = web_settings.oidc_role_member test_role = web_settings.oidc_role_test - total_operators_query = ( - select(func.count()) - .select_from(UserProfile) - .where(UserProfile.roles.contains(operator_role)) - ) + # Operator + member counts in one query. The optional test-role exclusion + # is folded into each conditional branch. + operator_cond = UserProfile.roles.contains(operator_role) + member_cond = UserProfile.roles.contains(member_role) if test_role: - total_operators_query = total_operators_query.where( - ~UserProfile.roles.contains(test_role) - ) - total_operators = session.execute(total_operators_query).scalar() or 0 + not_test = ~UserProfile.roles.contains(test_role) + operator_cond = and_(operator_cond, not_test) + member_cond = and_(member_cond, not_test) - total_members_query = ( - select(func.count()) - .select_from(UserProfile) - .where(UserProfile.roles.contains(member_role)) - ) - if test_role: - total_members_query = total_members_query.where( - ~UserProfile.roles.contains(test_role) - ) - total_members = session.execute(total_members_query).scalar() or 0 + profile_row = session.execute( + select( + func.sum(case((operator_cond, 1), else_=0)), + func.sum(case((member_cond, 1), else_=0)), + ).select_from(UserProfile) + ).one() + total_operators = profile_row[0] or 0 + total_members = profile_row[1] or 0 return DashboardStats( total_nodes=total_nodes, diff --git a/src/meshcore_hub/web/static/js/spa/api.js b/src/meshcore_hub/web/static/js/spa/api.js index 7ce0a14..f572a53 100644 --- a/src/meshcore_hub/web/static/js/spa/api.js +++ b/src/meshcore_hub/web/static/js/spa/api.js @@ -4,13 +4,25 @@ * Wrapper around fetch() for making API calls to the proxied backend. */ +/** + * Returns true if the error is a fetch abort (e.g. the request was cancelled + * because the user navigated to another page). + * @param {*} e + * @returns {boolean} + */ +export function isAbortError(e) { + return !!e && e.name === 'AbortError'; +} + /** * Make a GET request and return parsed JSON. * @param {string} path - URL path (e.g., '/api/v1/nodes') * @param {Object} [params] - Query parameters + * @param {Object} [options] - Extra options + * @param {AbortSignal} [options.signal] - Signal to cancel the request (e.g. on navigation) * @returns {Promise} Parsed JSON response */ -export async function apiGet(path, params = {}) { +export async function apiGet(path, params = {}, { signal } = {}) { const url = new URL(path, window.location.origin); for (const [k, v] of Object.entries(params)) { if (v !== null && v !== undefined && v !== '') { @@ -21,7 +33,7 @@ export async function apiGet(path, params = {}) { } } } - const response = await fetch(url); + const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index e1fef91..eedc1e9 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -6,6 +6,7 @@ */ import { Router } from './router.js'; +import { isAbortError } from './api.js'; import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js'; import { loadLocale, t } from './i18n.js'; import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, iconMembers, iconPage, iconChannel } from './icons.js'; @@ -45,6 +46,8 @@ function pageHandler(loader) { const module = await loader(); return await module.render(appContainer, params, router); } catch (e) { + // Navigating away cancels in-flight requests — not a real error. + if (isAbortError(e)) return; console.error('Page load error:', e); appContainer.innerHTML = `
diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index f7b2f76..53a60b6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, @@ -23,6 +23,7 @@ function routeTypeBadge(routeType) { } export async function render(container, params, router) { + const { signal } = params || {}; const query = params.query || {}; const search = query.search || ''; const observed_by = query.observed_by @@ -74,11 +75,11 @@ ${displayContent}`, container); if (observed_by.length > 0) apiParams.observed_by = observed_by; if (adopted_by) apiParams.adopted_by = adopted_by; const fetches = [ - apiGet('/api/v1/advertisements', apiParams), - apiGet('/api/v1/nodes', { limit: 500, observer: true }), + apiGet('/api/v1/advertisements', apiParams, { signal }), + apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }), ]; if (config.oidc_enabled) { - fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 })); + fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal })); } const results = await Promise.all(fetches); const data = results[0]; @@ -309,6 +310,7 @@ ${mobileSortSelect({ ${paginationBlock}`, { total }); } catch (e) { + if (isAbortError(e)) return; renderPage(nothing, { error: e.message }); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js index 79a5862..011d0d4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/channels.js +++ b/src/meshcore_hub/web/static/js/spa/pages/channels.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; +import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js'; import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js'; import { iconChannel, iconPlus, iconEdit, iconTrash, iconLock } from '../icons.js'; @@ -120,12 +120,13 @@ function renderDeleteModal({ channel, onConfirm, onCancel }) { } export async function render(container, params, router) { + const { signal } = params || {}; try { const config = getConfig(); const oidcEnabled = config.oidc_enabled; const isAdmin = hasRole('admin'); - const data = await apiGet('/api/v1/channels'); + const data = await apiGet('/api/v1/channels', {}, { signal }); const channels = data.items || []; let modalState = null; @@ -281,6 +282,7 @@ export async function render(container, params, router) { renderPage(channels); } catch (e) { + if (isAbortError(e)) return; litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js index 57f9881..30ab615 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js +++ b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js @@ -1,9 +1,10 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js'; export async function render(container, params, router) { + const { signal } = params || {}; try { - const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug)); + const page = await apiGet('/spa/pages/' + encodeURIComponent(params.slug), {}, { signal }); const config = getConfig(); const networkName = config.network_name || 'MeshCore Network'; @@ -19,6 +20,7 @@ export async function render(container, params, router) {
`, container); } catch (e) { + if (isAbortError(e)) return; if (e.message && e.message.includes('404')) { litRender(errorAlert(t('common.page_not_found')), container); } else { diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index c43deb2..b8568c7 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, getConfig, getChannelLabelsMap, resolveChannelLabel, @@ -158,6 +158,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages }) { } export async function render(container, params, router) { + const { signal } = params || {}; try { const config = getConfig(); let channelLabels = new Map(); @@ -167,11 +168,11 @@ export async function render(container, params, router) { const showMessages = features.messages !== false; const [stats, advertActivity, messageActivity, nodeCount, channelsData] = await Promise.all([ - apiGet('/api/v1/dashboard/stats'), - apiGet('/api/v1/dashboard/activity', { days: 7 }), - apiGet('/api/v1/dashboard/message-activity', { days: 7 }), - apiGet('/api/v1/dashboard/node-count', { days: 7 }), - apiGet('/api/v1/channels'), + apiGet('/api/v1/dashboard/stats', {}, { signal }), + apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), + apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), + apiGet('/api/v1/dashboard/node-count', { days: 7 }, { signal }), + apiGet('/api/v1/channels', {}, { signal }), ]); channelLabels = new Map([ ...getChannelLabelsMap(config), @@ -256,6 +257,7 @@ ${bottomCount > 0 ? html` }; } catch (e) { + if (isAbortError(e)) return; litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index 27e631a..a20e819 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, getConfig, errorAlert, pageColors, renderStatCard, t, @@ -200,6 +200,7 @@ function renderMembersPanel({ features, stats }) { } export async function render(container, params, router) { + const { signal } = params || {}; try { const config = getConfig(); const features = config.features || {}; @@ -210,9 +211,9 @@ export async function render(container, params, router) { const rc = config.network_radio_config; const [stats, advertActivity, messageActivity] = await Promise.all([ - apiGet('/api/v1/dashboard/stats'), - apiGet('/api/v1/dashboard/activity', { days: 7 }), - apiGet('/api/v1/dashboard/message-activity', { days: 7 }), + apiGet('/api/v1/dashboard/stats', {}, { signal }), + apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), + apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), ]); const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false; @@ -276,6 +277,7 @@ export async function render(container, params, router) { }; } catch (e) { + if (isAbortError(e)) return; litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index 9acfd24..7cbc03a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert, @@ -116,9 +116,10 @@ function createPopupContent(node, oidcEnabled) { } export async function render(container, params, router) { + const { signal } = params || {}; try { const config = getConfig(); - const data = await apiGet('/map/data'); + const data = await apiGet('/map/data', {}, { signal }); let allNodes = data.nodes || []; const mapCenter = data.center || { lat: 0, lon: 0 }; const adoptedCenter = data.adopted_center || null; @@ -140,7 +141,7 @@ export async function render(container, params, router) { lastMemberFilter = memberFilter; const params = {}; if (memberFilter) params.adopted_by = memberFilter; - const newData = await apiGet('/map/data', params); + const newData = await apiGet('/map/data', params, { signal }); allNodes = newData.nodes || []; } const filteredNodes = applyFiltersCore(); @@ -356,6 +357,7 @@ ${config.oidc_enabled ? html` return () => map.remove(); } catch (e) { + if (isAbortError(e)) return; litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js index ec4c24b..22aba14 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/members.js @@ -1,4 +1,4 @@ -import { apiGet } from '../api.js'; +import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, errorAlert, getConfig } from '../components.js'; import { iconAntenna, iconUsers } from '../icons.js'; @@ -64,6 +64,7 @@ function renderGroup(title, profiles, icon, router) { } export async function render(container, params, router) { + const { signal } = params || {}; try { const config = getConfig(); const roleNames = config.role_names || {}; @@ -71,7 +72,7 @@ export async function render(container, params, router) { const memberRole = roleNames.member || 'member'; const testRole = roleNames.test || 'test'; - const resp = await apiGet('/api/v1/user/profiles', { limit: 500 }); + const resp = await apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }); const allProfiles = resp.items || []; const profiles = allProfiles.filter(p => !p.roles || !p.roles.includes(testRole)); @@ -105,6 +106,7 @@ ${renderGroup(t('members_page.members'), members, html`