openvibers/openvibe.shared Commits
4.10.0: 2fat's name effect, Report a problem in every footer, status text twins, getStats in 9 scans (#99)
Changes
@@@ -4,6 +4,34 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.10.0 — 2026-10-24++++- **items.css draws `name-fx-twofat`, "2fat Bass Drop"**, the one-of-a-kind name effect the owner asked for 2fat on++ 2026-10-23: the name thumps on the beat in club lights, with an equalizer before it and a speaker after it. Its++ particles (`px-twofat`) are notes, a CD and a tape in club pink. Like Josh Vegas, the Calm setting++ (`data-ov-motion=reduced`) and the system's reduced motion stop it, so it shows the same on openvibe.chat, Network's++ pages and every site drawing items as on Live (Live#318, OpenVibe.Inventory#71).++- **Shared's own status text uses the 4.9.0 twins**: the showcase's "yes" cells and the island's action buttons++ (`--ovis-t`: `--success-text`/`--danger-text` by state, the accent otherwise; the status colour stays their fill and++ tint). Shared now writes no status colour as text without its twin (H4's last uses; the 23 sites moved on 10-22/23).++- **"Report a problem" in every site's footer** (the Legal column, and the compact line). It opens a ticket on++ openvibe.help with the site and the page filled in (OpenVibe.Help#81):++ `https://openvibe.help/tickets/new?site=<host without www.>&page=<origin + path>`.++ - The page leaves out the query and the fragment, where a sign-in code or a token can ride.++ - A server-rendered footer links the plain form, and a click fills in the page at that moment, so a single-page++ app's current route is the one reported.++- **`analytics/pg` keeps bot rows shorter when asked**: `pruneRawEventsPg(db, { days, botDays })` and the tracker's++ `retention: { days, botDays }` delete `is_bot` rows after `botDays` (default `days`, so nothing changes until a++ service sets it). Bot rows are in the hourly and daily rollups once their day closes, and were half or more of the raw++ table: Live 1.49M of 2.47M rows (it prunes them at 7 days itself since 10-22), Network 1.22M of 2.46M (991 MB).++- **`analytics/pg` `getStats` reads the raw range 9 times instead of 17** (plan J3). On Live's production data++ (1.3M rows), 30 days took 8 to 12 s and now 2.8 s; 7 days 3.1 s → 1.5 s. Related reads share one grouped scan,++ split in JS: status codes and bot types; device, browser, OS and country; the busiest API calls and the slowest paths;++ and requests, the signed-in split and sessions. Each distinct count per group (visitors per page, per hour of the day,++ per day signed in or not) groups by session first instead of sorting for `COUNT(DISTINCT)`. The numbers and response++ shapes are unchanged: the old and new reads matched on production inside one snapshot, and++ `test/analytics-pg.test.js` checks each list on known rows.++## 4.9.0 — 2026-10-23- **Status colours that read as text** (plan 2026-10-22, track H4). After 4.8.0, axe in the light themes still failed@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.7.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.10.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.8.0",++ "version": "4.10.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -90,7 +90,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {/*** @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)* @param {string} service-- * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, intervalMs,++ * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, botDays = days, intervalMs,* initialDelayMs } or false), timers, now*/constructor(db, service, opts = {}) {@@@ -108,7 +108,8 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {if (opts.retention !== false) {const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};const days = r.days || 30;-- const run = () => { pruneRawEventsPg(db, { days, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };++ const botDays = r.botDays || days;++ const run = () => { pruneRawEventsPg(db, { days, botDays, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };once(run, r.initialDelayMs == null ? 5 * 60e3 : r.initialDelayMs);every(run, r.intervalMs || DAY);}@@@ -319,22 +320,33 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {FROM analytics_daily WHERE service = $1 AND date >= $2`, [svc, dayOf(nowMs - rangeMs)]);const realtime = await get(`SELECT COUNT(*) AS requests, COUNT(DISTINCT session_id) AS visitors, COUNT(*) FILTER (WHERE is_bot = 1) AS botsFROM analytics_events WHERE service = $1 AND created_at >= $2`, [svc, sqlTime(nowMs - 300000)]);-- const topPages = await all(`SELECT path, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors FROM analytics_events-- WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path ORDER BY hits DESC LIMIT 20`, human);++ // Each raw read below scans the range of analytics_events, so related ones share a scan (9 instead of 17: Live's++ // 30 days of 1.3M rows took several seconds, plan J3). A grouped read is split here, and a distinct count per++ // group is two GROUP BYs (each session once per group, then counted), which hash instead of sorting. The++ // numbers and shapes are the ones the separate reads gave.++ const desc = (key) => (x, y) => y[key] - x[key];++ const tally = (rows, col, n, out = 'cnt') => {++ const m = new Map();++ for (const r of rows) m.set(r[col], (m.get(r[col]) || 0) + r[n]);++ return [...m].map(([k, v]) => ({ [col]: k, [out]: v })).sort(desc(out));++ };++ const topPages = await all(`SELECT path, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors FROM (++ SELECT path, session_id, COUNT(*) AS c FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0++ GROUP BY path, session_id) s++ GROUP BY path ORDER BY hits DESC LIMIT 20`, human);const topReferers = await all(`SELECT referer, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2AND referer IS NOT NULL AND referer != '' AND is_bot = 0 GROUP BY referer ORDER BY hits DESC LIMIT 15`, human);-- const botBreakdown = await all(`SELECT bot_type, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 1-- GROUP BY bot_type ORDER BY hits DESC LIMIT 15`, human);-- const statusCodes = await all(`SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'++ const statusRows = await all(`SELECT is_bot, bot_type, CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'WHEN status_code >= 400 AND status_code < 500 THEN '4xx' WHEN status_code >= 500 THEN '5xx' ELSE 'other' END AS group_code, COUNT(*) AS cnt-- FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY group_code ORDER BY cnt DESC`, human);-- const breakdown = (col) => all(`SELECT ${col}, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0-- GROUP BY ${col} ORDER BY cnt DESC`, human);-- const deviceBreakdown = await breakdown('device_type');-- const browserBreakdown = await breakdown('browser');-- const osBreakdown = await breakdown('os');-- const countryBreakdown = await all(`SELECT country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2-- AND country IS NOT NULL AND is_bot = 0 GROUP BY country ORDER BY cnt DESC LIMIT 20`, human);++ FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY 1, 2, 3`, human);++ const botBreakdown = tally(statusRows.filter((r) => r.is_bot === 1), 'bot_type', 'cnt', 'hits').slice(0, 15);++ const statusCodes = tally(statusRows, 'group_code', 'cnt');++ const dims = await all(`SELECT device_type, browser, os, country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0++ GROUP BY 1, 2, 3, 4`, human);++ const deviceBreakdown = tally(dims, 'device_type', 'cnt');++ const browserBreakdown = tally(dims, 'browser', 'cnt');++ const osBreakdown = tally(dims, 'os', 'cnt');++ const countryBreakdown = tally(dims.filter((r) => r.country != null), 'country', 'cnt').slice(0, 20);const responsePercentiles = await safe(async () => {const r = await get(`SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS p50, percentile_disc(0.9) WITHIN GROUP (ORDER BY response_time_ms) AS p90,percentile_disc(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95, percentile_disc(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS p99,@@@ -342,36 +354,46 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0`, human);return r && r.max != null ? r : {};}, {});-- const bandwidth = await safe(async () => {-- const bw = await get(`SELECT COUNT(*) AS total_requests, COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,-- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests, SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END)::bigint AS estimated_bytes-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human);-- return bw && { total_requests: bw.total_requests || 0, page_requests: bw.page_requests || 0, api_requests: bw.api_requests || 0, estimated_bytes: bw.estimated_bytes || 0 };-- }, {});-- const authBreakdown = await safe(() => get(`SELECT COUNT(*) FILTER (WHERE authenticated = 1) AS authenticated, COUNT(*) FILTER (WHERE authenticated = 0) AS anonymous,-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human), {});++ // Requests, signed-in share and sessions: one scan (a session can be anonymous and then signed in, so the++ // session total counts distinct ids over the per-session rows).++ const totals = await safe(() => get(`SELECT SUM(c)::bigint AS total_requests, SUM(pv)::bigint AS page_requests, SUM(api)::bigint AS api_requests,++ SUM(pv * 50000 + (c - pv) * 2000)::bigint AS estimated_bytes,++ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS authenticated, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anonymous,++ COUNT(session_id) FILTER (WHERE a = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) AS total_sessions++ FROM (SELECT authenticated AS a, session_id, COUNT(*) AS c, COUNT(*) FILTER (WHERE event_type = 'pageview') AS pv,++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s`, human), null);++ const bandwidth = totals ? { total_requests: totals.total_requests || 0, page_requests: totals.page_requests || 0, api_requests: totals.api_requests || 0,++ estimated_bytes: totals.estimated_bytes || 0 } : {};++ const authBreakdown = totals ? { authenticated: totals.authenticated, anonymous: totals.anonymous,++ unique_authenticated_users: totals.unique_authenticated_users, total_sessions: totals.total_sessions } : {};++ const sessionCount = totals ? totals.total_sessions || 0 : 0;const errorTrend = await safe(() => (isSubDay? all(`SELECT ${bucketExpr} AS bucket, COUNT(*) AS total, COUNT(*) FILTER (WHERE status_code >= 400) AS errors,COUNT(*) FILTER (WHERE status_code >= 500) AS server_errorsFROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human): all('SELECT hour AS bucket, (pageviews + api_calls) AS total, error_count AS errors FROM analytics_hourly WHERE service = $1 AND hour >= $2 ORDER BY hour ASC', [svc, hourlyCutoff])), []);-- const peakHours = await safe(() => all(`SELECT substr(created_at, 12, 2)::int AS hour_of_day, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors,-- CAST(AVG(response_time_ms) AS integer) AS avg_ms-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY hour_of_day ORDER BY hour_of_day ASC`, human), []);-- const topApiEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms,-- COUNT(*) FILTER (WHERE status_code >= 400) AS errors-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND event_type = 'api_call' AND is_bot = 0 GROUP BY path, method ORDER BY hits DESC LIMIT 20`, human), []);-- const sessionCount = await safe(() => this.db.value(`SELECT COUNT(DISTINCT session_id) FROM analytics_events WHERE service = $1 AND created_at >= $2-- AND session_id IS NOT NULL AND is_bot = 0`, human), 0);++ const peakHours = await safe(() => all(`SELECT h AS hour_of_day, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors,++ CAST(SUM(rt)::numeric / NULLIF(SUM(rn), 0) AS integer) AS avg_ms++ FROM (SELECT substr(created_at, 12, 2)::int AS h, session_id, COUNT(*) AS c, SUM(response_time_ms) AS rt, COUNT(response_time_ms) AS rn++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s++ GROUP BY h ORDER BY h ASC`, human), []);++ // Endpoints: the busiest API calls and the slowest paths (3+ timed requests) from one grouped read.++ const endpoints = await safe(() => all(`SELECT path, method, COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_hits,++ CAST(AVG(response_time_ms) FILTER (WHERE event_type = 'api_call') AS integer) AS api_avg_ms,++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND status_code >= 400) AS api_errors,++ COUNT(response_time_ms) AS timed, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path, method`, human), []);++ const topApiEndpoints = endpoints.filter((r) => r.api_hits > 0)++ .map((r) => ({ path: r.path, method: r.method, hits: r.api_hits, avg_ms: r.api_avg_ms, errors: r.api_errors })).sort(desc('hits')).slice(0, 20);const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };-- const slowestEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0-- GROUP BY path, method HAVING COUNT(*) >= 3 ORDER BY avg_ms DESC LIMIT 10`, human), []);-- const authTrend = await safe(() => all(`SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS bucket,-- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS auth_visitors, COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 0) AS anon_visitors,-- COUNT(*) FILTER (WHERE authenticated = 1) AS auth_hits, COUNT(*) FILTER (WHERE authenticated = 0) AS anon_hits-- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human), []);++ const slowestEndpoints = endpoints.filter((r) => r.timed >= 3)++ .map((r) => ({ path: r.path, method: r.method, hits: r.timed, avg_ms: r.avg_ms, max_ms: r.max_ms })).sort(desc('avg_ms')).slice(0, 10);++ const authTrend = await safe(() => all(`SELECT b AS bucket, COUNT(session_id) FILTER (WHERE a = 1) AS auth_visitors, COUNT(session_id) FILTER (WHERE a = 0) AS anon_visitors,++ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS auth_hits, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anon_hits++ FROM (SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS b, authenticated AS a, session_id, COUNT(*) AS c++ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2, 3) s++ GROUP BY b ORDER BY b ASC`, human), []);return {service: svc, period_days: hours ? hours / 24 : days, period_hours: hours || null, summary: summary || {}, realtime: realtime || {},daily, hourly, timeBuckets, topPages, topReferers, botBreakdown, statusCodes, deviceBreakdown, browserBreakdown, osBreakdown,@@@ -420,7 +442,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {}/** ADR-021's raw-event retention on PostgreSQL: rows older than `days` go, in batches; the rollups stay. */--async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 } = {}) {++async function pruneRawEventsPg(db, { days = 30, botDays = days, now = Date.now, batch = 5000 } = {}) {const cutoff = sqlTime(now() - days * DAY);let removed = 0;for (;;) {@@@ -428,6 +450,16 @@ async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 }removed += n;if (n < batch) break;}++ // Bot rows can go sooner (botDays): they are in the hourly and daily rollups once their day closes, and are half or++ // more of the raw table (Live 1.49M of 2.47M rows on 2026-10-22, Network 1.22M of 2.46M on 10-23).++ if (botDays < days) {++ const botCutoff = sqlTime(now() - botDays * DAY);++ for (;;) {++ const n = await db.exec(`DELETE FROM analytics_events WHERE id IN (SELECT id FROM analytics_events WHERE is_bot = 1 AND created_at < $1 LIMIT ${batch})`, [botCutoff]);++ removed += n;++ if (n < batch) break;++ }++ }const today = dayOf(now());await db.exec('DELETE FROM analytics_visitor_days WHERE day < $1', [dayOf(now() - DAY)]);await db.exec('DELETE FROM analytics_day_salts WHERE day < $1', [today]);@@@ -68,6 +68,7 @@})());const NETWORK_URL = 'https://openvibe.network';++ const REPORT_URL = 'https://openvibe.help/tickets/new';const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));// One place that knows what the network contains. Adding a property here gives every site@@@ -95,6 +96,17 @@{ key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },];++ // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81).++ // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on++ // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts).++ function reportHref() {++ try {++ if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL;++ return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`;++ } catch { return REPORT_URL; }++ }++ const reportLink = () => `<a href="${esc(reportHref())}" data-ov-report>Report a problem</a>`;++const SOCIAL = [{ name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },@@@ -223,6 +235,7 @@<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}++ ${reportLink()}</nav>${shippedLine(c)}<span class="ovf-compact-meta">Open source · ${year}</span>@@@ -259,6 +272,7 @@<nav class="ovf-col" aria-label="Legal"><h3>Legal</h3>${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}++ ${reportLink()}</nav></div><div class="ovf-account" id="ovf-account" hidden></div>@@@ -393,6 +407,12 @@_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');_mounted.setAttribute('data-variant', _cfg.variant);_mounted.innerHTML = buildHTML(_cfg);++ if (!_mounted._ovReport) {++ _mounted._ovReport = true;++ const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };++ _mounted.addEventListener('click', fresh);++ _mounted.addEventListener('auxclick', fresh);++ }try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }fillAccount(_cfg);if (_cfg.shipped !== false) loadShipped();@@@ -24,9 +24,9 @@.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}.ov-island[hidden]{display:none}.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}--.ov-island{--ovis-c:var(--accent,#3b82f6)}--.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}--.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ov-island{--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}++.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}++.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}@@@ -34,9 +34,9 @@.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}--.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}--.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}--.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}++.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}++.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}@@@ -48,7 +48,7 @@.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}--.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}++.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-t,var(--ovis-c));background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}@@@ -253,6 +253,72 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;.name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }}++/* 2fat Bass Drop (2026-10-23, one of a kind: 2fat, who brings the old classic bangers back): the name thumps on the beat,++ a big drop then a smaller one, in club lights, with an equalizer dancing before it and a speaker after it. Only its++ one holder wears it (fx_twofat is never in the catalog). The equalizer and the speaker are pseudo-elements, so they++ carry their own fill and motion rules (the Calm setting and the system preference). */++.name-fx-twofat {++ background: linear-gradient(90deg, #ff2fd6 0%, #a855ff 25%, #3de0ff 50%, #a855ff 75%, #ff2fd6 100%);++ background-size: 200% 100%;++ -webkit-background-clip: text;++ background-clip: text;++ -webkit-text-fill-color: transparent;++ display: inline-block;++ font-weight: 900;++ letter-spacing: .03em;++ white-space: nowrap;++ transform-origin: 50% 70%;++ animation: nameFxTwofatLights 3s linear infinite, nameFxTwofatThump 1s ease-out infinite;++}++.name-fx-twofat::before {++ content: '▂▅▇▃▆';++ display: inline-block;++ margin-right: .3em;++ color: #3de0ff;++ -webkit-text-fill-color: #3de0ff;++ text-shadow: 0 0 4px rgba(61, 224, 255, .8);++ font-size: .6em;++ letter-spacing: .12em;++ vertical-align: .1em;++ animation: nameFxTwofatEq .5s steps(1, end) infinite;++}++.name-fx-twofat::after {++ content: '🔊';++ display: inline-block;++ margin-left: .3em;++ -webkit-text-fill-color: initial;++ font-size: .85em;++ animation: nameFxTwofatSpeaker 1s ease-out infinite;++}++@keyframes nameFxTwofatLights {++ 0% { background-position: 0% 50%; }++ 100% { background-position: 200% 50%; }++}++/* The beat: a big thump, then a smaller one, the glow flaring pink, then blue. */++@keyframes nameFxTwofatThump {++ 0% { transform: scale(1.12); filter: drop-shadow(0 0 6px rgba(255, 47, 214, .95)); }++ 18% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }++ 50% { transform: scale(1.05); filter: drop-shadow(0 0 4px rgba(61, 224, 255, .85)); }++ 68%, 100% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }++}++/* The equalizer jumps four times a beat (a browser that cannot animate `content` shows the first frame). */++@keyframes nameFxTwofatEq {++ 0% { content: '▂▅▇▃▆'; }++ 25% { content: '▅▃▆▇▂'; }++ 50% { content: '▇▆▂▅▃'; }++ 75% { content: '▃▇▅▂▆'; }++}++@keyframes nameFxTwofatSpeaker {++ 0% { transform: scale(1.25); }++ 25%, 100% { transform: scale(1); }++}++html[data-ov-motion=reduced] .name-fx-twofat, html[data-ov-motion=reduced] .name-fx-twofat::before,++html[data-ov-motion=reduced] .name-fx-twofat::after { animation: none !important; }++@media (prefers-reduced-motion: reduce) {++ .name-fx-twofat, .name-fx-twofat::before, .name-fx-twofat::after { animation: none !important; }++ .name-fx-twofat { filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9)); }++}++/* Community badges (network.badge.image@1): the reviewed image, a little larger than the text, just before the name. */.ov-badge { display: inline-block; width: 1.15em; height: 1.15em; margin-right: 3px; vertical-align: -0.2em; border-radius: 3px; object-fit: contain; }@@@ -283,6 +349,7 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;.px-stars .ov-px-p { color: #fbbf24; }.px-void .ov-px-p { color: #9900ff; }.px-vegas .ov-px-p { color: #ffcf3f; text-shadow: 0 0 4px rgba(255, 61, 127, .75); }++.px-twofat .ov-px-p { color: #ff2fd6; text-shadow: 0 0 4px rgba(61, 224, 255, .8); }@media (prefers-reduced-motion: reduce) {.ov-fx[class*="name-fx-"], .ov-hat { animation: none !important; }@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.9.0",++ "version": "4.10.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.9.0",++ "version": "4.10.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.9.0",++ "version": "4.10.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -43,7 +43,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}--.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}++.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}@@@ -213,7 +213,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}--.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}++.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}@@@ -40,6 +40,33 @@ const res = (status = 200) => ({ statusCode: status });assert.ok(stats.timeBuckets.length >= 1 && /^2026-09-28 12:\d+$/.test(stats.timeBuckets[0].bucket), JSON.stringify(stats.timeBuckets[0]));assert.strictEqual(stats.responsePercentiles.max, 40);assert.ok(Array.isArray(stats.peakHours) && stats.peakHours[0].hour_of_day === 12);++ // The reads that share a scan (4.10.0) give each list its own numbers: a session that signs in counts once in the++ // session total and once on each side of the signed-in split; no session id counts no visitor; bots stay out.++ const ev = (path, type, session, auth, status, ms, extra = {}) => db.query(`INSERT INTO analytics_events (service, event_type, path, method,++ status_code, response_time_ms, session_id, authenticated, is_bot, bot_type, device_type, created_at) VALUES ('probe', $1, $2, 'GET', $3, $4, $5, $6, $7, $8, $9, '2026-09-28 11:30:00')`,++ [type, path, status, ms, session, auth, extra.bot ? 1 : 0, extra.botType || null, extra.device || 'desktop']);++ await ev('/a', 'pageview', 's1', 0, 200, 10);++ await ev('/a', 'pageview', 's1', 1, 200, 20);++ await ev('/a', 'pageview', 's2', 0, 404, 30);++ await ev('/api/x', 'api_call', 's2', 0, 500, 40, { device: 'mobile' });++ await ev('/api/x', 'api_call', 's3', 1, 200, 50);++ await ev('/api/x', 'api_call', null, 0, 200, null);++ await ev('/a', 'pageview', 'b1', 0, 200, 5, { bot: true, botType: 'googlebot' });++ await ev('/robots.txt', 'pageview', 'b2', 0, 301, 5, { bot: true });++ const p = await a.getStats({ days: 7, service: 'probe' });++ const byKey = (rows, k) => Object.fromEntries(rows.map((r) => [r[k], r]));++ assert.deepStrictEqual(p.topPages.map((r) => [r.path, r.hits, r.visitors]).sort(), [['/a', 3, 2], ['/api/x', 3, 2]]);++ assert.deepStrictEqual(Object.fromEntries(p.statusCodes.map((r) => [r.group_code, r.cnt])), { '2xx': 5, '3xx': 1, '4xx': 1, '5xx': 1 });++ assert.deepStrictEqual(p.botBreakdown.map((r) => [r.bot_type, r.hits]).sort(), [[null, 1], ['googlebot', 1]]);++ assert.deepStrictEqual(p.deviceBreakdown, [{ device_type: 'desktop', cnt: 5 }, { device_type: 'mobile', cnt: 1 }]);++ assert.deepStrictEqual(p.authBreakdown, { authenticated: 2, anonymous: 4, unique_authenticated_users: 2, total_sessions: 3 });++ assert.strictEqual(p.sessionCount, 3);++ assert.deepStrictEqual(p.bandwidth, { total_requests: 6, page_requests: 3, api_requests: 3, estimated_bytes: 156000 });++ assert.deepStrictEqual(p.topApiEndpoints, [{ path: '/api/x', method: 'GET', hits: 3, avg_ms: 45, errors: 1 }]);++ assert.deepStrictEqual(p.slowestEndpoints, [{ path: '/a', method: 'GET', hits: 3, avg_ms: 20, max_ms: 30 }], 'two timed calls are too few to rank');++ assert.deepStrictEqual(p.peakHours, [{ hour_of_day: 11, hits: 6, visitors: 3, avg_ms: 30 }]);++ assert.deepStrictEqual(byKey(p.authTrend, 'bucket')['2026-09-28'], { bucket: '2026-09-28', auth_visitors: 2, anon_visitors: 2, auth_hits: 2, anon_hits: 4 });++ await db.query("DELETE FROM analytics_events WHERE service = 'probe'");const overview = await a.getOverview(7);assert.ok(Array.isArray(overview.services));const bots = await a.getBotAnalysis(7);@@@ -49,6 +76,13 @@ const res = (status = 200) => ({ statusCode: status });now = T0 + 86400000;await a.aggregate();assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM analytics_visitor_days')), 0);++ // botDays: a bot row goes after botDays, a person's after days.++ const ins = (isBot, at) => db.query(`INSERT INTO analytics_events (service, event_type, path, is_bot, created_at) VALUES ('botdays', 'pageview', '/', $1, $2)`, [isBot, at]);++ await ins(1, '2026-09-29 12:00:00'); await ins(0, '2026-09-29 12:00:00'); await ins(1, '2026-10-06 12:00:00');++ const bp = await pruneRawEventsPg(db, { days: 30, botDays: 7, now: () => Date.parse('2026-10-07T00:00:00Z') });++ assert.strictEqual(bp.removed, 1, 'only the bot row older than 7 days');++ assert.strictEqual(Number(await db.value("SELECT COUNT(*) FROM analytics_events WHERE service = 'botdays'")), 2);++ await db.query("DELETE FROM analytics_events WHERE service = 'botdays'");now = T0 + 31 * 86400000;const r = await pruneRawEventsPg(db, { days: 30, now: () => now });assert.strictEqual(r.removed, 3);@@@ -93,11 +93,12 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;assert.strictEqual(await items.decorate(d2), 1);assert.deepStrictEqual([...d2.getElementById('x').classList], []);-- // The stylesheet: Live's 14 name effects (13 and the one-of-a-kind Josh Vegas), an inline-block wrapper, and reduced motion.++ // The stylesheet: Live's 15 name effects (13 and the one-of-a-kind Josh Vegas and 2fat Bass Drop), an inline-block wrapper, and reduced motion.const css = fs.readFileSync(path.join(__dirname, '..', 'items.css'), 'utf8');const fx = [...css.matchAll(/^\.(name-fx-[a-z]+) \{/gm)].map((m) => m[1]);-- assert.strictEqual(fx.length, 14, `14 name effects (${fx.join(', ')})`);-- assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas'));++ assert.strictEqual(fx.length, 15, `15 name effects (${fx.join(', ')})`);++ assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas') && fx.includes('name-fx-twofat'));++ assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-twofat::after \{ animation: none !important; \}/, 'the Calm setting stops the bass drop');assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-vegas::after \{ animation: none !important; \}/, 'the Calm setting stops the reels');assert.match(css, /\.ov-fx \{ display: inline-block; \}/);assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);@@@ -69,6 +69,18 @@ console.log('shared modules: all checks passed');assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');++ // "Report a problem" (4.10.0): a ticket on openvibe.help naming this site and page, the page without its query or++ // fragment (a sign-in code or a token can ride there); server-rendered, the plain form.++ assert.ok(html.includes('data-ov-report>Report a problem</a>'), 'the full footer has Report a problem');++ assert.ok(own.includes('data-ov-report>Report a problem</a>'), 'the compact footer has it too');++ const rp = vm.createContext({ console, URLSearchParams }); rp.globalThis = rp; rp.window = rp; rp.localStorage = { getItem: () => null, setItem() {} };++ rp.location = { protocol: 'https:', hostname: 'www.openvibe.live', origin: 'https://www.openvibe.live', pathname: '/@kim', href: 'https://www.openvibe.live/@kim?code=SECRET#t=1' };++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), rp);++ const rh = rp.OpenVibeFooter.buildHTML({ service: 'live', variant: 'compact' });++ assert.ok(rh.includes(`href="https://openvibe.help/tickets/new?site=openvibe.live&page=${encodeURIComponent('https://www.openvibe.live/@kim')}"`), rh.match(/href="[^"]*tickets\/new[^"]*"/));++ assert.ok(!rh.includes('SECRET'), 'the query never reaches the link');++ const srv = require('../footer.js').ssr({ service: 'live', variant: 'full' });++ assert.ok(srv.includes('href="https://openvibe.help/tickets/new" data-ov-report'), 'server-rendered: the plain form');for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);
@@ -4,6 +4,34 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,34 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.10.0 — 2026-10-24
+
+- **items.css draws `name-fx-twofat`, "2fat Bass Drop"**, the one-of-a-kind name effect the owner asked for 2fat on
+ 2026-10-23: the name thumps on the beat in club lights, with an equalizer before it and a speaker after it. Its
+ particles (`px-twofat`) are notes, a CD and a tape in club pink. Like Josh Vegas, the Calm setting
+ (`data-ov-motion=reduced`) and the system's reduced motion stop it, so it shows the same on openvibe.chat, Network's
+ pages and every site drawing items as on Live (Live#318, OpenVibe.Inventory#71).
+- **Shared's own status text uses the 4.9.0 twins**: the showcase's "yes" cells and the island's action buttons
+ (`--ovis-t`: `--success-text`/`--danger-text` by state, the accent otherwise; the status colour stays their fill and
+ tint). Shared now writes no status colour as text without its twin (H4's last uses; the 23 sites moved on 10-22/23).
+- **"Report a problem" in every site's footer** (the Legal column, and the compact line). It opens a ticket on
+ openvibe.help with the site and the page filled in (OpenVibe.Help#81):
+ `https://openvibe.help/tickets/new?site=<host without www.>&page=<origin + path>`.
+ - The page leaves out the query and the fragment, where a sign-in code or a token can ride.
+ - A server-rendered footer links the plain form, and a click fills in the page at that moment, so a single-page
+ app's current route is the one reported.
+- **`analytics/pg` keeps bot rows shorter when asked**: `pruneRawEventsPg(db, { days, botDays })` and the tracker's
+ `retention: { days, botDays }` delete `is_bot` rows after `botDays` (default `days`, so nothing changes until a
+ service sets it). Bot rows are in the hourly and daily rollups once their day closes, and were half or more of the raw
+ table: Live 1.49M of 2.47M rows (it prunes them at 7 days itself since 10-22), Network 1.22M of 2.46M (991 MB).
+- **`analytics/pg` `getStats` reads the raw range 9 times instead of 17** (plan J3). On Live's production data
+ (1.3M rows), 30 days took 8 to 12 s and now 2.8 s; 7 days 3.1 s → 1.5 s. Related reads share one grouped scan,
+ split in JS: status codes and bot types; device, browser, OS and country; the busiest API calls and the slowest paths;
+ and requests, the signed-in split and sessions. Each distinct count per group (visitors per page, per hour of the day,
+ per day signed in or not) groups by session first instead of sorting for `COUNT(DISTINCT)`. The numbers and response
+ shapes are unchanged: the old and new reads matched on production inside one snapshot, and
+ `test/analytics-pg.test.js` checks each list on known rows.
+
## 4.9.0 — 2026-10-23
## 4.9.0 — 2026-10-23
- **Status colours that read as text** (plan 2026-10-22, track H4). After 4.8.0, axe in the light themes still failed
- **Status colours that read as text** (plan 2026-10-22, track H4). After 4.8.0, axe in the light themes still failed
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.7.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.10.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.8.0",
+ "version": "4.10.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -90,7 +90,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
@@ -90,7 +90,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
/**
/**
* @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)
* @param {object} db openvibe-sdk/db handle (the service's own database, analyticsSchema() migrated)
* @param {string} service
* @param {string} service
- * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, intervalMs,
+ * @param {object} [opts] paramPrefixes, pathRules, retention ({ days = 30, botDays = days, intervalMs,
* initialDelayMs } or false), timers, now
* initialDelayMs } or false), timers, now
*/
*/
constructor(db, service, opts = {}) {
constructor(db, service, opts = {}) {
@@ -108,7 +108,8 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
@@ -108,7 +108,8 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
if (opts.retention !== false) {
if (opts.retention !== false) {
const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};
const r = opts.retention && typeof opts.retention === 'object' ? opts.retention : {};
const days = r.days || 30;
const days = r.days || 30;
- const run = () => { pruneRawEventsPg(db, { days, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };
+ const botDays = r.botDays || days;
+ const run = () => { pruneRawEventsPg(db, { days, botDays, now: this._now }).catch((err) => console.error(`[Analytics:${service}] prune error:`, err.message)); };
once(run, r.initialDelayMs == null ? 5 * 60e3 : r.initialDelayMs);
once(run, r.initialDelayMs == null ? 5 * 60e3 : r.initialDelayMs);
every(run, r.intervalMs || DAY);
every(run, r.intervalMs || DAY);
}
}
@@ -319,22 +320,33 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
@@ -319,22 +320,33 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
FROM analytics_daily WHERE service = $1 AND date >= $2`, [svc, dayOf(nowMs - rangeMs)]);
FROM analytics_daily WHERE service = $1 AND date >= $2`, [svc, dayOf(nowMs - rangeMs)]);
const realtime = await get(`SELECT COUNT(*) AS requests, COUNT(DISTINCT session_id) AS visitors, COUNT(*) FILTER (WHERE is_bot = 1) AS bots
const realtime = await get(`SELECT COUNT(*) AS requests, COUNT(DISTINCT session_id) AS visitors, COUNT(*) FILTER (WHERE is_bot = 1) AS bots
FROM analytics_events WHERE service = $1 AND created_at >= $2`, [svc, sqlTime(nowMs - 300000)]);
FROM analytics_events WHERE service = $1 AND created_at >= $2`, [svc, sqlTime(nowMs - 300000)]);
- const topPages = await all(`SELECT path, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors FROM analytics_events
- WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path ORDER BY hits DESC LIMIT 20`, human);
+ // Each raw read below scans the range of analytics_events, so related ones share a scan (9 instead of 17: Live's
+ // 30 days of 1.3M rows took several seconds, plan J3). A grouped read is split here, and a distinct count per
+ // group is two GROUP BYs (each session once per group, then counted), which hash instead of sorting. The
+ // numbers and shapes are the ones the separate reads gave.
+ const desc = (key) => (x, y) => y[key] - x[key];
+ const tally = (rows, col, n, out = 'cnt') => {
+ const m = new Map();
+ for (const r of rows) m.set(r[col], (m.get(r[col]) || 0) + r[n]);
+ return [...m].map(([k, v]) => ({ [col]: k, [out]: v })).sort(desc(out));
+ };
+ const topPages = await all(`SELECT path, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors FROM (
+ SELECT path, session_id, COUNT(*) AS c FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
+ GROUP BY path, session_id) s
+ GROUP BY path ORDER BY hits DESC LIMIT 20`, human);
const topReferers = await all(`SELECT referer, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2
const topReferers = await all(`SELECT referer, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2
AND referer IS NOT NULL AND referer != '' AND is_bot = 0 GROUP BY referer ORDER BY hits DESC LIMIT 15`, human);
AND referer IS NOT NULL AND referer != '' AND is_bot = 0 GROUP BY referer ORDER BY hits DESC LIMIT 15`, human);
- const botBreakdown = await all(`SELECT bot_type, COUNT(*) AS hits FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 1
- GROUP BY bot_type ORDER BY hits DESC LIMIT 15`, human);
- const statusCodes = await all(`SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
+ const statusRows = await all(`SELECT is_bot, bot_type, CASE WHEN status_code >= 200 AND status_code < 300 THEN '2xx' WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
WHEN status_code >= 400 AND status_code < 500 THEN '4xx' WHEN status_code >= 500 THEN '5xx' ELSE 'other' END AS group_code, COUNT(*) AS cnt
WHEN status_code >= 400 AND status_code < 500 THEN '4xx' WHEN status_code >= 500 THEN '5xx' ELSE 'other' END AS group_code, COUNT(*) AS cnt
- FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY group_code ORDER BY cnt DESC`, human);
- const breakdown = (col) => all(`SELECT ${col}, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
- GROUP BY ${col} ORDER BY cnt DESC`, human);
- const deviceBreakdown = await breakdown('device_type');
- const browserBreakdown = await breakdown('browser');
- const osBreakdown = await breakdown('os');
- const countryBreakdown = await all(`SELECT country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2
- AND country IS NOT NULL AND is_bot = 0 GROUP BY country ORDER BY cnt DESC LIMIT 20`, human);
+ FROM analytics_events WHERE service = $1 AND created_at >= $2 GROUP BY 1, 2, 3`, human);
+ const botBreakdown = tally(statusRows.filter((r) => r.is_bot === 1), 'bot_type', 'cnt', 'hits').slice(0, 15);
+ const statusCodes = tally(statusRows, 'group_code', 'cnt');
+ const dims = await all(`SELECT device_type, browser, os, country, COUNT(*) AS cnt FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0
+ GROUP BY 1, 2, 3, 4`, human);
+ const deviceBreakdown = tally(dims, 'device_type', 'cnt');
+ const browserBreakdown = tally(dims, 'browser', 'cnt');
+ const osBreakdown = tally(dims, 'os', 'cnt');
+ const countryBreakdown = tally(dims.filter((r) => r.country != null), 'country', 'cnt').slice(0, 20);
const responsePercentiles = await safe(async () => {
const responsePercentiles = await safe(async () => {
const r = await get(`SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS p50, percentile_disc(0.9) WITHIN GROUP (ORDER BY response_time_ms) AS p90,
const r = await get(`SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS p50, percentile_disc(0.9) WITHIN GROUP (ORDER BY response_time_ms) AS p90,
percentile_disc(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95, percentile_disc(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS p99,
percentile_disc(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS p95, percentile_disc(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS p99,
@@ -342,36 +354,46 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
@@ -342,36 +354,46 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0`, human);
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0`, human);
return r && r.max != null ? r : {};
return r && r.max != null ? r : {};
}, {});
}, {});
- const bandwidth = await safe(async () => {
- const bw = await get(`SELECT COUNT(*) AS total_requests, COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
- COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests, SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END)::bigint AS estimated_bytes
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human);
- return bw && { total_requests: bw.total_requests || 0, page_requests: bw.page_requests || 0, api_requests: bw.api_requests || 0, estimated_bytes: bw.estimated_bytes || 0 };
- }, {});
- const authBreakdown = await safe(() => get(`SELECT COUNT(*) FILTER (WHERE authenticated = 1) AS authenticated, COUNT(*) FILTER (WHERE authenticated = 0) AS anonymous,
- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0`, human), {});
+ // Requests, signed-in share and sessions: one scan (a session can be anonymous and then signed in, so the
+ // session total counts distinct ids over the per-session rows).
+ const totals = await safe(() => get(`SELECT SUM(c)::bigint AS total_requests, SUM(pv)::bigint AS page_requests, SUM(api)::bigint AS api_requests,
+ SUM(pv * 50000 + (c - pv) * 2000)::bigint AS estimated_bytes,
+ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS authenticated, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anonymous,
+ COUNT(session_id) FILTER (WHERE a = 1) AS unique_authenticated_users, COUNT(DISTINCT session_id) AS total_sessions
+ FROM (SELECT authenticated AS a, session_id, COUNT(*) AS c, COUNT(*) FILTER (WHERE event_type = 'pageview') AS pv,
+ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api
+ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s`, human), null);
+ const bandwidth = totals ? { total_requests: totals.total_requests || 0, page_requests: totals.page_requests || 0, api_requests: totals.api_requests || 0,
+ estimated_bytes: totals.estimated_bytes || 0 } : {};
+ const authBreakdown = totals ? { authenticated: totals.authenticated, anonymous: totals.anonymous,
+ unique_authenticated_users: totals.unique_authenticated_users, total_sessions: totals.total_sessions } : {};
+ const sessionCount = totals ? totals.total_sessions || 0 : 0;
const errorTrend = await safe(() => (isSubDay
const errorTrend = await safe(() => (isSubDay
? all(`SELECT ${bucketExpr} AS bucket, COUNT(*) AS total, COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
? all(`SELECT ${bucketExpr} AS bucket, COUNT(*) AS total, COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human)
FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human)
: all('SELECT hour AS bucket, (pageviews + api_calls) AS total, error_count AS errors FROM analytics_hourly WHERE service = $1 AND hour >= $2 ORDER BY hour ASC', [svc, hourlyCutoff])), []);
: all('SELECT hour AS bucket, (pageviews + api_calls) AS total, error_count AS errors FROM analytics_hourly WHERE service = $1 AND hour >= $2 ORDER BY hour ASC', [svc, hourlyCutoff])), []);
- const peakHours = await safe(() => all(`SELECT substr(created_at, 12, 2)::int AS hour_of_day, COUNT(*) AS hits, COUNT(DISTINCT session_id) AS visitors,
- CAST(AVG(response_time_ms) AS integer) AS avg_ms
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY hour_of_day ORDER BY hour_of_day ASC`, human), []);
- const topApiEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms,
- COUNT(*) FILTER (WHERE status_code >= 400) AS errors
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND event_type = 'api_call' AND is_bot = 0 GROUP BY path, method ORDER BY hits DESC LIMIT 20`, human), []);
- const sessionCount = await safe(() => this.db.value(`SELECT COUNT(DISTINCT session_id) FROM analytics_events WHERE service = $1 AND created_at >= $2
- AND session_id IS NOT NULL AND is_bot = 0`, human), 0);
+ const peakHours = await safe(() => all(`SELECT h AS hour_of_day, SUM(c)::bigint AS hits, COUNT(session_id) AS visitors,
+ CAST(SUM(rt)::numeric / NULLIF(SUM(rn), 0) AS integer) AS avg_ms
+ FROM (SELECT substr(created_at, 12, 2)::int AS h, session_id, COUNT(*) AS c, SUM(response_time_ms) AS rt, COUNT(response_time_ms) AS rn
+ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2) s
+ GROUP BY h ORDER BY h ASC`, human), []);
+ // Endpoints: the busiest API calls and the slowest paths (3+ timed requests) from one grouped read.
+ const endpoints = await safe(() => all(`SELECT path, method, COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_hits,
+ CAST(AVG(response_time_ms) FILTER (WHERE event_type = 'api_call') AS integer) AS api_avg_ms,
+ COUNT(*) FILTER (WHERE event_type = 'api_call' AND status_code >= 400) AS api_errors,
+ COUNT(response_time_ms) AS timed, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms
+ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY path, method`, human), []);
+ const topApiEndpoints = endpoints.filter((r) => r.api_hits > 0)
+ .map((r) => ({ path: r.path, method: r.method, hits: r.api_hits, avg_ms: r.api_avg_ms, errors: r.api_errors })).sort(desc('hits')).slice(0, 20);
const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };
const visitorTypes = { new_visitors: null, returning_visitors: null, not_collected: 'ADR-021' };
- const slowestEndpoints = await safe(() => all(`SELECT path, method, COUNT(*) AS hits, CAST(AVG(response_time_ms) AS integer) AS avg_ms, MAX(response_time_ms) AS max_ms
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND response_time_ms IS NOT NULL AND is_bot = 0
- GROUP BY path, method HAVING COUNT(*) >= 3 ORDER BY avg_ms DESC LIMIT 10`, human), []);
- const authTrend = await safe(() => all(`SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS bucket,
- COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 1) AS auth_visitors, COUNT(DISTINCT session_id) FILTER (WHERE authenticated = 0) AS anon_visitors,
- COUNT(*) FILTER (WHERE authenticated = 1) AS auth_hits, COUNT(*) FILTER (WHERE authenticated = 0) AS anon_hits
- FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY bucket ORDER BY bucket ASC`, human), []);
+ const slowestEndpoints = endpoints.filter((r) => r.timed >= 3)
+ .map((r) => ({ path: r.path, method: r.method, hits: r.timed, avg_ms: r.avg_ms, max_ms: r.max_ms })).sort(desc('avg_ms')).slice(0, 10);
+ const authTrend = await safe(() => all(`SELECT b AS bucket, COUNT(session_id) FILTER (WHERE a = 1) AS auth_visitors, COUNT(session_id) FILTER (WHERE a = 0) AS anon_visitors,
+ COALESCE(SUM(c) FILTER (WHERE a = 1), 0)::bigint AS auth_hits, COALESCE(SUM(c) FILTER (WHERE a = 0), 0)::bigint AS anon_hits
+ FROM (SELECT ${isSubDay ? bucketExpr : 'substr(created_at, 1, 10)'} AS b, authenticated AS a, session_id, COUNT(*) AS c
+ FROM analytics_events WHERE service = $1 AND created_at >= $2 AND is_bot = 0 GROUP BY 1, 2, 3) s
+ GROUP BY b ORDER BY b ASC`, human), []);
return {
return {
service: svc, period_days: hours ? hours / 24 : days, period_hours: hours || null, summary: summary || {}, realtime: realtime || {},
service: svc, period_days: hours ? hours / 24 : days, period_hours: hours || null, summary: summary || {}, realtime: realtime || {},
daily, hourly, timeBuckets, topPages, topReferers, botBreakdown, statusCodes, deviceBreakdown, browserBreakdown, osBreakdown,
daily, hourly, timeBuckets, topPages, topReferers, botBreakdown, statusCodes, deviceBreakdown, browserBreakdown, osBreakdown,
@@ -420,7 +442,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
@@ -420,7 +442,7 @@ class AnalyticsTrackerPg extends AnalyticsTrackerCore {
}
}
/** ADR-021's raw-event retention on PostgreSQL: rows older than `days` go, in batches; the rollups stay. */
/** ADR-021's raw-event retention on PostgreSQL: rows older than `days` go, in batches; the rollups stay. */
-async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 } = {}) {
+async function pruneRawEventsPg(db, { days = 30, botDays = days, now = Date.now, batch = 5000 } = {}) {
const cutoff = sqlTime(now() - days * DAY);
const cutoff = sqlTime(now() - days * DAY);
let removed = 0;
let removed = 0;
for (;;) {
for (;;) {
@@ -428,6 +450,16 @@ async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 }
@@ -428,6 +450,16 @@ async function pruneRawEventsPg(db, { days = 30, now = Date.now, batch = 5000 }
removed += n;
removed += n;
if (n < batch) break;
if (n < batch) break;
}
}
+ // Bot rows can go sooner (botDays): they are in the hourly and daily rollups once their day closes, and are half or
+ // more of the raw table (Live 1.49M of 2.47M rows on 2026-10-22, Network 1.22M of 2.46M on 10-23).
+ if (botDays < days) {
+ const botCutoff = sqlTime(now() - botDays * DAY);
+ for (;;) {
+ const n = await db.exec(`DELETE FROM analytics_events WHERE id IN (SELECT id FROM analytics_events WHERE is_bot = 1 AND created_at < $1 LIMIT ${batch})`, [botCutoff]);
+ removed += n;
+ if (n < batch) break;
+ }
+ }
const today = dayOf(now());
const today = dayOf(now());
await db.exec('DELETE FROM analytics_visitor_days WHERE day < $1', [dayOf(now() - DAY)]);
await db.exec('DELETE FROM analytics_visitor_days WHERE day < $1', [dayOf(now() - DAY)]);
await db.exec('DELETE FROM analytics_day_salts WHERE day < $1', [today]);
await db.exec('DELETE FROM analytics_day_salts WHERE day < $1', [today]);
@@ -68,6 +68,7 @@
@@ -68,6 +68,7 @@
})());
})());
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
+ const REPORT_URL = 'https://openvibe.help/tickets/new';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
// One place that knows what the network contains. Adding a property here gives every site
// One place that knows what the network contains. Adding a property here gives every site
@@ -95,6 +96,17 @@
@@ -95,6 +96,17 @@
{ key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
{ key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
];
+ // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81).
+ // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on
+ // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts).
+ function reportHref() {
+ try {
+ if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL;
+ return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`;
+ } catch { return REPORT_URL; }
+ }
+ const reportLink = () => `<a href="${esc(reportHref())}" data-ov-report>Report a problem</a>`;
+
const SOCIAL = [
const SOCIAL = [
{ name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
{ name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },
{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },
@@ -223,6 +235,7 @@
@@ -223,6 +235,7 @@
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
+ ${reportLink()}
</nav>
</nav>
${shippedLine(c)}
${shippedLine(c)}
<span class="ovf-compact-meta">Open source · ${year}</span>
<span class="ovf-compact-meta">Open source · ${year}</span>
@@ -259,6 +272,7 @@
@@ -259,6 +272,7 @@
<nav class="ovf-col" aria-label="Legal">
<nav class="ovf-col" aria-label="Legal">
<h3>Legal</h3>
<h3>Legal</h3>
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
+ ${reportLink()}
</nav>
</nav>
</div>
</div>
<div class="ovf-account" id="ovf-account" hidden></div>
<div class="ovf-account" id="ovf-account" hidden></div>
@@ -393,6 +407,12 @@
@@ -393,6 +407,12 @@
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
_mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
_mounted.setAttribute('data-variant', _cfg.variant);
_mounted.setAttribute('data-variant', _cfg.variant);
_mounted.innerHTML = buildHTML(_cfg);
_mounted.innerHTML = buildHTML(_cfg);
+ if (!_mounted._ovReport) {
+ _mounted._ovReport = true;
+ const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); };
+ _mounted.addEventListener('click', fresh);
+ _mounted.addEventListener('auxclick', fresh);
+ }
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
fillAccount(_cfg);
fillAccount(_cfg);
if (_cfg.shipped !== false) loadShipped();
if (_cfg.shipped !== false) loadShipped();
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
.ov-island[hidden]{display:none}
.ov-island[hidden]{display:none}
.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
-.ov-island{--ovis-c:var(--accent,#3b82f6)}
-.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}
-.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ov-island{--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
+.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
+.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
@@ -34,9 +34,9 @@
@@ -34,9 +34,9 @@
.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
-.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}
-.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}
-.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6);--ovis-t:var(--ovis-c)}
+.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e);--ovis-t:var(--success-text,var(--success,#22c55e))}
+.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444);--ovis-t:var(--danger-text,var(--danger,#ef4444))}
.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
-.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
+.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-t,var(--ovis-c));background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
@@ -253,6 +253,72 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
@@ -253,6 +253,72 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
.name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
.name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
}
}
+/* 2fat Bass Drop (2026-10-23, one of a kind: 2fat, who brings the old classic bangers back): the name thumps on the beat,
+ a big drop then a smaller one, in club lights, with an equalizer dancing before it and a speaker after it. Only its
+ one holder wears it (fx_twofat is never in the catalog). The equalizer and the speaker are pseudo-elements, so they
+ carry their own fill and motion rules (the Calm setting and the system preference). */
+.name-fx-twofat {
+ background: linear-gradient(90deg, #ff2fd6 0%, #a855ff 25%, #3de0ff 50%, #a855ff 75%, #ff2fd6 100%);
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ display: inline-block;
+ font-weight: 900;
+ letter-spacing: .03em;
+ white-space: nowrap;
+ transform-origin: 50% 70%;
+ animation: nameFxTwofatLights 3s linear infinite, nameFxTwofatThump 1s ease-out infinite;
+}
+.name-fx-twofat::before {
+ content: '▂▅▇▃▆';
+ display: inline-block;
+ margin-right: .3em;
+ color: #3de0ff;
+ -webkit-text-fill-color: #3de0ff;
+ text-shadow: 0 0 4px rgba(61, 224, 255, .8);
+ font-size: .6em;
+ letter-spacing: .12em;
+ vertical-align: .1em;
+ animation: nameFxTwofatEq .5s steps(1, end) infinite;
+}
+.name-fx-twofat::after {
+ content: '🔊';
+ display: inline-block;
+ margin-left: .3em;
+ -webkit-text-fill-color: initial;
+ font-size: .85em;
+ animation: nameFxTwofatSpeaker 1s ease-out infinite;
+}
+@keyframes nameFxTwofatLights {
+ 0% { background-position: 0% 50%; }
+ 100% { background-position: 200% 50%; }
+}
+/* The beat: a big thump, then a smaller one, the glow flaring pink, then blue. */
+@keyframes nameFxTwofatThump {
+ 0% { transform: scale(1.12); filter: drop-shadow(0 0 6px rgba(255, 47, 214, .95)); }
+ 18% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }
+ 50% { transform: scale(1.05); filter: drop-shadow(0 0 4px rgba(61, 224, 255, .85)); }
+ 68%, 100% { transform: scale(1); filter: drop-shadow(0 0 2px rgba(168, 85, 255, .6)); }
+}
+/* The equalizer jumps four times a beat (a browser that cannot animate `content` shows the first frame). */
+@keyframes nameFxTwofatEq {
+ 0% { content: '▂▅▇▃▆'; }
+ 25% { content: '▅▃▆▇▂'; }
+ 50% { content: '▇▆▂▅▃'; }
+ 75% { content: '▃▇▅▂▆'; }
+}
+@keyframes nameFxTwofatSpeaker {
+ 0% { transform: scale(1.25); }
+ 25%, 100% { transform: scale(1); }
+}
+html[data-ov-motion=reduced] .name-fx-twofat, html[data-ov-motion=reduced] .name-fx-twofat::before,
+html[data-ov-motion=reduced] .name-fx-twofat::after { animation: none !important; }
+@media (prefers-reduced-motion: reduce) {
+ .name-fx-twofat, .name-fx-twofat::before, .name-fx-twofat::after { animation: none !important; }
+ .name-fx-twofat { filter: drop-shadow(0 0 3px rgba(255, 47, 214, .9)); }
+}
+
/* Community badges (network.badge.image@1): the reviewed image, a little larger than the text, just before the name. */
/* Community badges (network.badge.image@1): the reviewed image, a little larger than the text, just before the name. */
.ov-badge { display: inline-block; width: 1.15em; height: 1.15em; margin-right: 3px; vertical-align: -0.2em; border-radius: 3px; object-fit: contain; }
.ov-badge { display: inline-block; width: 1.15em; height: 1.15em; margin-right: 3px; vertical-align: -0.2em; border-radius: 3px; object-fit: contain; }
@@ -283,6 +349,7 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
@@ -283,6 +349,7 @@ html[data-ov-motion=reduced] .name-fx-vegas::after { animation: none !important;
.px-stars .ov-px-p { color: #fbbf24; }
.px-stars .ov-px-p { color: #fbbf24; }
.px-void .ov-px-p { color: #9900ff; }
.px-void .ov-px-p { color: #9900ff; }
.px-vegas .ov-px-p { color: #ffcf3f; text-shadow: 0 0 4px rgba(255, 61, 127, .75); }
.px-vegas .ov-px-p { color: #ffcf3f; text-shadow: 0 0 4px rgba(255, 61, 127, .75); }
+.px-twofat .ov-px-p { color: #ff2fd6; text-shadow: 0 0 4px rgba(61, 224, 255, .8); }
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.ov-fx[class*="name-fx-"], .ov-hat { animation: none !important; }
.ov-fx[class*="name-fx-"], .ov-hat { animation: none !important; }
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.9.0",
+ "version": "4.10.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.9.0",
+ "version": "4.10.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.9.0",
+ "version": "4.10.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -43,7 +43,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -43,7 +43,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
-.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
+.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
@@ -213,7 +213,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@@ -213,7 +213,7 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table thead th{font-size:12px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7386a3);background:var(--bg-secondary,#101828)}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table tbody th{font-weight:600;width:44%}.sc-table tr:last-child>*{border-bottom:0}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
.sc-table .sc-us{background:color-mix(in srgb,var(--accent,#3b82f6) 7%,transparent)}
-.sc-yes{color:var(--success,#22c55e);font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
+.sc-yes{color:var(--success-text,var(--success,#22c55e));font-weight:800}.sc-no{color:var(--text-muted,#7386a3)}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tiers{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier{display:flex;flex-direction:column;padding:18px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
.sc-tier.sc-hl{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 1px var(--accent,#3b82f6) inset}
@@ -40,6 +40,33 @@ const res = (status = 200) => ({ statusCode: status });
@@ -40,6 +40,33 @@ const res = (status = 200) => ({ statusCode: status });
assert.ok(stats.timeBuckets.length >= 1 && /^2026-09-28 12:\d+$/.test(stats.timeBuckets[0].bucket), JSON.stringify(stats.timeBuckets[0]));
assert.ok(stats.timeBuckets.length >= 1 && /^2026-09-28 12:\d+$/.test(stats.timeBuckets[0].bucket), JSON.stringify(stats.timeBuckets[0]));
assert.strictEqual(stats.responsePercentiles.max, 40);
assert.strictEqual(stats.responsePercentiles.max, 40);
assert.ok(Array.isArray(stats.peakHours) && stats.peakHours[0].hour_of_day === 12);
assert.ok(Array.isArray(stats.peakHours) && stats.peakHours[0].hour_of_day === 12);
+ // The reads that share a scan (4.10.0) give each list its own numbers: a session that signs in counts once in the
+ // session total and once on each side of the signed-in split; no session id counts no visitor; bots stay out.
+ const ev = (path, type, session, auth, status, ms, extra = {}) => db.query(`INSERT INTO analytics_events (service, event_type, path, method,
+ status_code, response_time_ms, session_id, authenticated, is_bot, bot_type, device_type, created_at) VALUES ('probe', $1, $2, 'GET', $3, $4, $5, $6, $7, $8, $9, '2026-09-28 11:30:00')`,
+ [type, path, status, ms, session, auth, extra.bot ? 1 : 0, extra.botType || null, extra.device || 'desktop']);
+ await ev('/a', 'pageview', 's1', 0, 200, 10);
+ await ev('/a', 'pageview', 's1', 1, 200, 20);
+ await ev('/a', 'pageview', 's2', 0, 404, 30);
+ await ev('/api/x', 'api_call', 's2', 0, 500, 40, { device: 'mobile' });
+ await ev('/api/x', 'api_call', 's3', 1, 200, 50);
+ await ev('/api/x', 'api_call', null, 0, 200, null);
+ await ev('/a', 'pageview', 'b1', 0, 200, 5, { bot: true, botType: 'googlebot' });
+ await ev('/robots.txt', 'pageview', 'b2', 0, 301, 5, { bot: true });
+ const p = await a.getStats({ days: 7, service: 'probe' });
+ const byKey = (rows, k) => Object.fromEntries(rows.map((r) => [r[k], r]));
+ assert.deepStrictEqual(p.topPages.map((r) => [r.path, r.hits, r.visitors]).sort(), [['/a', 3, 2], ['/api/x', 3, 2]]);
+ assert.deepStrictEqual(Object.fromEntries(p.statusCodes.map((r) => [r.group_code, r.cnt])), { '2xx': 5, '3xx': 1, '4xx': 1, '5xx': 1 });
+ assert.deepStrictEqual(p.botBreakdown.map((r) => [r.bot_type, r.hits]).sort(), [[null, 1], ['googlebot', 1]]);
+ assert.deepStrictEqual(p.deviceBreakdown, [{ device_type: 'desktop', cnt: 5 }, { device_type: 'mobile', cnt: 1 }]);
+ assert.deepStrictEqual(p.authBreakdown, { authenticated: 2, anonymous: 4, unique_authenticated_users: 2, total_sessions: 3 });
+ assert.strictEqual(p.sessionCount, 3);
+ assert.deepStrictEqual(p.bandwidth, { total_requests: 6, page_requests: 3, api_requests: 3, estimated_bytes: 156000 });
+ assert.deepStrictEqual(p.topApiEndpoints, [{ path: '/api/x', method: 'GET', hits: 3, avg_ms: 45, errors: 1 }]);
+ assert.deepStrictEqual(p.slowestEndpoints, [{ path: '/a', method: 'GET', hits: 3, avg_ms: 20, max_ms: 30 }], 'two timed calls are too few to rank');
+ assert.deepStrictEqual(p.peakHours, [{ hour_of_day: 11, hits: 6, visitors: 3, avg_ms: 30 }]);
+ assert.deepStrictEqual(byKey(p.authTrend, 'bucket')['2026-09-28'], { bucket: '2026-09-28', auth_visitors: 2, anon_visitors: 2, auth_hits: 2, anon_hits: 4 });
+ await db.query("DELETE FROM analytics_events WHERE service = 'probe'");
const overview = await a.getOverview(7);
const overview = await a.getOverview(7);
assert.ok(Array.isArray(overview.services));
assert.ok(Array.isArray(overview.services));
const bots = await a.getBotAnalysis(7);
const bots = await a.getBotAnalysis(7);
@@ -49,6 +76,13 @@ const res = (status = 200) => ({ statusCode: status });
@@ -49,6 +76,13 @@ const res = (status = 200) => ({ statusCode: status });
now = T0 + 86400000;
now = T0 + 86400000;
await a.aggregate();
await a.aggregate();
assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM analytics_visitor_days')), 0);
assert.strictEqual(Number(await db.value('SELECT COUNT(*) FROM analytics_visitor_days')), 0);
+ // botDays: a bot row goes after botDays, a person's after days.
+ const ins = (isBot, at) => db.query(`INSERT INTO analytics_events (service, event_type, path, is_bot, created_at) VALUES ('botdays', 'pageview', '/', $1, $2)`, [isBot, at]);
+ await ins(1, '2026-09-29 12:00:00'); await ins(0, '2026-09-29 12:00:00'); await ins(1, '2026-10-06 12:00:00');
+ const bp = await pruneRawEventsPg(db, { days: 30, botDays: 7, now: () => Date.parse('2026-10-07T00:00:00Z') });
+ assert.strictEqual(bp.removed, 1, 'only the bot row older than 7 days');
+ assert.strictEqual(Number(await db.value("SELECT COUNT(*) FROM analytics_events WHERE service = 'botdays'")), 2);
+ await db.query("DELETE FROM analytics_events WHERE service = 'botdays'");
now = T0 + 31 * 86400000;
now = T0 + 31 * 86400000;
const r = await pruneRawEventsPg(db, { days: 30, now: () => now });
const r = await pruneRawEventsPg(db, { days: 30, now: () => now });
assert.strictEqual(r.removed, 3);
assert.strictEqual(r.removed, 3);
@@ -93,11 +93,12 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
@@ -93,11 +93,12 @@ const subjectOf = (i) => `usr_01JZ${String(i).padStart(22, '0')}`;
assert.strictEqual(await items.decorate(d2), 1);
assert.strictEqual(await items.decorate(d2), 1);
assert.deepStrictEqual([...d2.getElementById('x').classList], []);
assert.deepStrictEqual([...d2.getElementById('x').classList], []);
- // The stylesheet: Live's 14 name effects (13 and the one-of-a-kind Josh Vegas), an inline-block wrapper, and reduced motion.
+ // The stylesheet: Live's 15 name effects (13 and the one-of-a-kind Josh Vegas and 2fat Bass Drop), an inline-block wrapper, and reduced motion.
const css = fs.readFileSync(path.join(__dirname, '..', 'items.css'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'items.css'), 'utf8');
const fx = [...css.matchAll(/^\.(name-fx-[a-z]+) \{/gm)].map((m) => m[1]);
const fx = [...css.matchAll(/^\.(name-fx-[a-z]+) \{/gm)].map((m) => m[1]);
- assert.strictEqual(fx.length, 14, `14 name effects (${fx.join(', ')})`);
- assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas'));
+ assert.strictEqual(fx.length, 15, `15 name effects (${fx.join(', ')})`);
+ assert.ok(fx.includes('name-fx-rainbow') && fx.includes('name-fx-divine') && fx.includes('name-fx-vegas') && fx.includes('name-fx-twofat'));
+ assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-twofat::after \{ animation: none !important; \}/, 'the Calm setting stops the bass drop');
assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-vegas::after \{ animation: none !important; \}/, 'the Calm setting stops the reels');
assert.match(css, /html\[data-ov-motion=reduced\] \.name-fx-vegas::after \{ animation: none !important; \}/, 'the Calm setting stops the reels');
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /\.ov-fx \{ display: inline-block; \}/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*animation: none !important/);
@@ -69,6 +69,18 @@ console.log('shared modules: all checks passed');
@@ -69,6 +69,18 @@ console.log('shared modules: all checks passed');
assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');
assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');
const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });
const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });
assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');
assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');
+ // "Report a problem" (4.10.0): a ticket on openvibe.help naming this site and page, the page without its query or
+ // fragment (a sign-in code or a token can ride there); server-rendered, the plain form.
+ assert.ok(html.includes('data-ov-report>Report a problem</a>'), 'the full footer has Report a problem');
+ assert.ok(own.includes('data-ov-report>Report a problem</a>'), 'the compact footer has it too');
+ const rp = vm.createContext({ console, URLSearchParams }); rp.globalThis = rp; rp.window = rp; rp.localStorage = { getItem: () => null, setItem() {} };
+ rp.location = { protocol: 'https:', hostname: 'www.openvibe.live', origin: 'https://www.openvibe.live', pathname: '/@kim', href: 'https://www.openvibe.live/@kim?code=SECRET#t=1' };
+ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), rp);
+ const rh = rp.OpenVibeFooter.buildHTML({ service: 'live', variant: 'compact' });
+ assert.ok(rh.includes(`href="https://openvibe.help/tickets/new?site=openvibe.live&page=${encodeURIComponent('https://www.openvibe.live/@kim')}"`), rh.match(/href="[^"]*tickets\/new[^"]*"/));
+ assert.ok(!rh.includes('SECRET'), 'the query never reaches the link');
+ const srv = require('../footer.js').ssr({ service: 'live', variant: 'full' });
+ assert.ok(srv.includes('href="https://openvibe.help/tickets/new" data-ov-report'), 'server-rendered: the plain form');
for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {
for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {
const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };
const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);