/* Extensions - renders whatever plugins the backend found. Nothing here knows about any specific plugin. Each control in the manifest maps to a widget, and using it POSTs to /api/plugins//. Add a Python file, press Reload, and it appears. */ import { store, get, post, command, toast, num, confirmDialog } from '../core.js'; import { el, card, pageHead, button, note, badge, range, select, input, textarea, emptyState, stat, field, kv, } from '../ui.js'; import { sparkline } from '../charts.js'; export default { id: 'extensions', label: 'Extensions', icon: 'extensions', async render() { const root = el('div.stack'); const body = el('div.stack'); const reloadBtn = button('Reload extensions', async () => { reloadBtn.disabled = true; reloadBtn.querySelector('.ico')?.classList.add('spin'); try { const data = await post('/api/plugins/reload'); store.plugins = data.manifest; toast('Extensions reloaded', `${data.summary.count} loaded${data.summary.errors.length ? `, ${data.summary.errors.length} failed` : ''}`, data.summary.errors.length ? 'warning' : 'good'); paint(); } catch (err) { toast('Reload failed', err.message, 'critical'); } finally { reloadBtn.disabled = false; reloadBtn.querySelector('.ico')?.classList.remove('spin'); } }, { iconName: 'refresh', style: 'primary' }); root.appendChild(pageHead( 'Extensions', 'Custom controls you have added. Drop a Python file into the plugins folder and reload — ' + 'no frontend changes needed.', [reloadBtn], )); root.appendChild(body); /* -- Rendering -------------------------------------------------------- */ const readoutNodes = new Map(); // `${pluginId}.${key}` -> { value, spark } let seriesCache = {}; function paint() { const manifest = store.plugins || { plugins: [], errors: [] }; body.replaceChildren(); readoutNodes.clear(); /* How-to, always visible so the workflow is discoverable. */ body.appendChild(card('How to add a control', { sub: manifest.directory || 'backend/plugins/' }, el('div.stack', { style: { gap: '10px' } }, el('p', { text: 'Create a file in the plugins folder, build a Plugin, decorate a handler. ' + 'It becomes a card on this page the moment you press Reload.', style: { margin: '0', color: 'var(--text-2)', fontSize: '13px' }, }), el('pre', { style: { margin: '0', padding: '13px 15px', background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--mono)', fontSize: '11.5px', lineHeight: '1.65', overflowX: 'auto', color: 'var(--text-2)', }, text: `from backend.plugin_api import Plugin plugin = Plugin(id="my_tool", name="My tool", icon="⚙") @plugin.action("go", label="Do the thing", style="primary") async def go(ctx): await ctx.bridge.play_preset(motion=1002, area=2) return "Done" @plugin.slider("speed", label="Speed", min=0, max=0.6, default=0.2, unit="m/s") async def speed(ctx, value): ctx.storage["speed"] = value return f"Speed {value:.2f}" plugin.readout("speed", "Current speed", unit="m/s", chart=True)`, }), el('div.row.tight', {}, badge('action → button'), badge('slider → range'), badge('toggle → switch'), badge('select → dropdown'), badge('text → input'), badge('number → stepper'), badge('color → picker'), badge('readout → stat tile'), ), note('ctx gives you .bridge (every robot command), .state (live telemetry), ' + '.storage (persists across calls), .log(), .push() and .record().', 'info'), ), )); /* Load errors */ if (manifest.errors?.length) { body.appendChild(card('Extensions that failed to load', {}, el('div.stack', { style: { gap: '10px' } }, ...manifest.errors.map((error) => el('div', { style: { padding: '12px 14px', borderRadius: 'var(--radius-sm)', background: 'rgba(208,59,59,.07)', border: '1px solid rgba(208,59,59,.3)', }, }, el('div.row.between', { style: { marginBottom: '6px' } }, el('strong', { text: error.file, style: { fontSize: '13px' } }), badge('failed', 'critical'), ), el('div', { text: error.error, style: { fontSize: '12px', color: 'var(--text-2)' } }), error.trace ? el('pre', { text: error.trace, style: { margin: '8px 0 0', fontSize: '10.5px', fontFamily: 'var(--mono)', color: 'var(--text-3)', whiteSpace: 'pre-wrap', maxHeight: '150px', overflowY: 'auto', }, }) : null, )), ), )); } /* Plugin cards */ if (!manifest.plugins?.length) { body.appendChild(emptyState( 'No extensions loaded yet', 'The plugins folder has no usable files. Copy _template.py to a new name to start.', )); return; } const grid = el('div.grid.cols-2'); for (const plugin of manifest.plugins) grid.appendChild(renderPlugin(plugin)); body.appendChild(grid); paintReadouts(); } function renderPlugin(plugin) { const controls = el('div.stack', { style: { gap: '13px' } }); for (const control of plugin.controls) { const node = renderControl(plugin, control); if (node) controls.appendChild(node); } const readouts = el('div.grid.cols-2', { style: { gap: '9px' } }); for (const readout of plugin.readouts || []) { const valueNode = el('div.stat-value', { text: '—' }); const sparkHost = readout.chart ? el('div.stat-spark') : null; const tile = el('div.stat', {}, el('div.stat-label', { text: readout.label }), valueNode, sparkHost, ); readoutNodes.set(`${plugin.id}.${readout.key}`, { readout, valueNode, sparkHost }); readouts.appendChild(tile); } return card(plugin.name, { sub: plugin.description, actions: [ el('span', { text: plugin.icon, style: { fontSize: '16px' } }), plugin.has_tick ? badge(`ticks ${plugin.tick_interval}s`, 'accent') : null, ].filter(Boolean), }, el('div.stack', {}, plugin.readouts?.length ? readouts : null, plugin.controls.length ? controls : note('This extension declares no controls.', 'default'), ), ); } function renderControl(plugin, control) { const path = `/api/plugins/${plugin.id}/${control.key}`; const run = async (value, node) => { if (control.confirm) { const ok = await confirmDialog(control.label, control.confirm, { danger: control.style === 'danger' }); if (!ok) return; } if (node) node.disabled = true; try { const data = await post(path, { value }); toast(control.label, data?.message || 'Done', data?.ok === false ? 'critical' : 'good'); } catch (err) { toast(control.label, err.message, 'critical'); } finally { if (node) node.disabled = false; } }; switch (control.kind) { case 'action': { const btn = button(control.label, (node) => run(null, node), { style: control.style || 'default', title: control.help || '', block: true, }); return control.help ? el('div', {}, btn, el('div.hint', { text: control.help, style: { marginTop: '4px' } })) : btn; } case 'slider': { const control_ = range({ min: control.min, max: control.max, step: control.step, value: control.default, unit: control.unit ? ` ${control.unit}` : '', precision: decimalsFor(control.step), onInput: control.live ? throttle((v) => run(v), 200) : undefined, onChange: control.live ? undefined : (v) => run(v), }); return field(control.label, control_, control.help); } case 'toggle': { const box = el('input', { type: 'checkbox', checked: control.default }); box.addEventListener('change', () => run(box.checked)); return el('div', {}, el('label.switch', {}, box, el('span.switch-track'), el('span.switch-label', { text: control.label })), control.help ? el('div.hint', { text: control.help, style: { marginTop: '4px' } }) : null, ); } case 'select': { const node = select(control.options || [], { value: control.default, onChange: (value) => run(value), }); return field(control.label, node, control.help); } case 'text': { const box = control.multiline ? textarea({ placeholder: control.placeholder || '', value: control.default || '' }) : input({ placeholder: control.placeholder || '', value: control.default || '' }); const submit = button(control.submit_label || 'Send', (node) => { const value = box.value; if (!value.trim()) { box.focus(); return; } run(value, node).then(() => { box.value = ''; }); }, { style: 'primary', size: 'sm' }); if (!control.multiline) { box.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit.click(); }); } return field(control.label, el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } }, box, submit), control.help); } case 'number': { const box = input({ type: 'number', value: control.default, min: control.min ?? undefined, max: control.max ?? undefined, step: control.step, }); box.addEventListener('change', () => run(Number(box.value))); return field( control.unit ? `${control.label} (${control.unit})` : control.label, box, control.help, ); } case 'color': { const box = el('input.input', { type: 'color', value: control.default || '#3987e5' }); box.addEventListener('change', () => run(box.value)); return field(control.label, box, control.help); } default: return note(`Unsupported control type "${control.kind}"`, 'warning', '⚠'); } } /* -- Readouts --------------------------------------------------------- */ function paintReadouts() { const custom = store.state?.custom || {}; for (const [key, entry] of readoutNodes) { const [pluginId, readoutKey] = splitOnce(key, '.'); const value = custom[pluginId]?.[readoutKey]; const { readout, valueNode, sparkHost } = entry; if (value === undefined || value === null) { valueNode.textContent = '—'; } else if (readout.format === 'text' || typeof value === 'string') { valueNode.textContent = String(value); valueNode.style.fontSize = '17px'; } else { valueNode.textContent = num(value, readout.precision ?? 2); valueNode.style.fontSize = ''; if (readout.unit) valueNode.appendChild(el('span.unit', { text: readout.unit })); } if (sparkHost) { const points = seriesCache[`${pluginId}.${readoutKey}`] || []; sparkHost.replaceChildren( points.length > 2 ? sparkline(points, { height: 30, colorIndex: 2 }) : el('span'), ); } } } async function refreshSeries() { const wanted = [...readoutNodes.entries()] .filter(([, entry]) => entry.readout.chart) .map(([key]) => key); if (!wanted.length) return; try { seriesCache = await get(`/api/series?keys=${wanted.join(',')}&limit=120`); } catch { /* best-effort */ } } paint(); await refreshSeries(); paintReadouts(); const unsubPlugins = store.on('plugins', () => paint()); let last = 0; const unsubState = store.on('state', () => { const now = Date.now(); if (now - last < 900) return; last = now; paintReadouts(); }); const timer = setInterval(() => refreshSeries().then(paintReadouts), 4000); return { node: root, dispose: () => { unsubPlugins(); unsubState(); clearInterval(timer); }, }; }, }; function decimalsFor(step) { const text = String(step); const dot = text.indexOf('.'); return dot === -1 ? 0 : Math.min(4, text.length - dot - 1); } function splitOnce(text, separator) { const index = text.indexOf(separator); return index === -1 ? [text, ''] : [text.slice(0, index), text.slice(index + 1)]; } function throttle(fn, ms) { let last = 0; return (...args) => { const now = Date.now(); if (now - last < ms) return; last = now; fn(...args); }; }