openvibers/openvibe.network Commits
Your machines on openvibe.network: state, what each runs, its roles as switches, pairing that watches for the machine (#466)
Changes
@@@ -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, neversideways. 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; }@@@ -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)) {nnew file mode 100644@@@ -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.']));++ }++}@@@ -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);}@@@ -111,6 +111,7 @@<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & 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>@@@ -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/'];nnew file mode 100644@@@ -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); });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst 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]);
@@ -325,6 +325,50 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -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[aria-pressed="true"] { color: var(--accent-light); }
.recent-tool-star:hover, .recent-tool-star:focus-visible { 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
/* 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. */
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; }
.consent-fold { margin: 0 0 10px; }
@@ -159,13 +159,14 @@ function showSection(id, btn) {
@@ -159,13 +159,14 @@ function showSection(id, btn) {
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'data') loadDataSection();
if (id === 'data') loadDataSection();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
+ if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
}
}
function openInitialSection() {
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';
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
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');
return showSection('anon');
}
}
if (document.getElementById('sec-' + routeSection)) {
if (document.getElementById('sec-' + routeSection)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -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.']));
+ }
+}
@@ -162,7 +162,7 @@ function renderStuff(live, hist, nodes) {
@@ -162,7 +162,7 @@ function renderStuff(live, hist, nodes) {
cells.push(b);
cells.push(b);
};
};
add(live && live.following, 'following');
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' }); });
add(hist && hist.total, 'pages in your history', () => { showSection('history'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });
row.replaceChildren(...cells);
row.replaceChildren(...cells);
}
}
@@ -111,6 +111,7 @@
@@ -111,6 +111,7 @@
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</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('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('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>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
</div>
</div>
@@ -361,6 +362,22 @@
@@ -361,6 +362,22 @@
</div>
</div>
<!-- Security Section -->
<!-- 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="section" id="sec-security">
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-lock"></i></span> Change Password</h3>
<h3><span class="icon"><i class="fa-solid fa-lock"></i></span> Change Password</h3>
@@ -435,6 +452,7 @@
@@ -435,6 +452,7 @@
<script src="/js/my/core.js" defer></script>
<script src="/js/my/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/today.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/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/consent.js" defer></script>
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
// The account hub (public/my.html) and its client-routed sections. my.html maps each path to a
// 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.
// 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/'];
const JSON_PREFIXES = ['/api/', '/internal/', '/oauth/'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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); });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -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();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// 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');
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 tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);