openvibers/openvibe.network Commits

Admin Insights: what is used, what fails, and ask Vibe what to fix (#331)

OP OpenVibers committed 19 days ago · ca0e95fb29 · 5 files, +183 −1

* Admin Insights: sign-ups, each service's requests, errors and p95 from Prometheus, the most used and failing routes, and 'Ask Vibe what to fix' (the day's numbers to the staff room queue)

* SSRF ratchet: Insights' Prometheus query is a reviewed outbound call

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex 2b0a9b5a..f7957273 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/public/js/admin/analytics.js b/public/js/admin/analytics.js
iindex 464961fc..cc93890b 100644
---- a/public/js/admin/analytics.js
++++ b/public/js/admin/analytics.js
@@@ -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>';
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex 11dc34a0..e744caaf 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -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
// ═══════════════════════════════════════════════════════════
ddiff --git a/test/admin-insights.test.js b/test/admin-insights.test.js
nnew file mode 100644
iindex 00000000..432a20ba
---- /dev/null
++++ b/test/admin-insights.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 84f3d110..e3795cad 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)'],