607 lines
24 KiB
JavaScript
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' };
|