364 lines
13 KiB
JavaScript
364 lines
13 KiB
JavaScript
/* 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/<id>/<control>. 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);
|
|
};
|
|
}
|