openvibers/openvibe.bot Commits
machines: store the guided first run and show it (#301)
Changes
nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run-guided-and-measured): one row per++-- device and step, from the `node.first_run` telemetry events the Node sends over its existing device link. It powers++-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and++-- how long each took). The fields are an allowlist: no path, username or address is stored.++CREATE TABLE IF NOT EXISTS device_first_run (++ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,++ step text NOT NULL CHECK (step IN ('installed', 'paired', 'self_check', 'first_job')),++ at timestamptz NOT NULL,++ agent_version text,++ os text,++ ok boolean,++ reason text,++ info jsonb NOT NULL DEFAULT '{}'::jsonb,++ received_at timestamptz NOT NULL,++ PRIMARY KEY (device_id, step)++);++-- The admin funnel reads every step in time order; the machines page reads one machine's first_job (the primary key).++CREATE INDEX IF NOT EXISTS device_first_run_at ON device_first_run (at);@@@ -122,3 +122,10 @@/* hidden wins over .dir-btn's display (the panel link waits for a robot, the downloads for the files). */.sd [hidden] { display: none !important; }.sd-made { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(34, 197, 94, .45); background: rgba(34, 197, 94, .08); }++++/* The admin first-run funnel (/admin/first-run): the same directory shell, one compact table. */++.dir-table { width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 15px; }++.dir-table th, .dir-table td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border, #1f2d47); }++.dir-table thead th { font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.dir-table tbody th { font-weight: 650; }++.dir-table tbody td:nth-child(2) { font-variant-numeric: tabular-nums; }@@@ -921,6 +921,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =WHERE id = $1 AND revoked_at IS NULL RETURNING *`,[id, JSON.stringify(fleet), iso(now())]);}++ // ── The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run) ──────────────++ // One row per device and step, from the `node.first_run` telemetry events the Node already sends. The fields are++ // an allowlist: a path, a username or an address is never stored even if a buggy client sends one.++ const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);++ const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);++ const firstRunText = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) || null : null);++ function firstRunInfo(fields) {++ const info = {};++ const arch = firstRunText(fields.arch, 24);++ if (arch) info.arch = arch;++ for (const key of ['cpus', 'memory_total_bytes', 'memory_free_bytes', 'disk_total_bytes', 'disk_free_bytes']) {++ const n = firstRunNumber(fields[key]);++ if (n !== undefined) info[key] = n;++ }++ return info;++ }++ /**++ * Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event++ * that is not `node.first_run` with a known step is ignored, never an error for the caller.++ */++ async function recordFirstRun(deviceId, events) {++ if (!Array.isArray(events) || !deviceId) return 0;++ let stored = 0;++ for (const event of events) {++ if (!event || event.name !== 'node.first_run' || !event.fields || typeof event.fields !== 'object') continue;++ const step = firstRunText(event.fields.step, 20);++ if (!FIRST_RUN_STEPS.has(step)) continue;++ const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();++ // A clock far in the future would make every duration meaningless; keep it on this side of the present.++ const at = iso(Math.min(Math.max(ts, Date.UTC(2020, 0, 1)), now() + 5 * 60_000));++ const reason = step === 'self_check' ? firstRunText(event.fields.reason, 240) : null;++ const ok = step === 'self_check' && typeof event.fields.ok === 'boolean' ? event.fields.ok : null;++ const info = step === 'first_job' ? firstRunInfo(event.fields) : {};++ await db.query(++ `INSERT INTO device_first_run (device_id, step, at, agent_version, os, ok, reason, info, received_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)++ ON CONFLICT (device_id, step) DO UPDATE SET at = EXCLUDED.at, agent_version = EXCLUDED.agent_version,++ os = EXCLUDED.os, ok = EXCLUDED.ok, reason = EXCLUDED.reason, info = EXCLUDED.info,++ received_at = EXCLUDED.received_at`,++ [deviceId, step, at, firstRunText(event.fields.version, 40), firstRunText(event.fields.os, 24), ok, reason,++ JSON.stringify(info), iso(now())]);++ stored++;++ }++ return stored;++ }++ /** What the machine's first job found (the machines page), or null when it has not run yet. */++ async function firstRunOf(deviceId) {++ return db.maybe("SELECT step, at, agent_version, os, ok, reason, info FROM device_first_run WHERE device_id = $1 AND step = 'first_job'", [deviceId]);++ }++ /** Every stored first-run step, for the admin funnel (the page counts and times them). */++ async function firstRunFunnel() {++ return db.many('SELECT device_id, step, at, agent_version, os, ok, reason, info FROM device_first_run ORDER BY at, device_id, step');++ }++/** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */async function revokeNode(principalId) {if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');@@@ -1373,7 +1427,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =localProfile, saveLocalProfile, useCatalogueProfile },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,++ recordFirstRun, firstRunOf, firstRunFunnel },machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },@@@ -492,6 +492,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconn.lastTelemetry = now();}conn.telemetry = msg;++ if (urgent && !conn.sim) {++ // The guided first run's steps ride these events (OpenVibe.Node's docs/install.md#first-run): store the++ // step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must++ // keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never++ // break a robot's telemetry. The domain ignores an event that is not a known step.++ domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));++ }for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);}@@@ -298,7 +298,8 @@ ${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>--${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}</section>` : ''}`;++${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}++<p class="muted"><a href="/admin/first-run">The guided first run</a>: how many machines reach each step, and how long each took.</p></section>` : ''}`;return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });}@@@ -813,11 +813,31 @@ function renderMachinesPage({ machines = [], pairing = null }) {+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')+ '</div>';};++ // The guided first run's one job (Node v0.39.0): what this machine is, from the first_job step Bot stored.++ const bytesText = (n) => {++ if (!Number.isFinite(n) || n < 0) return '';++ const units = ['B', 'KB', 'MB', 'GB', 'TB'];++ let v = n, i = 0;++ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }++ return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;++ };++ const firstRunRow = (m) => {++ const f = m.first_run;++ if (!f) return '';++ const i = f.info && typeof f.info === 'object' ? f.info : {};++ const bits = [];++ if (Number.isInteger(i.cpus) && i.cpus > 0) bits.push(`${i.cpus} CPUs`);++ if (Number.isFinite(i.memory_total_bytes) && i.memory_total_bytes > 0) bits.push(`${bytesText(i.memory_free_bytes)} free of ${bytesText(i.memory_total_bytes)} memory`);++ if (Number.isFinite(i.disk_total_bytes) && i.disk_total_bytes > 0) bits.push(`${bytesText(i.disk_free_bytes)} free disk`);++ bits.push(`${f.os || 'this machine'}${i.arch ? '/' + i.arch : ''}`);++ return `<div class="machine-first-run"><span class="pill">First run</span><span class="muted">${esc(bits.join(' · '))}</span>`++ + '<span class="muted small">What its first job found: OS, CPUs, memory and free disk. No file, account or address leaves the machine.</span></div>';++ };const list = machines.length? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`-- + computerRow(m) + voiceRow(m)++ + firstRunRow(m) + computerRow(m) + voiceRow(m)+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;// Adding a machine is a choice of what it does, each with its own command (one copy button each), not steps.@@@ -1020,4 +1040,68 @@ function renderPhonePage() {return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });}--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, camerasOf, esc };++/**++ * /admin/first-run: the measured first run, reviewers only. One row per step the Node reported (node.first_run), so++ * the number that reach each step and the median time between them answer whether installs get to a working machine.++ */++function renderFirstRunPage({ rows = [] }) {++ const order = ['installed', 'paired', 'self_check', 'first_job'];++ const byStep = new Map(order.map((s) => [s, []]));++ const byDevice = new Map();++ for (const r of rows) {++ if (!byStep.has(r.step)) continue;++ const at = Date.parse(r.at);++ byStep.get(r.step).push({ ...r, ms: Number.isFinite(at) ? at : 0 });++ if (!byDevice.has(r.device_id)) byDevice.set(r.device_id, {});++ byDevice.get(r.device_id)[r.step] = Number.isFinite(at) ? at : 0;++ }++ const median = (values) => {++ if (!values.length) return null;++ const a = [...values].sort((x, y) => x - y);++ const m = Math.floor(a.length / 2);++ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;++ };++ const span = (from, to) => median([...byDevice.values()]++ .map((d) => (d[from] && d[to] && d[to] >= d[from] ? (d[to] - d[from]) / 1000 : null))++ .filter((x) => x !== null));++ const secs = (v) => {++ if (v === null || v === undefined) return '—';++ if (v < 1) return 'under a second';++ if (v < 90) return `${Math.round(v)} s`;++ const m = Math.round(v / 60 * 10) / 10;++ return `${m} min`;++ };++ const count = (s) => byStep.get(s).length;++ const checks = byStep.get('self_check');++ const failed = checks.filter((r) => r.ok === false).length;++ const rowsHtml = [++ ['Installed', count('installed'), null],++ ['Paired', count('paired'), span('installed', 'paired')],++ ['Self-check', count('self_check'), span('paired', 'self_check')],++ ['First job done', count('first_job'), span('self_check', 'first_job')],++ ].map(([label, n, d]) => `<tr><th scope="row">${label}</th><td>${n}</td><td>${esc(secs(d))}</td></tr>`).join('');++ const latest = [...byDevice.entries()].map(([id, steps]) => {++ const last = order.filter((s) => steps[s]).at(-1) || 'installed';++ const at = steps[last] || steps.installed || 0;++ return { id, last, at };++ }).sort((a, b) => b.at - a.at).slice(0, 25);++ const recent = latest.length++ ? `<ul class="dir-list">${latest.map(({ id, last, at }) => {++ const row = byStep.get(last).find((r) => r.device_id === id);++ return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`++ + `${row && row.agent_version ? ` · Node ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`++ + `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;++ }).join('')}</ul>`++ : '<p class="muted">No machine has reported a first run yet.</p>';++ const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>++<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Node's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>++<section class="dir-sec"><h2>The funnel</h2>++<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>++<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>++<tbody>${rowsHtml}</tbody></table>++<p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section>++<section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;++ return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });++}++++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };@@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');const { createMachineScreen } = require('../machine-screen');const { createScreenShares } = require('../screen-shares');const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');@@@ -461,6 +461,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limireturn res.redirect(303, '/robots/submissions');}));++ // The guided first run's funnel, reviewers only (OpenVibe.Node docs/install.md): the Node reports one++ // node.first_run telemetry event per step, and Bot stores one row per device and step.++ r.get('/admin/first-run', page(async (req, res) => {++ const me = requireUser(req);++ if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');++ res.setHeader('Content-Security-Policy', HOME_CSP);++ res.type('html').send(renderFirstRunPage({ rows: await domain.devices.firstRunFunnel() }));++ }));++r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');@@@ -591,8 +600,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;const robot = robotId ? await domain.robots.get(robotId) : null;const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};++ // Read online first: the first-run query must not make a machine look offline because the page took longer.++ const online = !!(hub && hub.isOnline(d.id));++ // What the guided first run's one job found on this machine (Node's node.first_run first_job event).++ const firstRun = await domain.devices.firstRunOf(d.id);return {-- ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,++ ...d, online, robot: robot ? robot.name : null,++ first_run: firstRun,computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++// The guided first run, measured (OpenVibe.Node's `openvibe-node`): the Node sends one `node.first_run` telemetry++// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores++// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot();++ const alex = t.network.newUser('alex');++ const ada = t.network.newUser('ada');++ ada.role = 'admin';++ const cookie = (u) => ({ Cookie: `ov_token=${t.network.signUser(u)}` });++ const get = (p, u) => fetch(t.base + p, { redirect: 'manual', headers: u ? cookie(u) : {} });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const poll = async (fn, ms = 5000) => {++ const end = Date.now() + ms;++ for (;;) {++ const v = await fn();++ if (v) return v;++ if (Date.now() > end) return null;++ await wait(50);++ }++ };++ async function pairOverWs(robotId, code, capabilities = {}) {++ const dev = await t.ws('/device');++ dev.send({ type: 'pair', robot: robotId, code, agent_version: 'v0.39.0', device_kind: 'onboard', drivers: ['dryrun'],++ capabilities: { worker: { runtime_classes: ['function'] }, ...capabilities } });++ const paired = await dev.waitFor((m) => m.type === 'paired');++ await dev.waitFor((m) => m.type === 'hello');++ await dev.waitFor((m) => m.type === 'config');++ return { dev, paired };++ }++ const rowsFor = (id) => t.db.many('SELECT * FROM device_first_run WHERE device_id = $1 ORDER BY at', [id]);++++ const { robot, pairing } = await t.robot(alex);++ const { dev, paired } = await pairOverWs(robot.id, pairing.code);++ const at = Date.now() - 60_000;++ const event = (step, extra = {}) => ({ name: 'node.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,++ fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });++++ await check('one node.first_run event per step is stored, with the machine counts and no personal fields', async () => {++ dev.send({ type: 'telemetry', events: [++ event('installed'),++ event('paired'),++ event('self_check', { ok: true }),++ event('first_job', { cpus: 8, memory_total_bytes: 16 << 30, memory_free_bytes: 8 << 30,++ disk_total_bytes: 100 << 30, disk_free_bytes: 40 << 30,++ // A buggy client's extra fields are never stored.++ path: '/home/alice', user: 'alice', ip: '10.0.0.7' }),++ ] });++ const rows = await poll(async () => { const r = await rowsFor(paired.device_id); return r.length === 4 ? r : null; });++ assert.ok(rows, 'the four steps were stored');++ assert.deepStrictEqual(rows.map((r) => r.step), ['installed', 'paired', 'self_check', 'first_job']);++ assert.strictEqual(rows[0].agent_version, 'v0.39.0');++ assert.strictEqual(rows[0].os, 'linux');++ assert.strictEqual(rows[2].ok, true);++ assert.strictEqual(rows[3].ok, null);++ assert.strictEqual(rows[3].info.cpus, 8);++ assert.strictEqual(rows[3].info.memory_total_bytes, 16 << 30);++ assert.strictEqual(rows[3].info.disk_free_bytes, 40 << 30);++ assert.deepStrictEqual(Object.keys(rows[3].info).sort(), ['arch', 'cpus', 'disk_free_bytes', 'disk_total_bytes', 'memory_free_bytes', 'memory_total_bytes']);++ const stored = JSON.stringify(rows);++ assert.ok(!stored.includes('/home/alice') && !stored.includes('alice') && !stored.includes('10.0.0.7'), 'no path, user or address is stored');++ });++++ await check('a failed self-check keeps its reason for the funnel; a resend updates the step instead of adding one', async () => {++ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'OpenVibe.Bot answered HTTP 503' })] });++ const failed = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.ok === false ? s : null; });++ assert.ok(failed, 'the failure replaced the pass');++ assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');++ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },++ { name: 'node.first_run', ts: at, fields: { step: 'not_a_step' } }] });++ const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });++ assert.ok(cleaned, 'control characters are cleaned from the reason');++ const rows = await rowsFor(paired.device_id);++ assert.strictEqual(rows.length, 4, 'ignored events added no rows and the resend added none');++ });++++ await check('the owner’s machines page shows what the first job found', async () => {++ const page = await (await get('/machines', alex)).text();++ assert.ok(page.includes('First run'), page.slice(0, 500));++ assert.ok(page.includes('8 CPUs') && page.includes('free disk') && page.includes('linux/amd64'), 'the machine figures are shown');++ assert.ok(!page.includes('/home/alice'));++ });++++ await check('only a reviewer reads the funnel, and it counts machines and the time between steps', async () => {++ const normal = await get('/admin/first-run', alex);++ assert.strictEqual(normal.status, 404);++ const anon = await get('/admin/first-run');++ assert.notStrictEqual(anon.status, 200, 'signed out: not the funnel');++ const r = await get('/admin/first-run', ada);++ assert.strictEqual(r.status, 200);++ const page = await r.text();++ assert.ok(page.includes('First run') && page.includes('The funnel'), 'the funnel page');++ assert.ok(page.includes('First job done') && page.includes('Review'), `the steps are listed: ${page.slice(0, 900)}`);++ assert.ok(page.includes('1 s') || page.includes('under a second'), 'the median between steps (the fixture is 1 s apart)');++ assert.match(page, new RegExp(paired.device_id), 'the recent machine');++ assert.ok(page.includes('0 self-checks passed, 1 failed') || page.includes('1 failed'), 'the failed self-check is counted as reached');++ });++++ dev.close();++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run-guided-and-measured): one row per
+-- device and step, from the `node.first_run` telemetry events the Node sends over its existing device link. It powers
+-- the machines page's figures for a machine's first job and the admin funnel (how many installs reach each step, and
+-- how long each took). The fields are an allowlist: no path, username or address is stored.
+CREATE TABLE IF NOT EXISTS device_first_run (
+ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ step text NOT NULL CHECK (step IN ('installed', 'paired', 'self_check', 'first_job')),
+ at timestamptz NOT NULL,
+ agent_version text,
+ os text,
+ ok boolean,
+ reason text,
+ info jsonb NOT NULL DEFAULT '{}'::jsonb,
+ received_at timestamptz NOT NULL,
+ PRIMARY KEY (device_id, step)
+);
+-- The admin funnel reads every step in time order; the machines page reads one machine's first_job (the primary key).
+CREATE INDEX IF NOT EXISTS device_first_run_at ON device_first_run (at);
@@ -122,3 +122,10 @@
@@ -122,3 +122,10 @@
/* hidden wins over .dir-btn's display (the panel link waits for a robot, the downloads for the files). */
/* hidden wins over .dir-btn's display (the panel link waits for a robot, the downloads for the files). */
.sd [hidden] { display: none !important; }
.sd [hidden] { display: none !important; }
.sd-made { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(34, 197, 94, .45); background: rgba(34, 197, 94, .08); }
.sd-made { margin: 0; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(34, 197, 94, .45); background: rgba(34, 197, 94, .08); }
+
+/* The admin first-run funnel (/admin/first-run): the same directory shell, one compact table. */
+.dir-table { width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 15px; }
+.dir-table th, .dir-table td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border, #1f2d47); }
+.dir-table thead th { font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.dir-table tbody th { font-weight: 650; }
+.dir-table tbody td:nth-child(2) { font-variant-numeric: tabular-nums; }
@@ -921,6 +921,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -921,6 +921,60 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
[id, JSON.stringify(fleet), iso(now())]);
[id, JSON.stringify(fleet), iso(now())]);
}
}
+ // ── The guided first run (OpenVibe.Node's `openvibe-node`, docs/install.md#first-run) ──────────────
+ // One row per device and step, from the `node.first_run` telemetry events the Node already sends. The fields are
+ // an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
+ const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
+ const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
+ const firstRunText = (v, max) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) || null : null);
+ function firstRunInfo(fields) {
+ const info = {};
+ const arch = firstRunText(fields.arch, 24);
+ if (arch) info.arch = arch;
+ for (const key of ['cpus', 'memory_total_bytes', 'memory_free_bytes', 'disk_total_bytes', 'disk_free_bytes']) {
+ const n = firstRunNumber(fields[key]);
+ if (n !== undefined) info[key] = n;
+ }
+ return info;
+ }
+ /**
+ * Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event
+ * that is not `node.first_run` with a known step is ignored, never an error for the caller.
+ */
+ async function recordFirstRun(deviceId, events) {
+ if (!Array.isArray(events) || !deviceId) return 0;
+ let stored = 0;
+ for (const event of events) {
+ if (!event || event.name !== 'node.first_run' || !event.fields || typeof event.fields !== 'object') continue;
+ const step = firstRunText(event.fields.step, 20);
+ if (!FIRST_RUN_STEPS.has(step)) continue;
+ const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();
+ // A clock far in the future would make every duration meaningless; keep it on this side of the present.
+ const at = iso(Math.min(Math.max(ts, Date.UTC(2020, 0, 1)), now() + 5 * 60_000));
+ const reason = step === 'self_check' ? firstRunText(event.fields.reason, 240) : null;
+ const ok = step === 'self_check' && typeof event.fields.ok === 'boolean' ? event.fields.ok : null;
+ const info = step === 'first_job' ? firstRunInfo(event.fields) : {};
+ await db.query(
+ `INSERT INTO device_first_run (device_id, step, at, agent_version, os, ok, reason, info, received_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
+ ON CONFLICT (device_id, step) DO UPDATE SET at = EXCLUDED.at, agent_version = EXCLUDED.agent_version,
+ os = EXCLUDED.os, ok = EXCLUDED.ok, reason = EXCLUDED.reason, info = EXCLUDED.info,
+ received_at = EXCLUDED.received_at`,
+ [deviceId, step, at, firstRunText(event.fields.version, 40), firstRunText(event.fields.os, 24), ok, reason,
+ JSON.stringify(info), iso(now())]);
+ stored++;
+ }
+ return stored;
+ }
+ /** What the machine's first job found (the machines page), or null when it has not run yet. */
+ async function firstRunOf(deviceId) {
+ return db.maybe("SELECT step, at, agent_version, os, ok, reason, info FROM device_first_run WHERE device_id = $1 AND step = 'first_job'", [deviceId]);
+ }
+ /** Every stored first-run step, for the admin funnel (the page counts and times them). */
+ async function firstRunFunnel() {
+ return db.many('SELECT device_id, step, at, agent_version, os, ok, reason, info FROM device_first_run ORDER BY at, device_id, step');
+ }
+
/** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */
/** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */
async function revokeNode(principalId) {
async function revokeNode(principalId) {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');
if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');
@@ -1373,7 +1427,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1373,7 +1427,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
+ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,
+ recordFirstRun, firstRunOf, firstRunFunnel },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
@@ -492,6 +492,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -492,6 +492,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
conn.lastTelemetry = now();
conn.lastTelemetry = now();
}
}
conn.telemetry = msg;
conn.telemetry = msg;
+ if (urgent && !conn.sim) {
+ // The guided first run's steps ride these events (OpenVibe.Node's docs/install.md#first-run): store the
+ // step times for the machines page and the admin funnel. Not awaited: the sensor broadcast below must
+ // keep its frame order (two telemetry frames are handled concurrently), and a storage failure must never
+ // break a robot's telemetry. The domain ignores an event that is not a known step.
+ domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));
+ }
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
@@ -298,7 +298,8 @@ ${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('
@@ -298,7 +298,8 @@ ${mine.length ? `<ul class="dir-list">${mine.map((s) => submissionRow(s)).join('
${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
${pending ? `<section class="dir-sec" aria-labelledby="queue-h"><h2 id="queue-h">Waiting for review</h2>
${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}
${signing ? '' : '<p class="dir-error" role="alert">No profile signing key is configured, so nothing can be approved yet (BOT_PROFILE_SIGNING_KEY).</p>'}
<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>
<p class="muted">Check the profile's commands and limits are safe for the hardware it names, that its driver's source is public and matches, and that the guide is a real guide. Approving signs it and puts it in the catalogue.</p>
-${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}</section>` : ''}`;
+${pending.length ? `<ul class="dir-list">${pending.map((s) => submissionRow(s, { review: true, by: names.get(s.submitter_subject) })).join('')}</ul>` : '<p class="muted">The queue is empty.</p>'}
+<p class="muted"><a href="/admin/first-run">The guided first run</a>: how many machines reach each step, and how long each took.</p></section>` : ''}`;
return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });
return shell({ config, path: '/robots/submissions', title: 'Community robots', description: 'Community robot profiles on OpenVibe.Bot.', body });
}
}
@@ -813,11 +813,31 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -813,11 +813,31 @@ function renderMachinesPage({ machines = [], pairing = null }) {
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
+ '</div>';
};
};
+ // The guided first run's one job (Node v0.39.0): what this machine is, from the first_job step Bot stored.
+ const bytesText = (n) => {
+ if (!Number.isFinite(n) || n < 0) return '';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let v = n, i = 0;
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+ return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
+ };
+ const firstRunRow = (m) => {
+ const f = m.first_run;
+ if (!f) return '';
+ const i = f.info && typeof f.info === 'object' ? f.info : {};
+ const bits = [];
+ if (Number.isInteger(i.cpus) && i.cpus > 0) bits.push(`${i.cpus} CPUs`);
+ if (Number.isFinite(i.memory_total_bytes) && i.memory_total_bytes > 0) bits.push(`${bytesText(i.memory_free_bytes)} free of ${bytesText(i.memory_total_bytes)} memory`);
+ if (Number.isFinite(i.disk_total_bytes) && i.disk_total_bytes > 0) bits.push(`${bytesText(i.disk_free_bytes)} free disk`);
+ bits.push(`${f.os || 'this machine'}${i.arch ? '/' + i.arch : ''}`);
+ return `<div class="machine-first-run"><span class="pill">First run</span><span class="muted">${esc(bits.join(' · '))}</span>`
+ + '<span class="muted small">What its first job found: OS, CPUs, memory and free disk. No file, account or address leaves the machine.</span></div>';
+ };
const list = machines.length
const list = machines.length
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
- + computerRow(m) + voiceRow(m)
+ + firstRunRow(m) + computerRow(m) + voiceRow(m)
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
// Adding a machine is a choice of what it does, each with its own command (one copy button each), not steps.
// Adding a machine is a choice of what it does, each with its own command (one copy button each), not steps.
@@ -1020,4 +1040,68 @@ function renderPhonePage() {
@@ -1020,4 +1040,68 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
}
-module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
+/**
+ * /admin/first-run: the measured first run, reviewers only. One row per step the Node reported (node.first_run), so
+ * the number that reach each step and the median time between them answer whether installs get to a working machine.
+ */
+function renderFirstRunPage({ rows = [] }) {
+ const order = ['installed', 'paired', 'self_check', 'first_job'];
+ const byStep = new Map(order.map((s) => [s, []]));
+ const byDevice = new Map();
+ for (const r of rows) {
+ if (!byStep.has(r.step)) continue;
+ const at = Date.parse(r.at);
+ byStep.get(r.step).push({ ...r, ms: Number.isFinite(at) ? at : 0 });
+ if (!byDevice.has(r.device_id)) byDevice.set(r.device_id, {});
+ byDevice.get(r.device_id)[r.step] = Number.isFinite(at) ? at : 0;
+ }
+ const median = (values) => {
+ if (!values.length) return null;
+ const a = [...values].sort((x, y) => x - y);
+ const m = Math.floor(a.length / 2);
+ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
+ };
+ const span = (from, to) => median([...byDevice.values()]
+ .map((d) => (d[from] && d[to] && d[to] >= d[from] ? (d[to] - d[from]) / 1000 : null))
+ .filter((x) => x !== null));
+ const secs = (v) => {
+ if (v === null || v === undefined) return '—';
+ if (v < 1) return 'under a second';
+ if (v < 90) return `${Math.round(v)} s`;
+ const m = Math.round(v / 60 * 10) / 10;
+ return `${m} min`;
+ };
+ const count = (s) => byStep.get(s).length;
+ const checks = byStep.get('self_check');
+ const failed = checks.filter((r) => r.ok === false).length;
+ const rowsHtml = [
+ ['Installed', count('installed'), null],
+ ['Paired', count('paired'), span('installed', 'paired')],
+ ['Self-check', count('self_check'), span('paired', 'self_check')],
+ ['First job done', count('first_job'), span('self_check', 'first_job')],
+ ].map(([label, n, d]) => `<tr><th scope="row">${label}</th><td>${n}</td><td>${esc(secs(d))}</td></tr>`).join('');
+ const latest = [...byDevice.entries()].map(([id, steps]) => {
+ const last = order.filter((s) => steps[s]).at(-1) || 'installed';
+ const at = steps[last] || steps.installed || 0;
+ return { id, last, at };
+ }).sort((a, b) => b.at - a.at).slice(0, 25);
+ const recent = latest.length
+ ? `<ul class="dir-list">${latest.map(({ id, last, at }) => {
+ const row = byStep.get(last).find((r) => r.device_id === id);
+ return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`
+ + `${row && row.agent_version ? ` · Node ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`
+ + `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;
+ }).join('')}</ul>`
+ : '<p class="muted">No machine has reported a first run yet.</p>';
+ const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
+<header class="dir-hero"><h1>First run</h1><p class="muted">How many machines reach each step of OpenVibe Node's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (<code>node.first_run</code>); nothing here names a person, folder or address.</p></header>
+<section class="dir-sec"><h2>The funnel</h2>
+<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
+<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
+<tbody>${rowsHtml}</tbody></table>
+<p class="muted">${count('self_check') - failed} self-check${count('self_check') - failed === 1 ? '' : 's'} passed${failed ? `, ${failed} failed` : ''}. A failed check is still a machine that got that far; its reason is in the machine's own record.</p></section>
+<section class="dir-sec"><h2>Most recent</h2>${recent}</section>`;
+ return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });
+}
+
+module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@ -461,6 +461,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -461,6 +461,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, '/robots/submissions');
return res.redirect(303, '/robots/submissions');
}));
}));
+ // The guided first run's funnel, reviewers only (OpenVibe.Node docs/install.md): the Node reports one
+ // node.first_run telemetry event per step, and Bot stores one row per device and step.
+ r.get('/admin/first-run', page(async (req, res) => {
+ const me = requireUser(req);
+ if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');
+ res.setHeader('Content-Security-Policy', HOME_CSP);
+ res.type('html').send(renderFirstRunPage({ rows: await domain.devices.firstRunFunnel() }));
+ }));
+
r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
r.post('/robots', ...manage, express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@ -591,8 +600,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -591,8 +600,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
const robot = robotId ? await domain.robots.get(robotId) : null;
const robot = robotId ? await domain.robots.get(robotId) : null;
const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
+ // Read online first: the first-run query must not make a machine look offline because the page took longer.
+ const online = !!(hub && hub.isOnline(d.id));
+ // What the guided first run's one job found on this machine (Node's node.first_run first_job event).
+ const firstRun = await domain.devices.firstRunOf(d.id);
return {
return {
- ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,
+ ...d, online, robot: robot ? robot.name : null,
+ first_run: firstRun,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+// The guided first run, measured (OpenVibe.Node's `openvibe-node`): the Node sends one `node.first_run` telemetry
+// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
+// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const ada = t.network.newUser('ada');
+ ada.role = 'admin';
+ const cookie = (u) => ({ Cookie: `ov_token=${t.network.signUser(u)}` });
+ const get = (p, u) => fetch(t.base + p, { redirect: 'manual', headers: u ? cookie(u) : {} });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const poll = async (fn, ms = 5000) => {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v) return v;
+ if (Date.now() > end) return null;
+ await wait(50);
+ }
+ };
+ async function pairOverWs(robotId, code, capabilities = {}) {
+ const dev = await t.ws('/device');
+ dev.send({ type: 'pair', robot: robotId, code, agent_version: 'v0.39.0', device_kind: 'onboard', drivers: ['dryrun'],
+ capabilities: { worker: { runtime_classes: ['function'] }, ...capabilities } });
+ const paired = await dev.waitFor((m) => m.type === 'paired');
+ await dev.waitFor((m) => m.type === 'hello');
+ await dev.waitFor((m) => m.type === 'config');
+ return { dev, paired };
+ }
+ const rowsFor = (id) => t.db.many('SELECT * FROM device_first_run WHERE device_id = $1 ORDER BY at', [id]);
+
+ const { robot, pairing } = await t.robot(alex);
+ const { dev, paired } = await pairOverWs(robot.id, pairing.code);
+ const at = Date.now() - 60_000;
+ const event = (step, extra = {}) => ({ name: 'node.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,
+ fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });
+
+ await check('one node.first_run event per step is stored, with the machine counts and no personal fields', async () => {
+ dev.send({ type: 'telemetry', events: [
+ event('installed'),
+ event('paired'),
+ event('self_check', { ok: true }),
+ event('first_job', { cpus: 8, memory_total_bytes: 16 << 30, memory_free_bytes: 8 << 30,
+ disk_total_bytes: 100 << 30, disk_free_bytes: 40 << 30,
+ // A buggy client's extra fields are never stored.
+ path: '/home/alice', user: 'alice', ip: '10.0.0.7' }),
+ ] });
+ const rows = await poll(async () => { const r = await rowsFor(paired.device_id); return r.length === 4 ? r : null; });
+ assert.ok(rows, 'the four steps were stored');
+ assert.deepStrictEqual(rows.map((r) => r.step), ['installed', 'paired', 'self_check', 'first_job']);
+ assert.strictEqual(rows[0].agent_version, 'v0.39.0');
+ assert.strictEqual(rows[0].os, 'linux');
+ assert.strictEqual(rows[2].ok, true);
+ assert.strictEqual(rows[3].ok, null);
+ assert.strictEqual(rows[3].info.cpus, 8);
+ assert.strictEqual(rows[3].info.memory_total_bytes, 16 << 30);
+ assert.strictEqual(rows[3].info.disk_free_bytes, 40 << 30);
+ assert.deepStrictEqual(Object.keys(rows[3].info).sort(), ['arch', 'cpus', 'disk_free_bytes', 'disk_total_bytes', 'memory_free_bytes', 'memory_total_bytes']);
+ const stored = JSON.stringify(rows);
+ assert.ok(!stored.includes('/home/alice') && !stored.includes('alice') && !stored.includes('10.0.0.7'), 'no path, user or address is stored');
+ });
+
+ await check('a failed self-check keeps its reason for the funnel; a resend updates the step instead of adding one', async () => {
+ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'OpenVibe.Bot answered HTTP 503' })] });
+ const failed = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.ok === false ? s : null; });
+ assert.ok(failed, 'the failure replaced the pass');
+ assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');
+ dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },
+ { name: 'node.first_run', ts: at, fields: { step: 'not_a_step' } }] });
+ const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });
+ assert.ok(cleaned, 'control characters are cleaned from the reason');
+ const rows = await rowsFor(paired.device_id);
+ assert.strictEqual(rows.length, 4, 'ignored events added no rows and the resend added none');
+ });
+
+ await check('the owner’s machines page shows what the first job found', async () => {
+ const page = await (await get('/machines', alex)).text();
+ assert.ok(page.includes('First run'), page.slice(0, 500));
+ assert.ok(page.includes('8 CPUs') && page.includes('free disk') && page.includes('linux/amd64'), 'the machine figures are shown');
+ assert.ok(!page.includes('/home/alice'));
+ });
+
+ await check('only a reviewer reads the funnel, and it counts machines and the time between steps', async () => {
+ const normal = await get('/admin/first-run', alex);
+ assert.strictEqual(normal.status, 404);
+ const anon = await get('/admin/first-run');
+ assert.notStrictEqual(anon.status, 200, 'signed out: not the funnel');
+ const r = await get('/admin/first-run', ada);
+ assert.strictEqual(r.status, 200);
+ const page = await r.text();
+ assert.ok(page.includes('First run') && page.includes('The funnel'), 'the funnel page');
+ assert.ok(page.includes('First job done') && page.includes('Review'), `the steps are listed: ${page.slice(0, 900)}`);
+ assert.ok(page.includes('1 s') || page.includes('under a second'), 'the median between steps (the fixture is 1 s apart)');
+ assert.match(page, new RegExp(paired.device_id), 'the recent machine');
+ assert.ok(page.includes('0 self-checks passed, 1 failed') || page.includes('1 failed'), 'the failed self-check is counted as reached');
+ });
+
+ dev.close();
+ await t.close();
+ done();
+})();