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

607 lines
24 KiB
JavaScript

/* Settings - robot discovery, network, transport and safety limits.
This is where the "works on any IP" promise lives: the dashboard never stores
a fixed robot address, it discovers one on whatever network you are currently
attached to, and it reports every address it is itself reachable on.
*/
import { store, get, post, toast, num } from '../core.js';
import {
el, card, pageHead, button, note, badge, table, input, select, field,
emptyState, toggle, range, kv, segmented, setChildren,
} from '../ui.js';
export default {
id: 'settings',
label: 'Settings',
icon: 'settings',
async render() {
const root = el('div.stack');
let config = store.settings?.values || {};
let network = store.network || {};
root.appendChild(pageHead(
'Settings',
'Connection, transport and safety limits. Changes are saved to config.json immediately.',
));
/* ==================================================================
Robot connection + discovery
================================================================== */
const hostInput = input({ value: config.robot_host || '', placeholder: 'IP address or hostname' });
const probeResult = el('div');
const scanResult = el('div');
const scanProgress = el('div', { style: { display: 'none' } });
const saveHost = async (value) => {
const data = await post('/api/settings', { robot_host: value });
config = data.settings.values;
store.settings = data.settings;
toast('Robot host saved', value || 'cleared',
'good');
if (data.needs_restart) restartNote.style.display = '';
};
const probeBtn = button('Test', async (node) => {
const host = hostInput.value.trim();
if (!host) { hostInput.focus(); return; }
node.disabled = true;
probeResult.replaceChildren(el('span.muted', { text: 'Probing…' }));
try {
const data = await post('/api/network/probe', { host });
const info = data.detail || {};
setChildren(probeResult,
el('div.row', {},
badge(data.ok ? 'reachable' : 'no answer', data.ok ? 'good' : 'critical'),
info.hostname ? badge(info.hostname) : null,
info.open_ports?.length ? badge(`ports ${info.open_ports.join(', ')}`, 'accent') : null,
badge(`${num(info.latency_ms, 0)} ms`),
),
info.is_pc1
? note('This is PC1, the motion-control unit. The documentation forbids using it as a '
+ 'build or run host. Point the dashboard at PC2 instead.', 'critical', '⚠')
: null,
);
} catch (err) {
probeResult.replaceChildren(note(err.message, 'critical', '⚠'));
} finally {
node.disabled = false;
}
});
const scanBtn = button('Scan this network', async (node) => {
node.disabled = true;
scanProgress.style.display = '';
scanProgress.replaceChildren(el('span.muted', { text: 'Starting sweep…' }));
scanResult.replaceChildren();
try {
const data = await post('/api/network/scan', {}, );
const hosts = data.hosts || [];
scanResult.replaceChildren(
el('div.row', { style: { marginBottom: '10px' } },
badge(`${hosts.length} responding`, hosts.length ? 'good' : 'warning'),
...(data.networks || []).map((n) => badge(n, 'accent')),
),
hosts.length
? table([
{ key: 'host', label: 'Address' },
{ key: 'hostname', label: 'Name', get: (r) => r.hostname || '—' },
{ key: 'ports', label: 'Open ports', get: (r) => r.open_ports.join(', ') },
{ key: 'latency', label: 'Latency', align: 'right', get: (r) => `${num(r.latency_ms, 0)} ms` },
{
key: 'tags', label: '',
get: (r) => el('div.row.tight', {},
r.is_self ? badge('this machine', 'default') : null,
r.is_pc1 ? badge('PC1 — do not use', 'critical') : null,
),
},
{
key: 'use', label: '',
get: (r) => button('Use', async () => {
hostInput.value = r.host;
await saveHost(r.host);
}, { size: 'sm', style: r.is_pc1 ? 'warn' : 'primary' }),
},
], hosts)
: emptyState('Nothing answered',
'No host on this subnet responded on the ports an X2 exposes. '
+ 'Check that the robot is powered and on the same network.'),
);
} catch (err) {
scanResult.replaceChildren(note(err.message, 'critical', '⚠'));
} finally {
node.disabled = false;
scanProgress.style.display = 'none';
}
}, { iconName: 'scan', style: 'primary' });
const restartNote = el('div', { style: { display: 'none' } },
note('Transport settings changed. Restart the bridge to apply them.', 'warning', '⚠'),
);
const restartBtn = button('Restart bridge', async (node) => {
node.disabled = true;
try {
const data = await post('/api/bridge/restart');
toast(data.ok ? 'Bridge restarted' : 'Restart failed', data.message,
data.ok ? 'good' : 'critical');
if (data.ok) {
restartNote.style.display = 'none';
const boot = await get('/api/bootstrap');
store.bridge = boot.bridge;
store.state = boot.state;
}
} catch (err) {
toast('Restart failed', err.message, 'critical');
} finally {
node.disabled = false;
}
}, { iconName: 'refresh' });
root.appendChild(card('Robot connection', { sub: 'No fixed address is stored' },
el('div.stack', {},
field('Robot host',
el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } },
hostInput,
probeBtn,
button('Save', () => saveHost(hostInput.value.trim())),
),
'Leave empty to rely on ROS 2 DDS multicast discovery, which finds the robot on the local '
+ 'segment without an address. Set it when you want the dashboard to report and probe a '
+ 'specific host.',
),
probeResult,
el('div.row.between', {},
el('span', { text: 'Find the robot automatically', style: { fontSize: '12px', color: 'var(--text-2)' } }),
scanBtn,
),
scanProgress,
scanResult,
note('The sweep covers whichever subnets this machine is on right now. Move to a different '
+ 'Wi-Fi network and scan again — nothing is cached.', 'info'),
el('div', { style: { borderTop: '1px solid var(--border-soft)', margin: '4px 0 2px' } }),
toggle('Find the robot automatically if its address changes', config.auto_discover,
(value) => saveSetting('auto_discover', value)),
toggle('Start the agent over SSH if the robot is on but not answering',
config.auto_start_agent,
(value) => saveSetting('auto_start_agent', value)),
el('div.grid.cols-2', { style: { gap: '10px' } },
field('SSH user',
input({
value: config.robot_ssh_user,
onchange: (e) => saveSetting('robot_ssh_user', e.target.value),
}),
),
field('SSH password',
input({
type: 'password',
value: config.robot_ssh_password,
placeholder: config.robot_ssh_password ? '' : 'not saved',
onchange: (e) => saveSetting('robot_ssh_password', e.target.value),
}),
),
),
note('The robot cannot start the dashboard agent by itself: the agi account is not '
+ 'allowed to enable systemd lingering, and the robot\'s clock jumps backwards after '
+ 'boot, which stalls cron. With these credentials the dashboard logs in and starts '
+ 'it for you after a power cycle. Stored in config.json on this machine.',
'warning', '⚠'),
restartNote,
),
));
/* ==================================================================
Dashboard reachability
================================================================== */
const urlHost = el('div');
function paintUrls() {
const urls = network.urls || [];
const primary = network.address?.ip
? `http://${network.address.ip}:${network.port}` : null;
const describe = (url) => {
if (url === primary) return { label: 'use this — works everywhere', tone: 'good' };
if (url.includes('localhost') || url.includes('127.0.0.1')) {
return { label: 'this machine only', tone: 'default' };
}
// Names need mDNS, which many access points block between clients.
if (!/\/\/\d+\.\d+\.\d+\.\d+/.test(url)) {
return { label: 'needs mDNS — often blocked on Wi-Fi', tone: 'warning' };
}
return { label: 'other adapter', tone: 'accent' };
};
urlHost.replaceChildren(
el('div.stack', { style: { gap: '8px' } },
...urls.map((url) => {
const meta = describe(url);
return el('div.row.between', {
style: {
padding: '10px 13px', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
},
},
el('span', { text: url, style: { fontFamily: 'var(--mono)', fontSize: '12.5px' } }),
el('div.row.tight', {},
badge(meta.label, meta.tone),
button('Copy', async () => {
try {
await navigator.clipboard.writeText(url);
toast('Copied', url, 'good', 1800);
} catch {
toast('Copy blocked', 'Select and copy the address manually.', 'warning');
}
}, { size: 'sm', style: 'ghost' }),
),
);
}),
),
);
}
const nameHost = el('div');
function paintName() {
const adv = network.advertised;
setChildren(nameHost,
field('Dashboard name on the network',
el('div.row', { style: { gap: '8px', flexWrap: 'nowrap' } },
input({
value: config.dashboard_name,
placeholder: 'agibot',
onchange: (e) => saveSetting('dashboard_name', e.target.value.trim().toLowerCase()),
}),
adv?.running ? badge('publishing', 'good')
: adv?.conflict ? badge('name taken', 'critical')
: badge('not publishing', 'warning'),
),
'The dashboard publishes this over mDNS, so the link is about the robot rather than '
+ 'this PC. Takes effect when the server restarts.',
),
adv?.error ? el('div', { style: { marginTop: '8px' } }, note(adv.error, 'warning', '⚠')) : null,
);
}
const qrHost = el('div');
const devicesHost = el('div');
function paintQr() {
const detail = network.address || {};
if (!detail.ip) {
setChildren(qrHost,
note(`No network address right now — ${detail.reason_text || 'not connected'}. `
+ 'The dashboard keeps checking and will show the link the moment the '
+ 'network is back.', 'warning', '⚠'),
);
return;
}
const url = `http://${detail.ip}:${network.port}`;
setChildren(qrHost,
el('div.row', { style: { gap: '18px', alignItems: 'center', flexWrap: 'wrap' } },
el('img', {
src: `/api/qr?url=${encodeURIComponent(url)}&t=${Date.now()}`,
alt: `QR code for ${url}`,
style: {
width: '156px', height: '156px', background: '#fff',
padding: '8px', borderRadius: 'var(--radius-sm)',
},
}),
el('div', { style: { flex: '1', minWidth: '220px' } },
el('div', { text: 'Open this on your phone',
style: { fontWeight: '600', marginBottom: '6px' } }),
el('div', {
text: url,
style: { fontFamily: 'var(--mono)', fontSize: '17px', color: 'var(--text)' },
}),
(() => {
const kindLabel = detail.kind === 'wifi' ? 'Wi-Fi' : 'wired';
const adapter = detail.adapter || '';
return el('div.row.tight', { style: { marginTop: '8px' } },
badge(kindLabel, detail.kind === 'wifi' ? 'good' : 'accent'),
// The adapter is usually just called "Wi-Fi" too - only worth a
// second badge when it actually says something different.
adapter && adapter.toLowerCase() !== kindLabel.toLowerCase()
? badge(adapter) : null,
detail.gateway ? badge(`via ${detail.gateway}`) : null,
badge('live', 'good'),
);
})(),
el('div.hint', {
text: 'Read from the network adapter and re-checked continuously — if this PC '
+ 'gets a new address the link above updates by itself. Scan or type it; '
+ 'it needs no name resolution, so it works even where .local does not.',
style: { marginTop: '8px' },
}),
el('div.row.tight', { style: { marginTop: '8px' } },
button('Copy link', async () => {
try {
await navigator.clipboard.writeText(url);
toast('Copied', url, 'good', 1800);
} catch {
toast('Copy blocked', 'Select and copy it manually.', 'warning');
}
}, { size: 'sm' }),
),
),
),
(detail.rejected || []).length
? el('details', { style: { marginTop: '10px' } },
el('summary', {
text: `${detail.rejected.length} other address(es) ignored`,
style: { cursor: 'pointer', fontSize: '12px', color: 'var(--text-3)' },
}),
el('div', { style: { marginTop: '8px' } },
table([
{ key: 'ip', label: 'Address' },
{ key: 'adapter', label: 'Adapter' },
{ key: 'why', label: 'Why it is not used' },
], detail.rejected),
),
)
: null,
);
}
function paintDevices() {
const devices = network.other_devices || [];
setChildren(devicesHost,
el('div.row', { style: { marginBottom: '8px' } },
el('strong', { text: 'Devices that have opened this dashboard',
style: { fontSize: '12.5px' } }),
badge(String(devices.length), devices.length ? 'good' : 'warning'),
),
devices.length
? table([
{ key: 'address', label: 'Address' },
{ key: 'hits', label: 'Requests', align: 'right' },
{
key: 'agent', label: 'Browser',
get: (r) => {
const a = r.agent || '';
if (/iPhone|iPad/i.test(a)) return 'iPhone / iPad';
if (/Android/i.test(a)) return 'Android';
if (/Macintosh/i.test(a)) return 'Mac';
if (/Windows/i.test(a)) return 'Windows';
return a.slice(0, 28) || '—';
},
},
], devices)
: note('Nothing but this PC has loaded the dashboard yet. If your phone appears here '
+ 'after scanning the QR code, the network is fine and only the .local name is '
+ 'being blocked — which is the access point filtering multicast, not something '
+ 'this dashboard can fix.', 'warning', '⚠'),
);
}
root.appendChild(card('Open on another device', {
sub: 'The server binds every interface',
actions: [button('Refresh', async () => {
network = await get('/api/network');
store.network = network;
paintUrls();
paintName();
paintQr();
paintDevices();
}, { size: 'sm', style: 'ghost', iconName: 'refresh' })],
},
el('div.stack', {},
qrHost,
urlHost,
nameHost,
devicesHost,
note('The .local link goes over mDNS, which iPhone, iPad, Mac, Android 12+ and Windows '
+ 'understand. Many access points block multicast between wireless clients, and then '
+ 'no .local name resolves from a phone no matter what the PC does — scan the QR code '
+ 'instead.', 'info'),
),
));
paintUrls();
paintName();
paintQr();
paintDevices();
// The server pushes a "network" message whenever the detected address
// changes, so a page left open never shows a link that stopped working.
const unsubscribeNetwork = store.on('network', (data) => {
network = { ...network, ...data };
store.network = network;
paintUrls();
paintQr();
toast('Network address changed',
data.address?.ip ? `Now at ${data.address.ip}` : 'No network address',
data.address?.ip ? 'good' : 'warning');
});
/* ==================================================================
Transport
================================================================== */
const transportFields = el('div.stack');
function paintTransport() {
const bridge = store.bridge || {};
const online = store.state?.connection?.online;
setChildren(transportFields,
el('div.row', { style: { marginBottom: '4px' } },
badge(bridge.simulated ? 'Simulation' : online ? 'Live' : 'Robot off',
bridge.simulated ? 'warning' : online ? 'good' : 'critical'),
bridge.agent?.agent_version ? badge(`agent ${bridge.agent.agent_version}`, 'accent') : null,
bridge.agent?.hostname ? badge(bridge.agent.hostname) : null,
),
bridge.simulated
? note('No robot address is set, so the dashboard is driving the built-in simulator. '
+ 'Enter the robot address above and restart the bridge to attach to the real X2.',
'warning', '⚠')
: null,
bridge.error ? note(bridge.error, 'critical', '⚠') : null,
field('Bridge mode',
select([
{ value: 'auto', label: 'Auto — use the robot agent when an address is set' },
{ value: 'agent', label: 'Robot only — never fall back to simulation' },
{ value: 'mock', label: 'Simulation only — never touch a real robot' },
], {
value: config.bridge_mode,
onChange: (value) => saveSetting('bridge_mode', value),
}),
),
field('Agent port',
input({
type: 'number', min: 1, max: 65535, value: config.agent_port,
onchange: (e) => saveSetting('agent_port', Number(e.target.value)),
}),
'The TCP port x2_agent.py listens on aboard the robot.',
),
field('ROS_DOMAIN_ID',
input({
type: 'number', min: 0, max: 232, value: config.ros_domain_id,
onchange: (e) => saveSetting('ros_domain_id', Number(e.target.value)),
}),
'Reference only — the agent inherits the domain from the shell that launched it.',
),
el('div.row', {}, restartBtn),
);
}
/* ==================================================================
Safety and server
================================================================== */
const limits = store.spec?.velocity_limits || {};
const safetyCard = card('Safety limits', { sub: 'Applied server-side to every command' },
el('div.stack', {},
el('div', {},
el('div', { text: 'Max forward velocity', style: labelStyle }),
range({
min: 0.1, max: limits.forward?.max ?? 0.8, step: 0.05,
value: config.max_forward_velocity, precision: 2, unit: ' m/s',
onChange: (v) => saveSetting('max_forward_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Max lateral velocity', style: labelStyle }),
range({
min: 0.1, max: limits.lateral?.max ?? 0.7, step: 0.05,
value: config.max_lateral_velocity, precision: 2, unit: ' m/s',
onChange: (v) => saveSetting('max_lateral_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Max yaw rate', style: labelStyle }),
range({
min: 0.1, max: limits.angular?.max ?? 0.8, step: 0.05,
value: config.max_angular_velocity, precision: 2, unit: ' rad/s',
onChange: (v) => saveSetting('max_angular_velocity', v),
}),
),
el('div', {},
el('div', { text: 'Dead-man timeout', style: labelStyle }),
range({
min: 0.2, max: 3, step: 0.1,
value: config.locomotion_deadman_s, precision: 1, unit: ' s',
onChange: (v) => saveSetting('locomotion_deadman_s', v),
}),
el('div.hint', { text: 'Velocity is zeroed if the browser stops sending for this long.' }),
),
toggle('Confirm before zero-torque', config.require_confirm_zero_torque,
(value) => saveSetting('require_confirm_zero_torque', value)),
),
);
const serverCard = card('Server', { sub: store.settings?.config_path || 'config.json' },
el('div.stack', {},
field('Display name',
input({
value: config.robot_label,
onchange: (e) => saveSetting('robot_label', e.target.value),
}),
),
field('HTTP port',
input({
type: 'number', min: 1, max: 65535, value: config.port,
onchange: (e) => saveSetting('port', Number(e.target.value)),
}),
'Takes effect the next time the server starts.',
),
el('div', {},
el('div', { text: 'Telemetry rate', style: labelStyle }),
range({
min: 1, max: 30, step: 1, value: config.telemetry_hz, precision: 0, unit: ' Hz',
onChange: (v) => saveSetting('telemetry_hz', v),
}),
el('div.hint', { text: 'How often state is pushed to the browser. Lower it on a weak link.' }),
),
el('div', { style: { marginTop: '4px' } },
kv([
['Server version', store.server?.version || '—'],
['Connected clients', String(store.server?.clients ?? '—')],
['Config file', store.settings?.config_path || '—'],
]),
),
),
);
root.appendChild(el('div.grid.cols-2', {},
card('Transport', { sub: 'How the dashboard reaches the robot' }, transportFields),
safetyCard,
));
root.appendChild(serverCard);
paintTransport();
/* ==================================================================
Helpers
================================================================== */
async function saveSetting(key, value) {
try {
const data = await post('/api/settings', { [key]: value });
config = data.settings.values;
store.settings = data.settings;
if (data.needs_restart) restartNote.style.display = '';
toast('Saved', `${key} = ${value}`, 'good', 1800);
} catch (err) {
toast('Could not save', err.message, 'critical');
}
}
const unsubscribe = store.on('scan_progress', (data) => {
scanProgress.replaceChildren(
el('div', {},
el('div.row.between', { style: { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' } },
el('span', { text: 'Sweeping subnet' }),
el('span', { text: `${data.done} / ${data.total}` }),
),
el('div.meter', {},
el('div.meter-fill', { style: { width: `${(data.done / data.total) * 100}%` } }),
),
),
);
});
return {
node: root,
dispose: () => { unsubscribe(); unsubscribeNetwork(); },
};
},
};
const labelStyle = { fontSize: '12px', color: 'var(--text-2)', marginBottom: '5px' };