2026-08-13 16:23:18 +04:00

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);
};
}