From 5f490e97e9e7f77da144a06effb72a5a6a0c3011 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Jun 2026 21:19:04 +1000 Subject: [PATCH] control: robust copy (execCommand fallback + feedback) + re-show deploy options per instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy used navigator.clipboard which is undefined over plain-http LAN URL → nothing copied, no feedback. Now falls back to execCommand + flashes Copied. Instances section gains an 'Install' button (for not-yet-activated instances) that toggles the deploy one-liners back up — no need to re-request when the approve panel is gone. Co-Authored-By: Claude Opus 4.8 --- public/views/control.js | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/public/views/control.js b/public/views/control.js index 92c01cc..3c93e54 100644 --- a/public/views/control.js +++ b/public/views/control.js @@ -12,12 +12,35 @@ const A = '/api/control/admin'; const IVCTL_BASE = 'https://ivctl.hynesy.com'; const REGISTER_URL = `${IVCTL_BASE}/register`; +// Robust copy: navigator.clipboard only exists in secure contexts (HTTPS/localhost), +// so over the LAN Control URL (plain http) it's undefined — fall back to execCommand. +// Always flashes "Copied" so there's feedback either way. +function copyText(value, btnEl) { + const flash = () => { if (!btnEl) return; const t = btnEl.textContent; btnEl.textContent = 'Copied'; setTimeout(() => { btnEl.textContent = t; }, 1200); }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(value).then(flash).catch(() => legacyCopy(value, flash)); + } else { + legacyCopy(value, flash); + } +} +function legacyCopy(value, done) { + try { + const ta = document.createElement('textarea'); + ta.value = value; ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.focus(); ta.select(); + document.execCommand('copy'); document.body.removeChild(ta); + done && done(); + } catch { /* the value stays selectable by hand */ } +} + // A label + monospace value + Copy button row (used by the Deploy panel). function copyRow(label, value) { + const copyBtn = el('button', { class: 'ghost', style: { marginRight: '0.35rem' } }, 'Copy'); + copyBtn.addEventListener('click', () => copyText(value, copyBtn)); return el('div', { style: { display: 'flex', gap: '0.4rem', alignItems: 'center', margin: '0.12rem 0' } }, el('span', { class: 'muted', style: { fontSize: '0.66rem', minWidth: '74px' } }, label), el('code', { style: { userSelect: 'all', wordBreak: 'break-all', fontSize: '0.7rem', flex: '1' } }, value), - btn('Copy', () => navigator.clipboard?.writeText(value), 'ghost')); + copyBtn); } // Zero-touch deploy options for a freshly approved instance. The tester runs ONE @@ -191,7 +214,17 @@ async function renderInstances(panel) { const groupSel = select([{ value: '', label: '(none)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))], l.group_id ?? ''); groupSel.onchange = () => patch({ group_id: groupSel.value || null }, 'group changed'); + const row = el('tr', {}); const actions = el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.2rem' } }); + // Re-show the deploy one-liners for an instance not yet activated (the enrollment + // code is still live until it first checks in). Toggles a detail row beneath. + if (l.claim_code) { + actions.appendChild(btn('Install', () => { + const next = row.nextElementSibling; + if (next && next.classList.contains('deploy-detail')) { next.remove(); return; } + row.after(el('tr', { class: 'deploy-detail' }, el('td', { colspan: 8 }, deployPanel(l.claim_code)))); + })); + } if (l.status !== 'suspended') actions.appendChild(btn('Suspend', () => patch({ status: 'suspended' }, 'suspended'))); if (l.status !== 'active') actions.appendChild(btn('Restore', () => patch({ status: 'active' }, 'restored'))); if (l.status !== 'revoked') actions.appendChild(btn('Revoke', () => { if (confirm('Revoke this instance?')) patch({ status: 'revoked' }, 'revoked'); }, 'ghost')); @@ -217,7 +250,7 @@ async function renderInstances(panel) { if (l.expires_at) statusCell.push( el('div', { class: 'muted', style: { fontSize: '0.68rem' } }, `lease ends ${fmtTime(l.expires_at)}`)); - return el('tr', {}, + row.append( td(el('strong', {}, l.label || l.email || `#${l.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, l.email && l.label ? l.email : `instance #${l.id}`)), td(groupSel), @@ -227,6 +260,7 @@ async function renderInstances(panel) { td(l.version || el('span', { class: 'muted' }, 'unknown')), td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(l.last_seen || l.last_seen_at))), td(actions, msg)); + return row; }); mount(panel,