openvibers/openvibe.network Commits
Admin Insights: what is used, what fails, and ask Vibe what to fix (#331)
Changes
@@@ -334,6 +334,7 @@<!-- Overview sub-tab --><div id="analytics-overview">++ <div id="analytics-insights"></div><div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div><div class="card" id="analytics-realtime-card" style="display:none"><h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>@@@ -96,7 +96,61 @@ function createChart(canvasId, config) {function fmtNum(n) { return (n || 0).toLocaleString(); }++// ── Insights: sign-ups, how each service is used and where it fails (last 24 h), and a way to ask Vibe what to fix ──++const INSIGHTS_CHAT = 'https://openvibe.chat';++let lastInsights = null;++async function loadInsights() {++ const el = document.getElementById('analytics-insights'); if (!el) return;++ el.innerHTML = '<div class="loading">Loading insights...</div>';++ let ins;++ try { ins = (await api('/api/admin/analytics/insights')).insights; } catch (e) { el.innerHTML = `<p class="muted">Insights could not be loaded: ${esc(e.message)}</p>`; return; }++ lastInsights = ins;++ const u = ins.users || {};++ const tot = ins.totals || { requests: 0, server_errors: 0 };++ const rate = tot.requests ? (tot.server_errors / tot.requests * 100) : 0;++ const max = Math.max(1, ...(u.daily || []).map((d) => d.n));++ const tile = (label, value, sub = '') => `<div class="stat-card"><div class="stat-value">${value}</div><div class="stat-label">${esc(label)}</div>${sub ? `<div class="muted" style="font-size:12px">${sub}</div>` : ''}</div>`;++ const svcRows = (ins.services || []).slice(0, 20).map((s) => {++ const pct = s.requests ? (s.server_errors / s.requests * 100) : 0;++ return `<tr><td>${esc(s.service)}</td><td style="text-align:right">${fmtNum(s.requests)}</td><td style="text-align:right">${fmtNum(s.client_errors)}</td><td style="text-align:right;${pct >= 1 ? 'color:var(--danger-text, var(--danger))' : ''}">${fmtNum(s.server_errors)}${s.server_errors ? ` <small class="muted">(${pct.toFixed(2)}%)</small>` : ''}</td><td style="text-align:right;${(s.p95_ms || 0) > 1500 ? 'color:var(--warning-text, var(--warning))' : ''}">${s.p95_ms != null ? fmtNum(s.p95_ms) + ' ms' : '—'}</td></tr>`;++ }).join('');++ el.innerHTML = `<div class="card" style="margin-bottom:16px">++ <h3 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-lightbulb"></i> Insights <small class="muted">last 24 hours · ${esc(new Date(ins.generated_at).toLocaleTimeString())}</small></span>++ <button class="btn btn-primary" id="insights-ask" type="button"><i class="fa-solid fa-wand-magic-sparkles"></i> Ask Vibe what to fix</button></h3>++ <div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>++ ${(u.daily || []).length ? `<div style="margin:10px 0 4px" class="muted">Sign-ups per day (14 days)</div><div style="display:flex;align-items:flex-end;gap:4px;height:70px" role="img" aria-label="Sign-ups per day">${u.daily.map((d) => `<div title="${esc(d.day)}: ${d.n}" style="flex:1;background:var(--accent);opacity:.85;border-radius:3px 3px 0 0;height:${Math.max(4, Math.round(d.n / max * 100))}%"></div>`).join('')}</div>` : ''}++ ${ins.metrics_error ? `<p class="muted">${esc(ins.metrics_error)}</p>` : `++ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px;margin-top:12px">++ <div><h4 style="margin:4px 0">Services</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">4xx</th><th style="text-align:right">5xx</th><th style="text-align:right">p95</th></tr>${svcRows}</table></div>++ <div><h4 style="margin:4px 0">Most used</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>++ <h4 style="margin:12px 0 4px">Where errors happen</h4>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">5xx</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div>++ </div>`}++ </div>`;++ const ask = document.getElementById('insights-ask');++ if (ask) ask.addEventListener('click', askVibeToImprove);++}++/** The day's numbers go to the staff room's queue as a task for Vibe (OpenVibe.Actor, as the admin who asked). */++async function askVibeToImprove() {++ const ins = lastInsights; if (!ins) return;++ const lines = [++ `Platform numbers for the last 24 hours (${ins.generated_at}):`,++ `Sign-ups: ${ins.users && ins.users.new_1d} today, ${ins.users && ins.users.new_7d} this week, ${ins.users && ins.users.total} accounts.`,++ `Requests ${ins.totals ? ins.totals.requests : '?'}, server errors ${ins.totals ? ins.totals.server_errors : '?'}.`,++ 'Services (requests / 4xx / 5xx / p95 ms):', ...(ins.services || []).slice(0, 15).map((s) => `- ${s.service}: ${s.requests} / ${s.client_errors} / ${s.server_errors} / ${s.p95_ms == null ? '?' : s.p95_ms}`),++ 'Most used:', ...(ins.top_routes || []).slice(0, 12).map((r) => `- ${r.service} ${r.route}: ${r.requests}`),++ 'Server errors by place:', ...((ins.error_routes || []).length ? ins.error_routes.slice(0, 12).map((r) => `- ${r.service} ${r.route}: ${r.errors}`) : ['- none']),++ ];++ try {++ const res = await fetch(INSIGHTS_CHAT + '/api/chat/rooms/staff/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },++ body: JSON.stringify({ kind: 'answer', title: 'From today\'s numbers: the three fixes or improvements worth doing first, and why', details: lines.join('\n').slice(0, 5800) }) });++ const body = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error(body.error || ('OpenVibe.Chat answered ' + res.status));++ toast('Asked Vibe in the staff room: the answer comes back there in a minute or two');++ } catch (e) { toast(e.message, 'error'); }++}++async function loadAnalyticsOverview() {++ loadInsights();const period = getAnalyticsPeriod();const statsEl = document.getElementById('analytics-stats');statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';@@@ -70,6 +70,54 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,}}++ // ═══════════════════════════════════════════════════════════++ // Insights — sign-ups (this database) and how every service is used and fails (Prometheus on the host, which++ // scrapes each service's /metrics: requests by route template and status class, latency histograms)++ // ═══════════════════════════════════════════════════════════++ // Routes that are plumbing, not something a person uses: left out of "most used".++ const PLUMBING = /^(\/metrics|\/api\/(ready|health)|static|\/release\.json|\/robots\.txt|\/favicon\.ico|\/internal\/|\/api\/frame\/hit|\/oauth\/token|\/\.well-known\/)/;++ router.get('/insights', async (req, res) => {++ const prom = String(config.prometheusUrl || process.env.PROMETHEUS_URL || 'http://127.0.0.1:9090').replace(/\/+$/, '');++ const query = async (q) => {++ const r = await fetch(`${prom}/api/v1/query?query=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(6000) });++ const j = await r.json().catch(() => null);++ if (!r.ok || !j || j.status !== 'success') throw new Error(`Prometheus answered ${r.status}`);++ return j.data.result.map((x) => ({ ...x.metric, value: Number(x.value[1]) }));++ };++ const out = { generated_at: new Date().toISOString(), window: '24h' };++ try {++ const db = analytics.db;++ const n = async (sql, ...a) => Number(((await db.prepare(sql).get(...a)) || {}).n) || 0;++ out.users = {++ total: await n('SELECT COUNT(*)::bigint AS n FROM users'),++ new_1d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '1 day', 'YYYY-MM-DD HH24:MI:SS')"),++ new_7d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '7 days', 'YYYY-MM-DD HH24:MI:SS')"),++ new_30d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '30 days', 'YYYY-MM-DD HH24:MI:SS')"),++ daily: (await db.prepare("SELECT substr(created_at, 1, 10) AS day, COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '14 days', 'YYYY-MM-DD') GROUP BY 1 ORDER BY 1").all()).map((r) => ({ day: r.day, n: Number(r.n) })),++ };++ } catch (err) { out.users = { error: err.message }; }++ try {++ const [reqs, c4, c5, p95, routes, errs] = await Promise.all([++ query('sum by (job) (increase(http_requests_total[24h]))'),++ query('sum by (job) (increase(http_requests_total{status_class="4xx"}[24h]))'),++ query('sum by (job) (increase(http_requests_total{status_class="5xx"}[24h]))'),++ query('histogram_quantile(0.95, sum by (job, le) (rate(http_request_duration_seconds_bucket[24h])))'),++ query('topk(80, sum by (job, route) (increase(http_requests_total{status_class=~"2xx|3xx"}[24h])))'),++ query('topk(20, sum by (job, route) (increase(http_requests_total{status_class="5xx"}[24h])) > 0)'),++ ]);++ const by = (list) => new Map(list.map((x) => [x.job, x.value]));++ const m4 = by(c4), m5 = by(c5), mp = by(p95);++ out.services = reqs.filter((x) => x.value >= 1).map((x) => ({++ service: x.job, requests: Math.round(x.value), client_errors: Math.round(m4.get(x.job) || 0), server_errors: Math.round(m5.get(x.job) || 0),++ p95_ms: Number.isFinite(mp.get(x.job)) ? Math.round(mp.get(x.job) * 1000) : null,++ })).sort((a, b) => b.requests - a.requests);++ out.top_routes = routes.filter((x) => x.route && !PLUMBING.test(x.route)).sort((a, b) => b.value - a.value).slice(0, 25).map((x) => ({ service: x.job, route: x.route, requests: Math.round(x.value) }));++ out.error_routes = errs.filter((x) => x.value >= 1).sort((a, b) => b.value - a.value).map((x) => ({ service: x.job, route: x.route, errors: Math.round(x.value) }));++ out.totals = { requests: out.services.reduce((a, s) => a + s.requests, 0), server_errors: out.services.reduce((a, s) => a + s.server_errors, 0) };++ } catch (err) { out.metrics_error = `Metrics are unavailable: ${err.message}`; }++ res.set('Cache-Control', 'private, no-store').json({ ok: true, insights: out });++ });++// ═══════════════════════════════════════════════════════════// Overview — Cross-Service Summary// ═══════════════════════════════════════════════════════════nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++// The admin panel's Insights (server/admin/analytics-routes.js GET /api/admin/analytics/insights): sign-ups from this++// database, and per service requests, 4xx, 5xx and p95 latency plus the most used and failing routes from Prometheus;++// plumbing routes stay out of "most used"; Prometheus down is said plainly while sign-ups still show; admins only.++// The panel draws it and can send the day's numbers to the staff room's queue for Vibe.++// node test/admin-insights.test.js++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const createAnalyticsRoutes = require('../server/admin/analytics-routes');++++const vec = (rows) => JSON.stringify({ status: 'success', data: { resultType: 'vector', result: rows.map(([metric, v]) => ({ metric, value: [1, String(v)] })) } });++const ANSWERS = [++ [/status_class="4xx"/, vec([[{ job: 'live' }, 120], [{ job: 'wiki' }, 3]])],++ [/status_class="5xx"\}\[24h\]\)\) > 0/, vec([[{ job: 'live', route: '/api/streams' }, 7]])],++ [/status_class="5xx"/, vec([[{ job: 'live' }, 7]])],++ [/histogram_quantile/, vec([[{ job: 'live' }, 0.42], [{ job: 'wiki' }, 'NaN']])],++ [/topk\(80/, vec([[{ job: 'media', route: '/v/:id' }, 30000], [{ job: 'tools', route: '/metrics' }, 24000], [{ job: 'chat', route: '/internal/chat/presence' }, 28000], [{ job: 'live', route: '/api/streams' }, 13000], [{ job: 'network', route: '/api/frame/hit' }, 8000]])],++ [/increase\(http_requests_total\[24h\]\)/, vec([[{ job: 'media' }, 50000], [{ job: 'live' }, 30000], [{ job: 'wiki' }, 900], [{ job: 'idle' }, 0]])],++];++++(async () => {++ let up = true;++ const asked = [];++ const prom = http.createServer((req, res) => {++ const q = new URL(req.url, 'http://x').searchParams.get('query');++ asked.push(q);++ if (!up) return res.writeHead(503).end('down');++ const hit = ANSWERS.find(([re]) => re.test(q));++ res.writeHead(200, { 'content-type': 'application/json' }).end(hit ? hit[1] : vec([]));++ });++ await new Promise((r) => prom.listen(0, '127.0.0.1', r));++ const users = { total: 1600, d1: 4, d7: 31, d30: 120 };++ const fakeDb = {++ prepare(sql) {++ return {++ get: async () => ({ n: /1 day/.test(sql) ? users.d1 : /7 days/.test(sql) ? users.d7 : /30 days/.test(sql) ? users.d30 : users.total }),++ all: async () => [{ day: '2026-10-24', n: 3 }, { day: '2026-10-25', n: 4 }],++ };++ },++ };++ const requireAuth = (req, res, next) => { const u = req.headers['x-test-user']; if (!u) return res.status(401).json({ error: 'Authentication required' }); req.user = JSON.parse(u); next(); };++ const app = express();++ app.use('/api/admin/analytics', createAnalyticsRoutes({ db: fakeDb, getStats: async () => ({}) }, requireAuth, { prometheusUrl: `http://127.0.0.1:${prom.address().port}`, services: {} }));++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const base = `http://127.0.0.1:${srv.address().port}/api/admin/analytics/insights`;++ const get = async (user) => { const r = await fetch(base, { headers: user ? { 'x-test-user': JSON.stringify(user) } : {} }); return { status: r.status, body: await r.json().catch(() => null), cache: r.headers.get('cache-control') }; };++ try {++ assert.strictEqual((await get(null)).status, 401);++ assert.strictEqual((await get({ id: 2, role: 'global_mod' })).status, 403, 'admins only');++ const r = await get({ id: 1, role: 'admin' });++ assert.strictEqual(r.status, 200);++ assert.match(r.cache, /no-store/);++ const ins = r.body.insights;++ assert.deepStrictEqual([ins.users.total, ins.users.new_1d, ins.users.new_7d, ins.users.new_30d], [1600, 4, 31, 120]);++ assert.deepStrictEqual(ins.users.daily, [{ day: '2026-10-24', n: 3 }, { day: '2026-10-25', n: 4 }]);++ assert.deepStrictEqual(ins.services.map((s) => s.service), ['media', 'live', 'wiki'], 'busiest first; a service with no traffic is left out');++ assert.deepStrictEqual(ins.services.find((s) => s.service === 'live'), { service: 'live', requests: 30000, client_errors: 120, server_errors: 7, p95_ms: 420 });++ assert.strictEqual(ins.services.find((s) => s.service === 'wiki').p95_ms, null, 'no latency yet is not zero');++ assert.deepStrictEqual(ins.top_routes.map((x) => `${x.service} ${x.route}`), ['media /v/:id', 'live /api/streams'], '/metrics, internal and frame hits are plumbing');++ assert.deepStrictEqual(ins.error_routes, [{ service: 'live', route: '/api/streams', errors: 7 }]);++ assert.deepStrictEqual(ins.totals, { requests: 80900, server_errors: 7 });++ assert.ok(asked.every((q) => !/[;]/.test(q)), 'fixed queries');++ up = false;++ const down = (await get({ id: 1, role: 'admin' })).body.insights;++ assert.match(down.metrics_error, /Metrics are unavailable/);++ assert.strictEqual(down.users.new_7d, 31, 'sign-ups still show');++ // The panel: drawn on the overview, asks the staff room with the admin's own token.++ const js = fs.readFileSync(path.join(__dirname, '../public/js/admin/analytics.js'), 'utf8');++ assert.match(js, /\/api\/admin\/analytics\/insights/);++ assert.match(js, /\/api\/chat\/rooms\/staff\/tasks/);++ assert.match(fs.readFileSync(path.join(__dirname, '../public/admin.html'), 'utf8'), /id="analytics-insights"/);++ console.log('admin insights: all checks passed');++ } finally {++ srv.close(); prom.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -111,7 +111,7 @@ const PATTERNS = {// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).const REVIEWED = {'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],-- 'server/admin/analytics-routes.js fetch': [1, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), never a request value'],++ 'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],'server/auth/discord-link.js fetch': [2, 'fixed Discord API host (discord.com/api)'],
@@ -334,6 +334,7 @@
@@ -334,6 +334,7 @@
<!-- Overview sub-tab -->
<!-- Overview sub-tab -->
<div id="analytics-overview">
<div id="analytics-overview">
+ <div id="analytics-insights"></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="card" id="analytics-realtime-card" style="display:none">
<div class="card" id="analytics-realtime-card" style="display:none">
<h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>
<h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>
@@ -96,7 +96,61 @@ function createChart(canvasId, config) {
@@ -96,7 +96,61 @@ function createChart(canvasId, config) {
function fmtNum(n) { return (n || 0).toLocaleString(); }
function fmtNum(n) { return (n || 0).toLocaleString(); }
+// ── Insights: sign-ups, how each service is used and where it fails (last 24 h), and a way to ask Vibe what to fix ──
+const INSIGHTS_CHAT = 'https://openvibe.chat';
+let lastInsights = null;
+async function loadInsights() {
+ const el = document.getElementById('analytics-insights'); if (!el) return;
+ el.innerHTML = '<div class="loading">Loading insights...</div>';
+ let ins;
+ try { ins = (await api('/api/admin/analytics/insights')).insights; } catch (e) { el.innerHTML = `<p class="muted">Insights could not be loaded: ${esc(e.message)}</p>`; return; }
+ lastInsights = ins;
+ const u = ins.users || {};
+ const tot = ins.totals || { requests: 0, server_errors: 0 };
+ const rate = tot.requests ? (tot.server_errors / tot.requests * 100) : 0;
+ const max = Math.max(1, ...(u.daily || []).map((d) => d.n));
+ const tile = (label, value, sub = '') => `<div class="stat-card"><div class="stat-value">${value}</div><div class="stat-label">${esc(label)}</div>${sub ? `<div class="muted" style="font-size:12px">${sub}</div>` : ''}</div>`;
+ const svcRows = (ins.services || []).slice(0, 20).map((s) => {
+ const pct = s.requests ? (s.server_errors / s.requests * 100) : 0;
+ return `<tr><td>${esc(s.service)}</td><td style="text-align:right">${fmtNum(s.requests)}</td><td style="text-align:right">${fmtNum(s.client_errors)}</td><td style="text-align:right;${pct >= 1 ? 'color:var(--danger-text, var(--danger))' : ''}">${fmtNum(s.server_errors)}${s.server_errors ? ` <small class="muted">(${pct.toFixed(2)}%)</small>` : ''}</td><td style="text-align:right;${(s.p95_ms || 0) > 1500 ? 'color:var(--warning-text, var(--warning))' : ''}">${s.p95_ms != null ? fmtNum(s.p95_ms) + ' ms' : '—'}</td></tr>`;
+ }).join('');
+ el.innerHTML = `<div class="card" style="margin-bottom:16px">
+ <h3 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-lightbulb"></i> Insights <small class="muted">last 24 hours · ${esc(new Date(ins.generated_at).toLocaleTimeString())}</small></span>
+ <button class="btn btn-primary" id="insights-ask" type="button"><i class="fa-solid fa-wand-magic-sparkles"></i> Ask Vibe what to fix</button></h3>
+ <div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>
+ ${(u.daily || []).length ? `<div style="margin:10px 0 4px" class="muted">Sign-ups per day (14 days)</div><div style="display:flex;align-items:flex-end;gap:4px;height:70px" role="img" aria-label="Sign-ups per day">${u.daily.map((d) => `<div title="${esc(d.day)}: ${d.n}" style="flex:1;background:var(--accent);opacity:.85;border-radius:3px 3px 0 0;height:${Math.max(4, Math.round(d.n / max * 100))}%"></div>`).join('')}</div>` : ''}
+ ${ins.metrics_error ? `<p class="muted">${esc(ins.metrics_error)}</p>` : `
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px;margin-top:12px">
+ <div><h4 style="margin:4px 0">Services</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">4xx</th><th style="text-align:right">5xx</th><th style="text-align:right">p95</th></tr>${svcRows}</table></div>
+ <div><h4 style="margin:4px 0">Most used</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>
+ <h4 style="margin:12px 0 4px">Where errors happen</h4>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">5xx</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div>
+ </div>`}
+ </div>`;
+ const ask = document.getElementById('insights-ask');
+ if (ask) ask.addEventListener('click', askVibeToImprove);
+}
+/** The day's numbers go to the staff room's queue as a task for Vibe (OpenVibe.Actor, as the admin who asked). */
+async function askVibeToImprove() {
+ const ins = lastInsights; if (!ins) return;
+ const lines = [
+ `Platform numbers for the last 24 hours (${ins.generated_at}):`,
+ `Sign-ups: ${ins.users && ins.users.new_1d} today, ${ins.users && ins.users.new_7d} this week, ${ins.users && ins.users.total} accounts.`,
+ `Requests ${ins.totals ? ins.totals.requests : '?'}, server errors ${ins.totals ? ins.totals.server_errors : '?'}.`,
+ 'Services (requests / 4xx / 5xx / p95 ms):', ...(ins.services || []).slice(0, 15).map((s) => `- ${s.service}: ${s.requests} / ${s.client_errors} / ${s.server_errors} / ${s.p95_ms == null ? '?' : s.p95_ms}`),
+ 'Most used:', ...(ins.top_routes || []).slice(0, 12).map((r) => `- ${r.service} ${r.route}: ${r.requests}`),
+ 'Server errors by place:', ...((ins.error_routes || []).length ? ins.error_routes.slice(0, 12).map((r) => `- ${r.service} ${r.route}: ${r.errors}`) : ['- none']),
+ ];
+ try {
+ const res = await fetch(INSIGHTS_CHAT + '/api/chat/rooms/staff/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
+ body: JSON.stringify({ kind: 'answer', title: 'From today\'s numbers: the three fixes or improvements worth doing first, and why', details: lines.join('\n').slice(0, 5800) }) });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(body.error || ('OpenVibe.Chat answered ' + res.status));
+ toast('Asked Vibe in the staff room: the answer comes back there in a minute or two');
+ } catch (e) { toast(e.message, 'error'); }
+}
+
async function loadAnalyticsOverview() {
async function loadAnalyticsOverview() {
+ loadInsights();
const period = getAnalyticsPeriod();
const period = getAnalyticsPeriod();
const statsEl = document.getElementById('analytics-stats');
const statsEl = document.getElementById('analytics-stats');
statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
@@ -70,6 +70,54 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -70,6 +70,54 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
}
}
}
}
+ // ═══════════════════════════════════════════════════════════
+ // Insights — sign-ups (this database) and how every service is used and fails (Prometheus on the host, which
+ // scrapes each service's /metrics: requests by route template and status class, latency histograms)
+ // ═══════════════════════════════════════════════════════════
+ // Routes that are plumbing, not something a person uses: left out of "most used".
+ const PLUMBING = /^(\/metrics|\/api\/(ready|health)|static|\/release\.json|\/robots\.txt|\/favicon\.ico|\/internal\/|\/api\/frame\/hit|\/oauth\/token|\/\.well-known\/)/;
+ router.get('/insights', async (req, res) => {
+ const prom = String(config.prometheusUrl || process.env.PROMETHEUS_URL || 'http://127.0.0.1:9090').replace(/\/+$/, '');
+ const query = async (q) => {
+ const r = await fetch(`${prom}/api/v1/query?query=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(6000) });
+ const j = await r.json().catch(() => null);
+ if (!r.ok || !j || j.status !== 'success') throw new Error(`Prometheus answered ${r.status}`);
+ return j.data.result.map((x) => ({ ...x.metric, value: Number(x.value[1]) }));
+ };
+ const out = { generated_at: new Date().toISOString(), window: '24h' };
+ try {
+ const db = analytics.db;
+ const n = async (sql, ...a) => Number(((await db.prepare(sql).get(...a)) || {}).n) || 0;
+ out.users = {
+ total: await n('SELECT COUNT(*)::bigint AS n FROM users'),
+ new_1d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '1 day', 'YYYY-MM-DD HH24:MI:SS')"),
+ new_7d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '7 days', 'YYYY-MM-DD HH24:MI:SS')"),
+ new_30d: await n("SELECT COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '30 days', 'YYYY-MM-DD HH24:MI:SS')"),
+ daily: (await db.prepare("SELECT substr(created_at, 1, 10) AS day, COUNT(*)::bigint AS n FROM users WHERE created_at >= to_char(now() AT TIME ZONE 'UTC' - interval '14 days', 'YYYY-MM-DD') GROUP BY 1 ORDER BY 1").all()).map((r) => ({ day: r.day, n: Number(r.n) })),
+ };
+ } catch (err) { out.users = { error: err.message }; }
+ try {
+ const [reqs, c4, c5, p95, routes, errs] = await Promise.all([
+ query('sum by (job) (increase(http_requests_total[24h]))'),
+ query('sum by (job) (increase(http_requests_total{status_class="4xx"}[24h]))'),
+ query('sum by (job) (increase(http_requests_total{status_class="5xx"}[24h]))'),
+ query('histogram_quantile(0.95, sum by (job, le) (rate(http_request_duration_seconds_bucket[24h])))'),
+ query('topk(80, sum by (job, route) (increase(http_requests_total{status_class=~"2xx|3xx"}[24h])))'),
+ query('topk(20, sum by (job, route) (increase(http_requests_total{status_class="5xx"}[24h])) > 0)'),
+ ]);
+ const by = (list) => new Map(list.map((x) => [x.job, x.value]));
+ const m4 = by(c4), m5 = by(c5), mp = by(p95);
+ out.services = reqs.filter((x) => x.value >= 1).map((x) => ({
+ service: x.job, requests: Math.round(x.value), client_errors: Math.round(m4.get(x.job) || 0), server_errors: Math.round(m5.get(x.job) || 0),
+ p95_ms: Number.isFinite(mp.get(x.job)) ? Math.round(mp.get(x.job) * 1000) : null,
+ })).sort((a, b) => b.requests - a.requests);
+ out.top_routes = routes.filter((x) => x.route && !PLUMBING.test(x.route)).sort((a, b) => b.value - a.value).slice(0, 25).map((x) => ({ service: x.job, route: x.route, requests: Math.round(x.value) }));
+ out.error_routes = errs.filter((x) => x.value >= 1).sort((a, b) => b.value - a.value).map((x) => ({ service: x.job, route: x.route, errors: Math.round(x.value) }));
+ out.totals = { requests: out.services.reduce((a, s) => a + s.requests, 0), server_errors: out.services.reduce((a, s) => a + s.server_errors, 0) };
+ } catch (err) { out.metrics_error = `Metrics are unavailable: ${err.message}`; }
+ res.set('Cache-Control', 'private, no-store').json({ ok: true, insights: out });
+ });
+
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
// Overview — Cross-Service Summary
// Overview — Cross-Service Summary
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+// The admin panel's Insights (server/admin/analytics-routes.js GET /api/admin/analytics/insights): sign-ups from this
+// database, and per service requests, 4xx, 5xx and p95 latency plus the most used and failing routes from Prometheus;
+// plumbing routes stay out of "most used"; Prometheus down is said plainly while sign-ups still show; admins only.
+// The panel draws it and can send the day's numbers to the staff room's queue for Vibe.
+// node test/admin-insights.test.js
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const createAnalyticsRoutes = require('../server/admin/analytics-routes');
+
+const vec = (rows) => JSON.stringify({ status: 'success', data: { resultType: 'vector', result: rows.map(([metric, v]) => ({ metric, value: [1, String(v)] })) } });
+const ANSWERS = [
+ [/status_class="4xx"/, vec([[{ job: 'live' }, 120], [{ job: 'wiki' }, 3]])],
+ [/status_class="5xx"\}\[24h\]\)\) > 0/, vec([[{ job: 'live', route: '/api/streams' }, 7]])],
+ [/status_class="5xx"/, vec([[{ job: 'live' }, 7]])],
+ [/histogram_quantile/, vec([[{ job: 'live' }, 0.42], [{ job: 'wiki' }, 'NaN']])],
+ [/topk\(80/, vec([[{ job: 'media', route: '/v/:id' }, 30000], [{ job: 'tools', route: '/metrics' }, 24000], [{ job: 'chat', route: '/internal/chat/presence' }, 28000], [{ job: 'live', route: '/api/streams' }, 13000], [{ job: 'network', route: '/api/frame/hit' }, 8000]])],
+ [/increase\(http_requests_total\[24h\]\)/, vec([[{ job: 'media' }, 50000], [{ job: 'live' }, 30000], [{ job: 'wiki' }, 900], [{ job: 'idle' }, 0]])],
+];
+
+(async () => {
+ let up = true;
+ const asked = [];
+ const prom = http.createServer((req, res) => {
+ const q = new URL(req.url, 'http://x').searchParams.get('query');
+ asked.push(q);
+ if (!up) return res.writeHead(503).end('down');
+ const hit = ANSWERS.find(([re]) => re.test(q));
+ res.writeHead(200, { 'content-type': 'application/json' }).end(hit ? hit[1] : vec([]));
+ });
+ await new Promise((r) => prom.listen(0, '127.0.0.1', r));
+ const users = { total: 1600, d1: 4, d7: 31, d30: 120 };
+ const fakeDb = {
+ prepare(sql) {
+ return {
+ get: async () => ({ n: /1 day/.test(sql) ? users.d1 : /7 days/.test(sql) ? users.d7 : /30 days/.test(sql) ? users.d30 : users.total }),
+ all: async () => [{ day: '2026-10-24', n: 3 }, { day: '2026-10-25', n: 4 }],
+ };
+ },
+ };
+ const requireAuth = (req, res, next) => { const u = req.headers['x-test-user']; if (!u) return res.status(401).json({ error: 'Authentication required' }); req.user = JSON.parse(u); next(); };
+ const app = express();
+ app.use('/api/admin/analytics', createAnalyticsRoutes({ db: fakeDb, getStats: async () => ({}) }, requireAuth, { prometheusUrl: `http://127.0.0.1:${prom.address().port}`, services: {} }));
+ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const base = `http://127.0.0.1:${srv.address().port}/api/admin/analytics/insights`;
+ const get = async (user) => { const r = await fetch(base, { headers: user ? { 'x-test-user': JSON.stringify(user) } : {} }); return { status: r.status, body: await r.json().catch(() => null), cache: r.headers.get('cache-control') }; };
+ try {
+ assert.strictEqual((await get(null)).status, 401);
+ assert.strictEqual((await get({ id: 2, role: 'global_mod' })).status, 403, 'admins only');
+ const r = await get({ id: 1, role: 'admin' });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.cache, /no-store/);
+ const ins = r.body.insights;
+ assert.deepStrictEqual([ins.users.total, ins.users.new_1d, ins.users.new_7d, ins.users.new_30d], [1600, 4, 31, 120]);
+ assert.deepStrictEqual(ins.users.daily, [{ day: '2026-10-24', n: 3 }, { day: '2026-10-25', n: 4 }]);
+ assert.deepStrictEqual(ins.services.map((s) => s.service), ['media', 'live', 'wiki'], 'busiest first; a service with no traffic is left out');
+ assert.deepStrictEqual(ins.services.find((s) => s.service === 'live'), { service: 'live', requests: 30000, client_errors: 120, server_errors: 7, p95_ms: 420 });
+ assert.strictEqual(ins.services.find((s) => s.service === 'wiki').p95_ms, null, 'no latency yet is not zero');
+ assert.deepStrictEqual(ins.top_routes.map((x) => `${x.service} ${x.route}`), ['media /v/:id', 'live /api/streams'], '/metrics, internal and frame hits are plumbing');
+ assert.deepStrictEqual(ins.error_routes, [{ service: 'live', route: '/api/streams', errors: 7 }]);
+ assert.deepStrictEqual(ins.totals, { requests: 80900, server_errors: 7 });
+ assert.ok(asked.every((q) => !/[;]/.test(q)), 'fixed queries');
+ up = false;
+ const down = (await get({ id: 1, role: 'admin' })).body.insights;
+ assert.match(down.metrics_error, /Metrics are unavailable/);
+ assert.strictEqual(down.users.new_7d, 31, 'sign-ups still show');
+ // The panel: drawn on the overview, asks the staff room with the admin's own token.
+ const js = fs.readFileSync(path.join(__dirname, '../public/js/admin/analytics.js'), 'utf8');
+ assert.match(js, /\/api\/admin\/analytics\/insights/);
+ assert.match(js, /\/api\/chat\/rooms\/staff\/tasks/);
+ assert.match(fs.readFileSync(path.join(__dirname, '../public/admin.html'), 'utf8'), /id="analytics-insights"/);
+ console.log('admin insights: all checks passed');
+ } finally {
+ srv.close(); prom.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -111,7 +111,7 @@ const PATTERNS = {
@@ -111,7 +111,7 @@ const PATTERNS = {
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
const REVIEWED = {
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
- 'server/admin/analytics-routes.js fetch': [1, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), never a request value'],
+ 'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
'server/auth/discord-link.js fetch': [2, 'fixed Discord API host (discord.com/api)'],
'server/auth/discord-link.js fetch': [2, 'fixed Discord API host (discord.com/api)'],