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 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,
|
||||
|
||||
Reference in New Issue
Block a user