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:
@@ -12,12 +12,35 @@ const A = '/api/control/admin';
|
|||||||
const IVCTL_BASE = 'https://ivctl.hynesy.com';
|
const IVCTL_BASE = 'https://ivctl.hynesy.com';
|
||||||
const REGISTER_URL = `${IVCTL_BASE}/register`;
|
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).
|
// A label + monospace value + Copy button row (used by the Deploy panel).
|
||||||
function copyRow(label, value) {
|
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' } },
|
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('span', { class: 'muted', style: { fontSize: '0.66rem', minWidth: '74px' } }, label),
|
||||||
el('code', { style: { userSelect: 'all', wordBreak: 'break-all', fontSize: '0.7rem', flex: '1' } }, value),
|
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
|
// 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 ?? '');
|
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');
|
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' } });
|
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 !== 'suspended') actions.appendChild(btn('Suspend', () => patch({ status: 'suspended' }, 'suspended')));
|
||||||
if (l.status !== 'active') actions.appendChild(btn('Restore', () => patch({ status: 'active' }, 'restored')));
|
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'));
|
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(
|
if (l.expires_at) statusCell.push(
|
||||||
el('div', { class: 'muted', style: { fontSize: '0.68rem' } }, `lease ends ${fmtTime(l.expires_at)}`));
|
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}`),
|
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}`)),
|
el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, l.email && l.label ? l.email : `instance #${l.id}`)),
|
||||||
td(groupSel),
|
td(groupSel),
|
||||||
@@ -227,6 +260,7 @@ async function renderInstances(panel) {
|
|||||||
td(l.version || el('span', { class: 'muted' }, 'unknown')),
|
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(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(l.last_seen || l.last_seen_at))),
|
||||||
td(actions, msg));
|
td(actions, msg));
|
||||||
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
mount(panel,
|
mount(panel,
|
||||||
|
|||||||
Reference in New Issue
Block a user