openvibers/openvibe.host Commits
browser-watch keeps what failed: per route, the axe rule ids with up to 3 targets, the first console errors, overflow offenders and bad statuses (at most 20, clipped), in the site's state file for the operator (never in metrics); the CLI line points at it. A Live axe failure on 2026-09-26 left only a count behind.
Changes
@@@ -53,6 +53,28 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {} catch { return withAge ? { release: null, bootedAt: null } : null; }}++/**++ * What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids++ * with up to 3 targets, the first console errors, overflow and status. At most 20 entries, text clipped.++ */++function failureDetails(reports) {++ const out = [];++ const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);++ for (const r of reports || []) {++ for (const route of r.routes || []) {++ if (route.ok !== false) continue;++ for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 3).map((t) => clip(t, 160)) });++ for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });++ for (const w of route.widths || []) {++ for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });++ if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 3).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });++ if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });++ }++ }++ }++ return out.slice(0, 20);++}++/** The failing checks of a --json report list: [{ check, fail }]. */function failingChecks(reports) {const out = [];@@@ -127,7 +149,7 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',}if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }const failing = failingChecks(reports);-- const state = { site, origin, release, ok: r.code === 0, failing, checkedAt: new Date(now).toISOString() };++ const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), checkedAt: new Date(now).toISOString() };await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });out.push({ site, release, action: 'checked', ok: state.ok, failing });}@@@ -135,4 +157,4 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',return { sites: out };}--module.exports = { watch, failingChecks, writeMetrics, originOf, currentRelease, DAY_MS, SETTLE_MS };++module.exports = { watch, failingChecks, failureDetails, writeMetrics, originOf, currentRelease, DAY_MS, SETTLE_MS };@@@ -473,7 +473,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procconst sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);const r = await lib.browserWatch.watch(ctx, { sites, force: !!opts.force });if (json) emit(r);-- else for (const x of r.sites) out(x.action === 'checked' ? `${x.ok ? 'ok ' : 'FAIL'} ${x.site} ${x.release || '?'}${x.failing && x.failing.length ? `; failing: ${x.failing.map((f) => `${f.check} ${f.fail}`).join(', ')}` : ''}`++ else for (const x of r.sites) out(x.action === 'checked' ? `${x.ok ? 'ok ' : 'FAIL'} ${x.site} ${x.release || '?'}${x.failing && x.failing.length ? `; failing: ${x.failing.map((f) => `${f.check} ${f.fail}`).join(', ')} (details: ${ctx.inv.stateDir}/browser-watch/${x.site}.json)` : ''}`: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;}@@@ -12,7 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];--const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } } }];++const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },++ routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'] }] }, { path: '/ok', ok: true }] }];function site({ release = 'abc1234def56' } = {}) {const host = scenario();@@@ -55,6 +56,12 @@ runTests([assert.deepStrictEqual([r.sites[0].ok, r.sites[0].failing], [false, [{ check: 'console', fail: 2 }, { check: 'overflow', fail: 1 }]]);assert.match(host.files.get(PROM).content, /^openvibe_browser_check_ok\{service="live"\} 0$/m);assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);++ const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);++ assert.deepStrictEqual(st.details, [++ { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c'] },++ { route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },++ { route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },++ ], 'what failed is kept for the operator');host = site();host.answers.push({ code: 1, reports: fail }, { code: 0, reports: pass });r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check });
@@ -53,6 +53,28 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {
@@ -53,6 +53,28 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {
} catch { return withAge ? { release: null, bootedAt: null } : null; }
} catch { return withAge ? { release: null, bootedAt: null } : null; }
}
}
+/**
+ * What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids
+ * with up to 3 targets, the first console errors, overflow and status. At most 20 entries, text clipped.
+ */
+function failureDetails(reports) {
+ const out = [];
+ const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);
+ for (const r of reports || []) {
+ for (const route of r.routes || []) {
+ if (route.ok !== false) continue;
+ for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 3).map((t) => clip(t, 160)) });
+ for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
+ for (const w of route.widths || []) {
+ for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
+ if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 3).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });
+ if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });
+ }
+ }
+ }
+ return out.slice(0, 20);
+}
+
/** The failing checks of a --json report list: [{ check, fail }]. */
/** The failing checks of a --json report list: [{ check, fail }]. */
function failingChecks(reports) {
function failingChecks(reports) {
const out = [];
const out = [];
@@ -127,7 +149,7 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
@@ -127,7 +149,7 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
}
}
if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }
if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }
const failing = failingChecks(reports);
const failing = failingChecks(reports);
- const state = { site, origin, release, ok: r.code === 0, failing, checkedAt: new Date(now).toISOString() };
+ const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), checkedAt: new Date(now).toISOString() };
await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });
await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });
out.push({ site, release, action: 'checked', ok: state.ok, failing });
out.push({ site, release, action: 'checked', ok: state.ok, failing });
}
}
@@ -135,4 +157,4 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
@@ -135,4 +157,4 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
return { sites: out };
return { sites: out };
}
}
-module.exports = { watch, failingChecks, writeMetrics, originOf, currentRelease, DAY_MS, SETTLE_MS };
+module.exports = { watch, failingChecks, failureDetails, writeMetrics, originOf, currentRelease, DAY_MS, SETTLE_MS };
@@ -473,7 +473,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -473,7 +473,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);
const sites = String(opts.sites || 'live').split(',').map((x) => x.trim()).filter(Boolean);
const r = await lib.browserWatch.watch(ctx, { sites, force: !!opts.force });
const r = await lib.browserWatch.watch(ctx, { sites, force: !!opts.force });
if (json) emit(r);
if (json) emit(r);
- else for (const x of r.sites) out(x.action === 'checked' ? `${x.ok ? 'ok ' : 'FAIL'} ${x.site} ${x.release || '?'}${x.failing && x.failing.length ? `; failing: ${x.failing.map((f) => `${f.check} ${f.fail}`).join(', ')}` : ''}`
+ else for (const x of r.sites) out(x.action === 'checked' ? `${x.ok ? 'ok ' : 'FAIL'} ${x.site} ${x.release || '?'}${x.failing && x.failing.length ? `; failing: ${x.failing.map((f) => `${f.check} ${f.fail}`).join(', ')} (details: ${ctx.inv.stateDir}/browser-watch/${x.site}.json)` : ''}`
: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);
: x.action === 'skipped' ? `skip ${x.site} ${x.release || '?'} (${x.reason}; last ${x.ok ? 'passed' : 'failed'})` : `ERR ${x.site}: ${x.reason}`);
return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;
return r.sites.some((x) => x.action === 'error') ? 2 : r.sites.some((x) => x.action === 'checked' && !x.ok) ? 1 : 0;
}
}
@@ -12,7 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');
@@ -12,7 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');
const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';
const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';
const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];
const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];
-const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } } }];
+const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },
+ routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'] }] }, { path: '/ok', ok: true }] }];
function site({ release = 'abc1234def56' } = {}) {
function site({ release = 'abc1234def56' } = {}) {
const host = scenario();
const host = scenario();
@@ -55,6 +56,12 @@ runTests([
@@ -55,6 +56,12 @@ runTests([
assert.deepStrictEqual([r.sites[0].ok, r.sites[0].failing], [false, [{ check: 'console', fail: 2 }, { check: 'overflow', fail: 1 }]]);
assert.deepStrictEqual([r.sites[0].ok, r.sites[0].failing], [false, [{ check: 'console', fail: 2 }, { check: 'overflow', fail: 1 }]]);
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_ok\{service="live"\} 0$/m);
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_ok\{service="live"\} 0$/m);
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);
+ const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);
+ assert.deepStrictEqual(st.details, [
+ { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c'] },
+ { route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
+ { route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },
+ ], 'what failed is kept for the operator');
host = site();
host = site();
host.answers.push({ code: 1, reports: fail }, { code: 0, reports: pass });
host.answers.push({ code: 1, reports: fail }, { code: 0, reports: pass });
r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check });
r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check });