mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-06-26 04:51:59 +02:00
perf(web): cancel in-flight requests on navigation; consolidate dashboard stats
Fix dashboard pages stalling under rapid navigation, plus reduce the cost
of the heaviest dashboard endpoint.
SPA request cancellation: apiGet never passed an AbortSignal, so navigating
away left a page's in-flight requests running — the homepage alone fires
three (/stats + two charts), the slowest being /stats. Under rapid
navigation these piled up, holding browser connections and API threadpool
threads, so the page actually wanted queued behind stale work; a late
resolver could also clobber the new page's DOM.
- api.js: apiGet accepts an optional { signal } and forwards it to fetch;
export isAbortError().
- router.js: each navigation gets an AbortController; the previous one is
aborted at the start of _handleRoute and its signal is passed to the page
handler. A navigation-generation guard stops a superseded route from
hiding the loader for the page that replaced it.
- app.js: pageHandler swallows AbortError (an intentional cancel is not an
error).
- all 11 page modules: thread params.signal into on-load apiGet calls and
guard their catch blocks with isAbortError.
dashboard/stats consolidation: collapse the 11 sequential COUNT(*) queries
into 4 using portable conditional aggregation (func.sum(case(...))) for
nodes, messages, advertisements, and user profiles. Responses are
unchanged.
Docs: extend the v0.12 "Read-Path Query Optimisations" note and add a
"Dashboard Navigation Responsiveness" note (front-end only, no action
required).
Tests: add test_stats_time_bucket_counts asserting the active/today/24h/7d
buckets. SPA bundles are gitignored and rebuilt by the Docker/CI build, so
only committed source changed; the esbuild build was run locally to
validate the JS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<any>} 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}`);
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</div>`, container);
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(errorAlert(t('common.page_not_found')), container);
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`<span class="text-seconda
|
||||
`, container);
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { apiGet, isAbortError } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
const query = params.query || {};
|
||||
const message_type = query.message_type || '';
|
||||
const channel_idx = query.channel_idx || '';
|
||||
@@ -207,9 +208,9 @@ ${displayContent}`, container);
|
||||
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
|
||||
if (observed_by.length > 0) apiParams.observed_by = observed_by;
|
||||
const [data, nodesData, channelsData] = await Promise.all([
|
||||
apiGet('/api/v1/messages', apiParams),
|
||||
apiGet('/api/v1/nodes', { limit: 500, observer: true }),
|
||||
apiGet('/api/v1/channels'),
|
||||
apiGet('/api/v1/messages', apiParams, { signal }),
|
||||
apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
|
||||
apiGet('/api/v1/channels', {}, { signal }),
|
||||
]);
|
||||
const builtinLabels = getChannelLabelsMap(config);
|
||||
const customLabels = new Map(
|
||||
@@ -437,6 +438,7 @@ ${mobileSortSelect({
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
renderPage(nothing, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
getConfig, hasRole, typeEmoji, formatDateTime,
|
||||
@@ -71,20 +71,21 @@ function renderEditTagModal() {
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
const cleanupFns = [];
|
||||
let publicKey = params.publicKey;
|
||||
|
||||
try {
|
||||
if (publicKey.length !== 64) {
|
||||
const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey));
|
||||
const resolved = await apiGet('/api/v1/nodes/prefix/' + encodeURIComponent(publicKey), {}, { signal });
|
||||
router.navigate('/nodes/' + resolved.public_key, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const [node, adsData, telemetryData] = await Promise.all([
|
||||
apiGet('/api/v1/nodes/' + publicKey),
|
||||
apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }),
|
||||
apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }),
|
||||
apiGet('/api/v1/nodes/' + publicKey, {}, { signal }),
|
||||
apiGet('/api/v1/advertisements', { public_key: publicKey, limit: 10 }, { signal }),
|
||||
apiGet('/api/v1/telemetry', { node_public_key: publicKey, limit: 10 }, { signal }),
|
||||
]);
|
||||
|
||||
if (!node) {
|
||||
@@ -556,6 +557,7 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container);
|
||||
cleanupFns.forEach(fn => fn());
|
||||
};
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(renderNotFound(publicKey), container);
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { apiGet, isAbortError } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const adv_type = query.adv_type || '';
|
||||
@@ -55,9 +56,9 @@ ${displayContent}`, container);
|
||||
try {
|
||||
const apiParams = { limit, offset, search, adv_type, sort, order };
|
||||
if (adopted_by) apiParams.adopted_by = adopted_by;
|
||||
const fetches = [apiGet('/api/v1/nodes', apiParams)];
|
||||
const fetches = [apiGet('/api/v1/nodes', apiParams, { 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];
|
||||
@@ -225,6 +226,7 @@ ${mobileSortSelect({
|
||||
${paginationBlock}`, { total });
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
renderPage(nothing, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet, apiPut } from '../api.js';
|
||||
import { apiGet, apiPut, isAbortError } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, t, errorAlert, successAlert,
|
||||
@@ -76,13 +76,15 @@ function renderPublicProfile(profile, config, target) {
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
const config = getConfig();
|
||||
|
||||
if (params.id) {
|
||||
try {
|
||||
const profile = await apiGet(`/api/v1/user/profile/${params.id}`);
|
||||
const profile = await apiGet(`/api/v1/user/profile/${params.id}`, {}, { signal });
|
||||
renderPublicProfile(profile, config, container);
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
return;
|
||||
@@ -99,7 +101,7 @@ export async function render(container, params, router) {
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await apiGet('/api/v1/user/profile/me');
|
||||
const profile = await apiGet('/api/v1/user/profile/me', {}, { signal });
|
||||
const profilePath = `/api/v1/user/profile/${profile.id}`;
|
||||
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
@@ -187,6 +189,7 @@ ${flashHtml}
|
||||
return () => ac.abort();
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export class Router {
|
||||
this._notFoundHandler = null;
|
||||
this._currentCleanup = null;
|
||||
this._onNavigate = null;
|
||||
this._navAbort = null;
|
||||
this._navGen = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +92,18 @@ export class Router {
|
||||
* Handle the current URL.
|
||||
*/
|
||||
async _handleRoute() {
|
||||
// Cancel any in-flight requests from the page we're leaving so they
|
||||
// don't hold connections / server resources behind the new page.
|
||||
if (this._navAbort) {
|
||||
this._navAbort.abort();
|
||||
}
|
||||
this._navAbort = new AbortController();
|
||||
const signal = this._navAbort.signal;
|
||||
|
||||
// Track this navigation so a stale (superseded) route can't toggle the
|
||||
// shared loading indicator for the navigation that replaced it.
|
||||
const navGen = ++this._navGen;
|
||||
|
||||
// Clean up previous page
|
||||
if (this._currentCleanup) {
|
||||
try { this._currentCleanup(); } catch (e) { /* ignore */ }
|
||||
@@ -119,15 +133,16 @@ export class Router {
|
||||
try {
|
||||
const result = this._match(pathname);
|
||||
if (result) {
|
||||
const cleanup = await result.handler({ ...result.params, query });
|
||||
const cleanup = await result.handler({ ...result.params, query, signal });
|
||||
if (typeof cleanup === 'function') {
|
||||
this._currentCleanup = cleanup;
|
||||
}
|
||||
} else if (this._notFoundHandler) {
|
||||
await this._notFoundHandler({ query });
|
||||
await this._notFoundHandler({ query, signal });
|
||||
}
|
||||
} finally {
|
||||
if (loader) loader.classList.add('hidden');
|
||||
// Only hide the loader if a newer navigation hasn't started.
|
||||
if (loader && navGen === this._navGen) loader.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Reset focus to dismiss any open dropdown after navigation
|
||||
|
||||
@@ -42,6 +42,72 @@ class TestDashboardStats:
|
||||
assert data["total_messages"] == 1
|
||||
assert data["total_advertisements"] == 1
|
||||
|
||||
def test_stats_time_bucket_counts(self, client_no_auth, api_db_session):
|
||||
"""Conditional-aggregation buckets (active/today/24h/7d) count the
|
||||
correct rows across recent and older records."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Nodes: one active (seen now), one stale (seen 2 days ago).
|
||||
api_db_session.add_all(
|
||||
[
|
||||
Node(public_key="a" * 64, name="Active", last_seen=now),
|
||||
Node(
|
||||
public_key="b" * 64,
|
||||
name="Stale",
|
||||
last_seen=now - timedelta(days=2),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Messages (contact type → always channel-visible): today, 3 days ago,
|
||||
# 10 days ago.
|
||||
api_db_session.add_all(
|
||||
[
|
||||
Message(message_type="contact", text="now", received_at=now),
|
||||
Message(
|
||||
message_type="contact",
|
||||
text="3d",
|
||||
received_at=now - timedelta(days=3),
|
||||
),
|
||||
Message(
|
||||
message_type="contact",
|
||||
text="10d",
|
||||
received_at=now - timedelta(days=10),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Flood advertisements: now (24h), 3 days ago (7d), 10 days ago (older).
|
||||
api_db_session.add_all(
|
||||
[
|
||||
Advertisement(public_key="c" * 64, route_type="flood", received_at=now),
|
||||
Advertisement(
|
||||
public_key="d" * 64,
|
||||
route_type="flood",
|
||||
received_at=now - timedelta(days=3),
|
||||
),
|
||||
Advertisement(
|
||||
public_key="e" * 64,
|
||||
route_type="flood",
|
||||
received_at=now - timedelta(days=10),
|
||||
),
|
||||
]
|
||||
)
|
||||
api_db_session.commit()
|
||||
|
||||
data = client_no_auth.get("/api/v1/dashboard/stats").json()
|
||||
|
||||
assert data["total_nodes"] == 2
|
||||
assert data["active_nodes"] == 1
|
||||
|
||||
assert data["total_messages"] == 3
|
||||
assert data["messages_today"] == 1
|
||||
assert data["messages_7d"] == 2 # now + 3d (10d excluded)
|
||||
|
||||
assert data["total_advertisements"] == 3
|
||||
assert data["advertisements_24h"] == 1
|
||||
assert data["advertisements_7d"] == 2 # now + 3d (10d excluded)
|
||||
|
||||
|
||||
class TestDashboardHtmlRemoved:
|
||||
"""Tests that legacy HTML dashboard endpoint has been removed."""
|
||||
|
||||
Reference in New Issue
Block a user