openvibers/openvibe.network Commits

Open stats: routes, not pages; the page is warm from boot (#361)

OP OpenVibers committed 17 days ago · 1e65e7ecee · 3 files, +39 −9

* Open stats: the routes table says routes (pages and API, by template); the 30-day window is computed soon after boot and kept warm, so no reader waits for it

* Open stats: one computation at a time; the warm-up refreshes before the numbers expire while readers keep the current ones

Changes

ddiff --git a/server/index.js b/server/index.js
iindex a3bd8df9..cf913edb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -544,10 +544,11 @@ app.use('/api/v1/creators', rateLimit({ windowMs: 60_000, max: 300 }), require('
app.use('/api/v1/status/incidents', rateLimit({ windowMs: 60_000, max: 120 }), require('./status/incidents').router({ requireAuth, incidentGuard: require('./identity/principals').guard('network.status.incident') }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem, release }));
// Open stats (owner, 2026-10-27): every OpenVibe site's numbers, public — /stats, /api/v1/stats, /api/v1/stats/pageviews.csv.
--app.use(require('./stats/open').createOpenStats({
++const openStats = require('./stats/open').createOpenStats({
db, selfToken, release, statusRows: () => require('./status/routes').rows(ecosystem),
prometheusUrl: config.prometheusUrl || process.env.PROMETHEUS_URL, liveUrl: process.env.OV_LIVE_INTERNAL_URL,
--}).router);
++});
++app.use(openStats.router);
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810', release }).router);
@@@ -905,6 +906,9 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
console.log(`║ Auth: ${config.loginUrl.padEnd(30)}║`);
console.log(`╚═══════════════════════════════════════╝\n`);
++ // Open stats (/stats): the default window computed soon after boot and kept warm, so no reader waits ~2 s for it.
++ timers.push(setTimeout(() => openStats.collect(30).catch(() => {}), 15 * 1000), setInterval(() => openStats.collect(30, { refresh: true }).catch(() => {}), openStats.ttlMs - 15 * 1000));
++
// ── Periodic Maintenance ─────────────────────────────────
// Clean expired notifications every hour
timers.push(setInterval(() => notificationService.maintenance().catch((e) => console.warn('[Notifications] maintenance failed:', e.message)), 60 * 60 * 1000));
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex 9d910a4f..32c6f3c8 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -211,12 +211,22 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
sites,
};
}
-- function collect(days) {
++ /** The numbers for a window, held TTL_MS; one computation at a time. `refresh` (the boot warm-up, just before the
++ * numbers expire) computes them again while readers keep the current ones, and swaps the new ones in when ready. */
++ function collect(days, { refresh = false } = {}) {
const e = cache.get(days);
-- if (e && now() - e.at < TTL_MS) return e.promise;
-- const promise = compute(days).catch((err) => { cache.delete(days); throw err; });
-- cache.set(days, { at: now(), promise });
-- return promise;
++ const fresh = !!e && now() - e.at < TTL_MS;
++ if (fresh && !refresh) return e.promise;
++ if (fresh && e.next) return e.next;
++ const next = compute(days);
++ if (fresh) {
++ e.next = next;
++ next.then(() => cache.set(days, { at: now(), promise: next }), () => { e.next = null; });
++ return next;
++ }
++ cache.set(days, { at: now(), promise: next });
++ next.catch(() => cache.delete(days));
++ return next;
}
const router = express.Router();
@@@ -241,7 +251,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
try { data = await collect(days); } catch { data = null; }
res.set('Cache-Control', 'public, max-age=60').type('html').send(render(data, days, { releaseId: release && release.release }));
});
-- return { router, collect };
++ return { router, collect, ttlMs: TTL_MS };
}
// ── Charts: server-drawn SVG (no script, crisp at any size), each with its numbers in a <title> and a text summary ──
@@@ -349,7 +359,7 @@ ${d.pageviews_daily ? `<section class="os-sec" aria-labelledby="h-pv"><h2 id="h-
${L ? `<section class="os-sec" aria-labelledby="h-live"><h2 id="h-live">${mark('live', 28, 'os-h-icon')}OpenVibe.Live</h2><div class="os-minis">${liveTiles}</div><p class="os-fine"><a href="https://openvibe.live/">openvibe.live</a> has these over time on its home page: tap any number there.</p></section>` : ''}
${A ? `<section class="os-sec" aria-labelledby="h-acc"><h2 id="h-acc">New accounts a day</h2>${bars(A.daily, 'New accounts')}</section>` : ''}
${C ? `<section class="os-sec" aria-labelledby="h-coins"><h2 id="h-coins">OpenCoins</h2><p class="os-fine">The coin people earn across every OpenVibe site, for watching, chatting and using the tools.</p><div class="os-minis">${[['Earned', fmt(C.earned)], ['Spent', fmt(C.spent)], ['Wallets holding coins', fmt(C.holders)], ['Transactions', fmt(C.transactions)]].map(([l, v]) => `<div class="os-mini"><b>${v}</b><span>${esc(l)}</span></div>`).join('')}</div>${bars(C.daily, 'OpenCoins earned')}</section>` : ''}
--${busy ? `<section class="os-sec" aria-labelledby="h-busy"><h2 id="h-busy">Busiest pages, 24 hours</h2><div class="os-scroll"><table class="os-busy"><thead><tr><th>Page</th><th>Service</th><th>Requests</th></tr></thead><tbody>${busy}</tbody></table></div></section>` : ''}
++${busy ? `<section class="os-sec" aria-labelledby="h-busy"><h2 id="h-busy">Busiest routes, 24 hours</h2><p class="os-fine">Pages and API routes, by their template (never a real address), with plumbing left out.</p><div class="os-scroll"><table class="os-busy"><thead><tr><th>Route</th><th>Service</th><th>Requests</th></tr></thead><tbody>${busy}</tbody></table></div></section>` : ''}
<section class="os-sec os-how" aria-labelledby="h-how"><h2 id="h-how">How these are counted</h2><p class="os-fine">${esc(d.note || '')} Accounts leave out guests and banned accounts. Nothing here names or follows anyone. Reuse anything: <a href="/api/v1/stats?days=${days}"><code>/api/v1/stats</code></a> answers the same numbers as JSON, open to every site and agent.</p></section>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
ddiff --git a/test/open-stats.test.js b/test/open-stats.test.js
iindex 1f8731bc..78b8f0c9 100644
---- a/test/open-stats.test.js
++++ b/test/open-stats.test.js
@@@ -75,6 +75,7 @@ const { createOpenStats, render } = require('../server/stats/open');
assert.ok(html.includes('aria-current="page">7 days</a>'), 'the window is chosen in the address, without script');
assert.ok(/class="os-site".*<span class="os-pre">OpenVibe.<\/span>Tools<\/b>/s.test(html) && html.includes('openvibe.tools'), 'a card per site');
assert.ok(html.includes('"@type":"Dataset"'), 'described as a dataset for search engines');
++ assert.ok(stats.ttlMs === 5 * 60 * 1000, 'held five minutes (server/index.js keeps the 30-day window warm)');
assert.ok(html.includes('<a href="/api/v1/stats?days=7">JSON</a>'));
assert.ok(/<svg class="os-spark"/.test(html) && /<figure class="os-chart"><svg [^>]*role="img" aria-label="Page views, /.test(html) && html.includes('<div class="os-axis">'), 'charts drawn on the server, each with its numbers in words');
const api = await fetch(`${base}/api/v1/stats?days=7`);
@@@ -85,6 +86,21 @@ const { createOpenStats, render } = require('../server/stats/open');
assert.strictEqual((await (await fetch(`${base}/api/v1/stats?days=12`)).json()).window_days, 30, 'an unknown window is 30 days');
} finally { server.close(); }
++ // One computation at a time, and the warm-up's refresh keeps readers on the current numbers until the new ones are in.
++ {
++ let t = 0, calls = 0;
++ const counting = { prepare: () => ({ all: async () => { calls++; return []; }, get: async () => ({ n: 1 }) }) };
++ const s = createOpenStats({ db: counting, now: () => t, fetchImpl: async () => ({ ok: false }) });
++ await Promise.all([s.collect(7), s.collect(7), s.collect(7)]);
++ const once = calls;
++ t = s.ttlMs - 15000;
++ await s.collect(7, { refresh: true });
++ assert.ok(calls > once, 'the refresh computes again before the numbers expire');
++ const after = calls;
++ t = s.ttlMs + 1000;
++ await s.collect(7);
++ assert.strictEqual(calls, after, 'a reader after the old numbers would have expired gets the refreshed ones, no wait');
++ }
// Nothing answered yet: the page still renders, saying so.
assert.ok(render(null, 30).includes('The numbers are being gathered'));
console.log('open stats: all checks passed');