control: robust copy (execCommand fallback + feedback) + re-show deploy options per instance

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 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-24 21:19:04 +10:00
parent 676c80984f
commit 5f490e97e9

View File

@@ -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,