openvibers/openvibe.network Commits

Your machines on openvibe.network: state, what each runs, its roles as switches, pairing that watches for the machine (#466)

OP OpenVibers committed 13 days ago · 80c6324b04 · 8 files, +384 −5

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex 642be1f2..5f19df86 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -325,6 +325,50 @@ button.td-stat:hover span { color: var(--accent-light); }
.recent-tool-star[aria-pressed="true"] { color: var(--accent-light); }
.recent-tool-star:hover, .recent-tool-star:focus-visible { color: var(--accent-light); }
++/* Machines (plan L34, my/machines.js): pair one, then each machine with its state, what it runs and its roles. */
++.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; }
++.mc-top > div { flex: 1 1 320px; min-width: 0; }
++.mc-pair { margin: 16px 0 4px; padding: 16px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent) 50%, var(--border)); background: color-mix(in srgb, var(--accent) 6%, transparent); display: grid; gap: 10px; }
++.mc-pair h4 { font-size: 15px; }
++.mc-code { font: 800 clamp(28px, 6vw, 40px)/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; color: var(--text); }
++.mc-cmd { margin: 0; padding: 10px 12px; border-radius: 10px; background: var(--bg); border: 1px solid var(--border); overflow-x: auto; font-size: 12.5px; }
++.mc-pair-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
++.mc-until { font-size: 12px; color: var(--text-muted); }
++.mc-wait { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-secondary); }
++.mc-wait-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mcBreathe 1.6s ease-in-out infinite; }
++@keyframes mcBreathe { 50% { opacity: .25; transform: scale(.7); } }
++.mc-status { min-height: 1.3em; margin: 10px 0 0; font-size: 13px; color: var(--text-secondary); }
++.mc-status.is-bad { color: #ef4444; }
++.mc-list { display: grid; gap: 12px; margin-top: 8px; }
++.mc-card { display: grid; gap: 12px; padding: 16px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-secondary); min-width: 0; }
++.mc-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 12px; align-items: center; }
++.mc-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--text-muted) 18%, transparent); }
++.mc-dot.is-on { background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 22%, transparent); }
++.mc-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
++.mc-name { font-size: 15.5px; font-weight: 750; overflow-wrap: anywhere; }
++.mc-state { font-size: 12.5px; color: var(--text-muted); }
++.mc-meta { grid-column: 2; font-size: 12.5px; color: var(--text-muted); }
++.mc-meta:empty { display: none; }
++.mc-rename { font: inherit; font-size: 15px; font-weight: 700; padding: 4px 8px; border-radius: 8px; border: 1px solid var(--accent); background: var(--bg-input); color: var(--text); max-width: 100%; }
++.mc-running { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
++.mc-running li { padding: 3px 10px; border-radius: 999px; font-size: 12px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent-light); }
++.mc-roles { border: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 8px; }
++.mc-roles legend { font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px; }
++.mc-role { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 10px; align-items: center; min-height: 52px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); cursor: pointer; }
++.mc-role:has(input:checked) { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: color-mix(in srgb, var(--accent) 8%, var(--bg-card)); }
++.mc-role input { width: 18px; height: 18px; accent-color: var(--accent); }
++.mc-role-ic { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
++.mc-role-t { display: grid; gap: 1px; min-width: 0; }
++.mc-role-t b { font-size: 13.5px; }
++.mc-role-t small { font-size: 12px; color: var(--text-muted); line-height: 1.35; }
++.mc-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.mc-revoke { background: transparent; border: 1px solid color-mix(in srgb, #ef4444 45%, var(--border)); color: #ef4444; }
++.mc-revoke.is-sure { background: #ef4444; color: #fff; border-color: #ef4444; }
++.mc-confirm { flex-basis: 100%; font-size: 12.5px; color: var(--text-secondary); }
++.mc-empty { display: flex; gap: 14px; align-items: center; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }
++.mc-empty > i { font-size: 26px; color: var(--accent-light); }
++@media (prefers-reduced-motion: reduce) { .mc-wait-dot { animation: none; } }
++
/* Your data choices (L24): kit fold rows, one per purpose. Phone first: one column of equal-weight buttons, never
sideways. The kit's own /shared/kit.css draws the fold; these rules only lay out the choice inside it. */
.consent-fold { margin: 0 0 10px; }
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex 8c6971e5..d943d671 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -159,13 +159,14 @@ function showSection(id, btn) {
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'data') loadDataSection();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
++ if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
}
function openInitialSection() {
-- const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
++ const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
-- if (isAnonSession() && ['notifications','linked','security','data','approvals'].includes(routeSection)) {
++ if (isAnonSession() && ['notifications','linked','security','data','approvals','machines'].includes(routeSection)) {
return showSection('anon');
}
if (document.getElementById('sec-' + routeSection)) {
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
nnew file mode 100644
iindex 00000000..6a7aedfe
---- /dev/null
++++ b/public/js/my/machines.js
@@@ -0,0 +1,178 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my#machines, /machines): your machines (plan L34, the machines hub).
++ Each machine you paired: whether it is online, what it runs, the roles it plays for you (Codes jobs, desks, your
++ own models, a robot), a new name, and revoking it. "Pair a machine" shows a one-time code and the command to run,
++ then watches for the machine to arrive. Pairing and roles only ever come from your own session (Network refuses an
++ app's token there). Classic script, global scope: my/core.js's apiFetch and showSection; built from DOM nodes.
++ ═══════════════════════════════════════════════════════════════ */
++let mcRoles = null; // the catalogue: [{ id, name, installs, shows }]
++let mcWatch = 0; // the timer that watches for a newly paired machine
++
++function mcEl(tag, cls, kids, attrs) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
++ (kids || []).forEach((c) => { if (c != null) n.append(c); });
++ return n;
++}
++const mcIcon = (name) => mcEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });
++function mcAgo(at) {
++ const t = Date.parse(at);
++ if (!Number.isFinite(t)) return '';
++ const s = Math.max(0, (Date.now() - t) / 1000);
++ if (s < 90) return 'just now';
++ if (s < 3600) return `${Math.round(s / 60)} min ago`;
++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ return `${Math.round(s / 86400)} d ago`;
++}
++const MC_KIND = { onboard: 'This computer', bridge: 'A bridge', server: 'A server' };
++const MC_ROLE_ICON = { 'codes.worker': 'fa-code', 'desks.host': 'fa-display', 'ai.host': 'fa-microchip', 'bot.robot': 'fa-robot' };
++
++function mcSay(text, bad) {
++ const s = document.getElementById('mc-status');
++ if (!s) return;
++ s.textContent = text || '';
++ s.classList.toggle('is-bad', !!bad);
++}
++
++/** One machine: its state, what it runs, its roles as switches, and Rename / Revoke. */
++function mcCard(m) {
++ const card = mcEl('article', 'mc-card', [], { 'data-machine': m.id });
++ const dot = mcEl('span', `mc-dot ${m.online ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' });
++ const name = mcEl('h4', 'mc-name', [m.name || MC_KIND[m.kind] || 'A machine']);
++ const state = mcEl('span', 'mc-state', [m.online ? 'Online' : (m.last_seen_at ? `Offline · seen ${mcAgo(m.last_seen_at)}` : 'Offline')]);
++ const meta = mcEl('p', 'mc-meta', [[MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
++ const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
++ card.append(head);
++
++ if (Array.isArray(m.running) && m.running.length) {
++ card.append(mcEl('ul', 'mc-running', m.running.slice(0, 6).map((r) => mcEl('li', '', [String((r && (r.title || r.kind || r.id)) || r)]))));
++ }
++
++ // The roles: a switch each; a change saves the whole set at once.
++ const roles = mcEl('fieldset', 'mc-roles', [mcEl('legend', '', ['What it does for you'])]);
++ (mcRoles || []).forEach((role) => {
++ const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
++ const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });
++ box.checked = (m.roles || []).includes(role.id);
++ box.addEventListener('change', () => mcSaveRoles(card, m));
++ roles.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(MC_ROLE_ICON[role.id] || 'fa-gear')]), mcEl('span', 'mc-role-t', [mcEl('b', '', [role.name]), mcEl('small', '', [role.shows || ''])])], { for: id }));
++ });
++ card.append(roles);
++
++ const acts = mcEl('div', 'mc-acts');
++ const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
++ rename.addEventListener('click', () => mcRename(card, m, name));
++ const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
++ revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
++ acts.append(rename, revoke);
++ card.append(acts);
++ return card;
++}
++
++async function mcSaveRoles(card, m) {
++ const roles = [...card.querySelectorAll('.mc-role input')].filter((b) => b.checked).map((b) => b.value);
++ card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = true; });
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
++ m.roles = (out && out.machine && out.machine.roles) || roles;
++ mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);
++ } catch (e) {
++ mcSay(e.message || 'The roles could not be saved.', true);
++ card.querySelectorAll('.mc-role input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
++ } finally {
++ card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = false; });
++ }
++}
++
++function mcRename(card, m, nameEl) {
++ const input = mcEl('input', 'mc-rename', [], { type: 'text', maxlength: '80', 'aria-label': 'New name' });
++ input.value = m.name || '';
++ const save = async () => {
++ const v = input.value.trim();
++ if (!v || v === m.name) { input.replaceWith(nameEl); return; }
++ try {
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}`, { method: 'PATCH', body: JSON.stringify({ name: v }) });
++ m.name = (out && out.machine && out.machine.name) || v;
++ nameEl.textContent = m.name;
++ mcSay('Renamed.');
++ } catch (e) { mcSay(e.message || 'The name could not be saved.', true); }
++ input.replaceWith(nameEl);
++ };
++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') save(); if (ev.key === 'Escape') input.replaceWith(nameEl); });
++ input.addEventListener('blur', save);
++ nameEl.replaceWith(input);
++ input.focus(); input.select();
++}
++
++/** Revoking is for good: a second, explicit press, in the page. */
++function mcConfirmRevoke(acts, m) {
++ const keep = [...acts.childNodes];
++ const sure = mcEl('button', 'btn btn-small mc-revoke is-sure', ['Revoke for good'], { type: 'button' });
++ const back = mcEl('button', 'btn btn-outline btn-small', ['Keep it'], { type: 'button' });
++ const note = mcEl('span', 'mc-confirm', ['It stops working for you at once; pairing it again needs a new code.']);
++ back.addEventListener('click', () => acts.replaceChildren(...keep));
++ sure.addEventListener('click', async () => {
++ sure.disabled = true;
++ try {
++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/revoke`, { method: 'POST', body: '{}' });
++ mcSay(`${m.name || 'The machine'} is revoked.`);
++ loadMachines();
++ } catch (e) { mcSay(e.message || 'It could not be revoked.', true); sure.disabled = false; }
++ });
++ acts.replaceChildren(note, sure, back);
++ back.focus();
++}
++
++/** A one-time code and the command to run, then watch for the machine to arrive. */
++async function mcPair() {
++ const box = document.getElementById('mc-pair');
++ const btn = document.getElementById('mc-pair-btn');
++ if (!box) return;
++ btn.disabled = true;
++ try {
++ const before = new Set([...document.querySelectorAll('[data-machine]')].map((c) => c.getAttribute('data-machine')));
++ const out = await apiFetch('/api/v1/machines/pairings', { method: 'POST', body: '{}' });
++ const p = (out && out.pairing) || {};
++ const code = mcEl('p', 'mc-code', [String(p.code || '')]);
++ const cmd = mcEl('pre', 'mc-cmd', [mcEl('code', '', [String(p.installer || '')])]);
++ const copy = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy the command'], { type: 'button' });
++ copy.addEventListener('click', () => navigator.clipboard.writeText(String(p.installer || '')).then(() => mcSay('Copied. Run it on the machine.')));
++ const until = p.expires_at ? mcEl('span', 'mc-until', [`The code works until ${new Date(p.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`]) : null;
++ const wait = mcEl('p', 'mc-wait', [mcEl('span', 'mc-wait-dot', [], { 'aria-hidden': 'true' }), 'Waiting for your machine…'], { role: 'status' });
++ box.replaceChildren(mcEl('h4', '', ['Run this on the machine']), mcEl('p', 'muted', ['It installs OpenVibe Node and pairs it with your account. Or type the code into Node yourself:']), code, cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);
++ box.hidden = false;
++ clearInterval(mcWatch);
++ const t0 = Date.now();
++ mcWatch = setInterval(async () => {
++ if (Date.now() - t0 > 15 * 60000) { clearInterval(mcWatch); wait.textContent = 'No machine yet. Pair again for a new code.'; return; }
++ const list = await apiFetch('/api/v1/machines').catch(() => null);
++ const fresh = ((list && list.machines) || []).find((m) => !before.has(m.id));
++ if (!fresh) return;
++ clearInterval(mcWatch);
++ box.hidden = true;
++ mcSay(`${fresh.name || 'Your machine'} is paired. Choose what it does for you below.`);
++ loadMachines();
++ }, 5000);
++ } catch (e) {
++ mcSay(e.message || 'A code could not be made.', true);
++ } finally { btn.disabled = false; }
++}
++
++async function loadMachines() {
++ const list = document.getElementById('mc-list');
++ if (!list) return;
++ try {
++ if (!mcRoles) mcRoles = ((await apiFetch('/api/v1/machines/roles').catch(() => null)) || {}).roles || [];
++ const out = await apiFetch('/api/v1/machines');
++ const machines = (out && out.machines) || [];
++ machines.sort((a, b) => (b.online === true) - (a.online === true));
++ if (!machines.length) {
++ list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop-code'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));
++ return;
++ }
++ list.replaceChildren(...machines.map(mcCard));
++ } catch (e) {
++ list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
++ }
++}
ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex a343ffd5..f4079439 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -162,7 +162,7 @@ function renderStuff(live, hist, nodes) {
cells.push(b);
};
add(live && live.following, 'following');
-- add(nodes && nodes.nodes && nodes.nodes.filter((n) => !n.revoked_at).length, 'machines');
++ add(nodes && nodes.nodes && nodes.nodes.filter((n) => !n.revoked_at).length, 'machines', () => { showSection('machines'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });
add(hist && hist.total, 'pages in your history', () => { showSection('history'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });
row.replaceChildren(...cells);
}
ddiff --git a/public/my.html b/public/my.html
iindex cc3071d9..b3b783c3 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -111,6 +111,7 @@
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI &amp; Data</button>
<button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
++ <button onclick="showSection('machines',this)"><i class="fa-solid fa-laptop-code"></i> Machines</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
</div>
@@@ -361,6 +362,22 @@
</div>
<!-- Security Section -->
++ <!-- Machines (plan L34): the machines you paired, their roles, pairing a new one (my/machines.js). -->
++ <div class="section" id="sec-machines">
++ <div class="card">
++ <div class="mc-top">
++ <div>
++ <h3><span class="icon"><i class="fa-solid fa-laptop-code"></i></span> Your machines</h3>
++ <p class="muted">Computers and servers paired with your account. They run your Codes jobs, host your desks and serve your own models. Only you can pair one or change what it does.</p>
++ </div>
++ <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a machine</button>
++ </div>
++ <div class="mc-pair" id="mc-pair" hidden></div>
++ <p class="mc-status" id="mc-status" role="status"></p>
++ <div class="mc-list" id="mc-list"><p class="muted">Loading your machines…</p></div>
++ </div>
++ </div>
++
<div class="section" id="sec-security">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-lock"></i></span> Change Password</h3>
@@@ -435,6 +452,7 @@
<script src="/js/my/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/today.js" defer></script>
++<script src="/js/my/machines.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/consent.js" defer></script>
ddiff --git a/server/not-found.js b/server/not-found.js
iindex 03d036af..c8ee6cc2 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -12,7 +12,7 @@
// The account hub (public/my.html) and its client-routed sections. my.html maps each path to a
// section (pathSection in its script); a path it does not know is not a page.
--const ACCOUNT_HUB_PATHS = ['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/history'];
++const ACCOUNT_HUB_PATHS = ['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/machines', '/profile', '/billing', '/preferences', '/history'];
const JSON_PREFIXES = ['/api/', '/internal/', '/oauth/'];
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
nnew file mode 100644
iindex 00000000..bf35285f
---- /dev/null
++++ b/test/my-machines.test.js
@@@ -0,0 +1,138 @@
++'use strict';
++// Your machines on /my#machines and /machines (plan L34). my/machines.js on a tiny DOM, with Network's machines hub
++// answered by hand: each machine shows its state, what it runs and its roles as switches; a switch saves the whole
++// set; Rename saves on Enter; Revoke asks a second time in the page; "Pair a machine" shows the code and the command
++// and watches for the machine to arrive; no machines, and signed out, each say so.
++// node test/my-machines.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/machines.js'), 'utf8');
++const HTML = fs.readFileSync(path.join(__dirname, '..', 'public/my.html'), 'utf8');
++const CORE = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/core.js'), 'utf8');
++const NOT_FOUND = require('../server/not-found');
++
++class Node {
++ constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this.hidden = false; this.listeners = {}; this._text = ''; this.disabled = false; this.checked = false; this.value = ''; this.parent = null; }
++ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'value') this.value = String(v); }
++ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
++ append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k; n.parent = this; this.children.push(n); } }
++ replaceChildren(...kids) { this.children = []; this.append(...kids); }
++ replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); p.children[i] = n; n.parent = p; }
++ get childNodes() { return this.children.slice(); }
++ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
++ fire(t, ev = {}) { (this.listeners[t] || []).forEach((fn) => fn(ev)); }
++ focus() {} select() {}
++ get classList() { const n = this; return { toggle: (c, on) => { n.className = on ? `${n.className} ${c}` : n.className.replace(c, ''); }, contains: (c) => n.className.split(' ').includes(c) }; }
++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
++ set textContent(v) { this._text = String(v); this.children = []; }
++ all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
++ querySelectorAll(sel) {
++ if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');
++ return [];
++ }
++}
++
++function page({ answers }) {
++ const ids = { 'mc-list': new Node('div'), 'mc-status': new Node('p'), 'mc-pair': new Node('div'), 'mc-pair-btn': new Node('button'), settings: new Node('h2') };
++ ids['mc-pair'].hidden = true;
++ const calls = [];
++ let timers = [];
++ const sandbox = {
++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelectorAll: (sel) => (sel === '[data-machine]' ? ids['mc-list'].all((n) => n.getAttribute('data-machine')) : []) },
++ console, Date, Number, String, Set, Math, JSON, Promise,
++ navigator: { clipboard: { writeText: async () => {} } },
++ setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
++ apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },
++ };
++ vm.runInNewContext(SRC, sandbox);
++ return { ids, sandbox, calls, tick: async () => { await Promise.all(timers.map((fn) => fn())); } };
++}
++const flush = () => new Promise((r) => setImmediate(r));
++const ROLES = [{ id: 'codes.worker', name: 'Codes worker', shows: 'Runs your coding jobs.' }, { id: 'desks.host', name: 'Desk host', shows: 'Hosts desks.' }, { id: 'ai.host', name: 'AI host', shows: 'Serves your own models.' }];
++const ago = (m) => new Date(Date.now() - m * 60000).toISOString();
++
++// ── The page: a Machines section in Settings, reached at /machines too, its script loaded ──
++assert.ok(HTML.includes('id="sec-machines"') && HTML.includes("showSection('machines',this)"), 'a Machines section and tab');
++assert.ok(HTML.indexOf('<script src="/js/my/machines.js" defer>') > 0, 'its script');
++assert.match(CORE, /'\/machines': 'machines'/, 'the /machines address opens it');
++assert.match(CORE, /if \(id === 'machines' && typeof loadMachines === 'function'\) loadMachines\(\);/, 'it loads when opened');
++assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the account hub, not a 404');
++
++(async () => {
++ // ── Two machines: the online one first, its roles as switches ──
++ let machines = [
++ { id: 'dev_A', kind: 'onboard', online: false, name: 'Old laptop', os: 'Linux', last_seen_at: ago(120), roles: [], running: [] },
++ { id: 'dev_B', kind: 'onboard', online: true, name: 'Studio PC', os: 'Windows 11', node_version: '0.31.0', roles: ['codes.worker'], running: [{ title: 'Fix the WHIP reconnect' }] },
++ ];
++ let p = page({ answers: (m, url, body) => {
++ if (url === '/api/v1/machines/roles') return { roles: ROLES };
++ if (m === 'GET' && url === '/api/v1/machines') return { machines };
++ if (m === 'PUT') return { machine: { ...machines[1], roles: body.roles } };
++ if (m === 'PATCH') return { machine: { ...machines[1], name: body.name } };
++ if (m === 'POST' && /revoke$/.test(url)) return { machine: { ...machines[0] } };
++ return {};
++ } });
++ await p.sandbox.loadMachines();
++ const cards = p.ids['mc-list'].children;
++ assert.deepStrictEqual(cards.map((c) => c.getAttribute('data-machine')), ['dev_B', 'dev_A'], 'online first');
++ assert.match(cards[0].textContent, /Studio PC.*Online.*Windows 11 · Node 0\.31\.0/);
++ assert.match(cards[0].textContent, /Fix the WHIP reconnect/, 'what it runs');
++ assert.match(cards[1].textContent, /Offline · seen 2 h ago/);
++ const boxes = cards[0].querySelectorAll('.mc-role input');
++ assert.deepStrictEqual(boxes.map((b) => [b.value, b.checked]), [['codes.worker', true], ['desks.host', false], ['ai.host', false]], 'a switch per role, the machine\'s own on');
++
++ // A switch saves the whole set.
++ boxes[2].checked = true; boxes[2].fire('change');
++ await flush();
++ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'PUT'), ['PUT', '/api/v1/machines/dev_B/roles', { roles: ['codes.worker', 'ai.host'] }]);
++ assert.match(p.ids['mc-status'].textContent, /picks this up at its next check-in/);
++
++ // Rename on Enter.
++ const rename = cards[0].all((n) => n.tagName === 'BUTTON' && /Rename/.test(n.textContent))[0];
++ rename.fire('click');
++ const input = cards[0].all((n) => n.className === 'mc-rename')[0];
++ input.value = 'Edit bay'; input.fire('keydown', { key: 'Enter' });
++ await flush();
++ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'PATCH'), ['PATCH', '/api/v1/machines/dev_B', { name: 'Edit bay' }]);
++
++ // Revoke asks again, in the page; "Keep it" puts things back; "Revoke for good" revokes.
++ const acts = cards[1].all((n) => n.className === 'mc-acts')[0];
++ acts.children.find((n) => /Revoke/.test(n.textContent)).fire('click');
++ assert.match(acts.textContent, /stops working for you at once/);
++ acts.children.find((n) => /Keep it/.test(n.textContent)).fire('click');
++ assert.ok(!p.calls.some((c) => /revoke$/.test(c[1])), 'nothing revoked on the first press');
++ acts.children.find((n) => /Revoke/.test(n.textContent)).fire('click');
++ acts.children.find((n) => /for good/.test(n.textContent)).fire('click');
++ await flush();
++ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_A/revoke'), 'revoked on the second');
++
++ // ── Pairing: the code and the command, then the machine arriving ──
++ machines = [];
++ p = page({ answers: (m, url) => {
++ if (url === '/api/v1/machines/roles') return { roles: ROLES };
++ if (m === 'POST' && url === '/api/v1/machines/pairings') return { pairing: { code: 'K7Q-4PX', installer: 'curl -fsSL https://openvibe.network/node/install | sh -s -- K7Q-4PX', expires_at: new Date(Date.now() + 600000).toISOString() } };
++ if (m === 'GET' && url === '/api/v1/machines') return { machines };
++ return {};
++ } });
++ await p.sandbox.loadMachines();
++ assert.match(p.ids['mc-list'].textContent, /No machines yet\./, 'none yet: what pairing is for');
++ await p.sandbox.mcPair();
++ assert.strictEqual(p.ids['mc-pair'].hidden, false);
++ assert.match(p.ids['mc-pair'].textContent, /K7Q-4PX/);
++ assert.match(p.ids['mc-pair'].textContent, /curl -fsSL/);
++ assert.match(p.ids['mc-pair'].textContent, /Waiting for your machine/);
++ machines = [{ id: 'dev_N', kind: 'onboard', online: true, name: 'New box', roles: [], running: [] }];
++ await p.tick(); await flush();
++ assert.strictEqual(p.ids['mc-pair'].hidden, true, 'it arrived');
++ assert.match(p.ids['mc-status'].textContent, /New box is paired/);
++
++ // ── Signed out ──
++ p = page({ answers: (m, url) => (url === '/api/v1/machines/roles' ? { roles: [] } : Object.assign(new Error('sign in'), { status: 401 })) });
++ await p.sandbox.loadMachines();
++ assert.match(p.ids['mc-list'].textContent, /Sign in to see your machines/);
++
++ console.log('my machines: state, running, roles as switches saved whole, rename, a second press to revoke, pairing that watches for the machine, empty and signed out');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 8c3b533c..576f049e 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'today.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);