openvibers/openvibe.network Commits
Open stats: routes, not pages; the page is warm from boot (#361)
Changes
@@@ -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, confconsole.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 hourtimers.push(setInterval(() => notificationService.maintenance().catch((e) => console.warn('[Notifications] maintenance failed:', e.message)), 60 * 60 * 1000));@@@ -211,12 +211,22 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], prsites,};}-- 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 = () => [], prtry { 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' })}@@@ -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');
@@ -544,10 +544,11 @@ app.use('/api/v1/creators', rateLimit({ windowMs: 60_000, max: 300 }), require('
@@ -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('/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 }));
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.
// 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),
db, selfToken, release, statusRows: () => require('./status/routes').rows(ecosystem),
prometheusUrl: config.prometheusUrl || process.env.PROMETHEUS_URL, liveUrl: process.env.OV_LIVE_INTERNAL_URL,
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.
// 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);
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
@@ -905,6 +906,9 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
console.log(`║ Auth: ${config.loginUrl.padEnd(30)}║`);
console.log(`║ Auth: ${config.loginUrl.padEnd(30)}║`);
console.log(`╚═══════════════════════════════════════╝\n`);
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 ─────────────────────────────────
// ── Periodic Maintenance ─────────────────────────────────
// Clean expired notifications every hour
// Clean expired notifications every hour
timers.push(setInterval(() => notificationService.maintenance().catch((e) => console.warn('[Notifications] maintenance failed:', e.message)), 60 * 60 * 1000));
timers.push(setInterval(() => notificationService.maintenance().catch((e) => console.warn('[Notifications] maintenance failed:', e.message)), 60 * 60 * 1000));
@@ -211,12 +211,22 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
@@ -211,12 +211,22 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
sites,
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);
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();
const router = express.Router();
@@ -241,7 +251,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
@@ -241,7 +251,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
try { data = await collect(days); } catch { data = null; }
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 }));
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 ──
// ── 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-
@@ -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>` : ''}
${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>` : ''}
${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>` : ''}
${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>
<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>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
@@ -75,6 +75,7 @@ const { createOpenStats, render } = require('../server/stats/open');
@@ -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(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(/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(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(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');
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`);
const api = await fetch(`${base}/api/v1/stats?days=7`);
@@ -85,6 +86,21 @@ const { createOpenStats, render } = require('../server/stats/open');
@@ -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');
assert.strictEqual((await (await fetch(`${base}/api/v1/stats?days=12`)).json()).window_days, 30, 'an unknown window is 30 days');
} finally { server.close(); }
} 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.
// Nothing answered yet: the page still renders, saying so.
assert.ok(render(null, 30).includes('The numbers are being gathered'));
assert.ok(render(null, 30).includes('The numbers are being gathered'));
console.log('open stats: all checks passed');
console.log('open stats: all checks passed');