539 lines
18 KiB
JavaScript
539 lines
18 KiB
JavaScript
/* ==========================================================================
|
|
Application shell: bootstrap, rail, routing, topbar, global shortcuts.
|
|
========================================================================== */
|
|
|
|
import { store, socket, get, post, toast, num, batteryTone, confirmDialog } from './core.js';
|
|
import { el, clear, icon, emptyState, button, badge } from './ui.js';
|
|
|
|
import overview from './tabs/overview.js';
|
|
import control from './tabs/control.js';
|
|
import motion from './tabs/motion.js';
|
|
import sensors from './tabs/sensors.js';
|
|
import vision from './tabs/vision.js';
|
|
import lidar from './tabs/lidar.js';
|
|
import model from './tabs/model.js';
|
|
import interaction from './tabs/interaction.js';
|
|
import power from './tabs/power.js';
|
|
import navigation from './tabs/navigation.js';
|
|
import extensions from './tabs/extensions.js';
|
|
import consoleTab from './tabs/console.js';
|
|
import settings from './tabs/settings.js';
|
|
|
|
const TABS = [
|
|
{ ...overview, group: 'Monitor' },
|
|
{ ...power, group: 'Monitor' },
|
|
{ ...sensors, group: 'Monitor' },
|
|
{ ...vision, group: 'Monitor' },
|
|
{ ...lidar, group: 'Monitor' },
|
|
{ ...model, group: 'Monitor' },
|
|
{ ...control, group: 'Operate' },
|
|
{ ...motion, group: 'Operate' },
|
|
{ ...interaction, group: 'Operate' },
|
|
{ ...navigation, group: 'Operate' },
|
|
{ ...extensions, group: 'System' },
|
|
{ ...consoleTab, group: 'System' },
|
|
{ ...settings, group: 'System' },
|
|
];
|
|
|
|
// Report which build this bundle came from. index.html (always served fresh)
|
|
// compares it against the server's build and warns if they differ, which is the
|
|
// only reliable way to notice that a browser served a stale cached bundle.
|
|
try {
|
|
window.__MAIN_BUILD__ = new URL(import.meta.url).searchParams.get('b') || 'dev';
|
|
} catch { /* non-critical */ }
|
|
|
|
const content = document.getElementById('content');
|
|
const rail = document.getElementById('rail');
|
|
|
|
let activeId = null;
|
|
let activeDispose = null;
|
|
|
|
/* -- Routing -------------------------------------------------------------- */
|
|
|
|
function buildRail() {
|
|
clear(rail);
|
|
let lastGroup = null;
|
|
|
|
for (const tab of TABS) {
|
|
if (tab.group !== lastGroup) {
|
|
rail.appendChild(el('div.rail-group', { text: tab.group }));
|
|
lastGroup = tab.group;
|
|
}
|
|
const item = el('button.rail-item', {
|
|
type: 'button',
|
|
dataset: { tab: tab.id },
|
|
'aria-current': tab.id === activeId ? 'page' : 'false',
|
|
onclick: () => navigate(tab.id),
|
|
},
|
|
icon(tab.icon),
|
|
el('span', { text: tab.label }),
|
|
);
|
|
rail.appendChild(item);
|
|
}
|
|
}
|
|
|
|
function markActive() {
|
|
for (const item of rail.querySelectorAll('.rail-item')) {
|
|
item.setAttribute('aria-current', item.dataset.tab === activeId ? 'page' : 'false');
|
|
}
|
|
}
|
|
|
|
export function setRailBadge(tabId, value, tone = 'default') {
|
|
const item = rail.querySelector(`.rail-item[data-tab="${tabId}"]`);
|
|
if (!item) return;
|
|
let badge = item.querySelector('.rail-badge');
|
|
if (!value) { badge?.remove(); return; }
|
|
if (!badge) {
|
|
badge = el('span.rail-badge');
|
|
item.appendChild(badge);
|
|
}
|
|
badge.textContent = String(value);
|
|
badge.dataset.tone = tone;
|
|
}
|
|
|
|
async function navigate(id, { replace = false } = {}) {
|
|
const tab = TABS.find((t) => t.id === id) || TABS[0];
|
|
if (activeDispose) { try { activeDispose(); } catch (err) { console.error(err); } }
|
|
activeDispose = null;
|
|
activeId = tab.id;
|
|
|
|
markActive();
|
|
if (replace) history.replaceState({ tab: tab.id }, '', `#${tab.id}`);
|
|
else if (location.hash.slice(1) !== tab.id) history.pushState({ tab: tab.id }, '', `#${tab.id}`);
|
|
|
|
clear(content);
|
|
content.scrollTop = 0;
|
|
|
|
try {
|
|
const result = await tab.render();
|
|
if (result && result.node) {
|
|
content.appendChild(result.node);
|
|
activeDispose = result.dispose || null;
|
|
} else if (result instanceof Node) {
|
|
content.appendChild(result);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Tab "${tab.id}" failed:`, err);
|
|
const stale = window.__MAIN_BUILD__ !== window.__BUILD__;
|
|
content.appendChild(emptyState(
|
|
'This section failed to render',
|
|
stale
|
|
? `${err?.message || err}\n\nThis page is running an outdated copy of the dashboard, `
|
|
+ 'which is very likely the cause. Load the current version and try again.'
|
|
: String(err?.message || err),
|
|
el('div.row', {},
|
|
button('Retry', () => navigate(tab.id, { replace: true }), { style: 'primary' }),
|
|
stale
|
|
? button('Load the current version',
|
|
() => location.replace(`${location.pathname}?r=${Date.now()}`))
|
|
: null,
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
window.addEventListener('popstate', () => {
|
|
const id = location.hash.slice(1);
|
|
if (id && id !== activeId) navigate(id, { replace: true });
|
|
});
|
|
|
|
/* -- Topbar --------------------------------------------------------------- */
|
|
|
|
const pills = {
|
|
connection: document.getElementById('pill-connection'),
|
|
mode: document.getElementById('pill-mode'),
|
|
battery: document.getElementById('pill-battery'),
|
|
host: document.getElementById('pill-host'),
|
|
};
|
|
|
|
function setPill(node, label, { state, level } = {}) {
|
|
node.querySelector('.pill-label').textContent = label;
|
|
if (state) node.querySelector('.dot')?.setAttribute('data-state', state);
|
|
if (level) node.dataset.level = level; else delete node.dataset.level;
|
|
}
|
|
|
|
function updateTopbar() {
|
|
const state = store.state;
|
|
const bridge = store.bridge;
|
|
|
|
if (!store.connected) {
|
|
setPill(pills.connection, 'Reconnecting…', { state: 'offline' });
|
|
} else if (bridge?.simulated) {
|
|
setPill(pills.connection, 'Simulation', { state: 'sim' });
|
|
pills.connection.title = 'No robot attached - the dashboard is running against the built-in simulator.';
|
|
} else if (state?.connection?.online) {
|
|
setPill(pills.connection, 'Live', { state: 'online' });
|
|
pills.connection.title = `Connected to the robot agent at ${state.connection.host}`;
|
|
} else {
|
|
setPill(pills.connection, 'Robot off', { state: 'offline' });
|
|
pills.connection.title = state?.connection?.error || 'The robot agent is not reachable.';
|
|
}
|
|
|
|
const modeSpec = store.spec?.modes?.find((m) => m.id === state?.mode);
|
|
setPill(pills.mode, modeSpec?.label || state?.mode || '—', {
|
|
level: modeSpec?.danger ? 'critical' : undefined,
|
|
});
|
|
|
|
const pct = state?.battery_pct;
|
|
const tone = batteryTone(pct);
|
|
setPill(pills.battery, pct === null || pct === undefined ? '—' : `${num(pct, 0)}%`, {
|
|
level: tone === 'good' ? undefined : tone,
|
|
});
|
|
|
|
const host = state?.connection?.host || store.settings?.values?.robot_host;
|
|
setPill(pills.host, host || 'not set');
|
|
pills.host.dataset.optional = '';
|
|
}
|
|
|
|
/* -- Offline gate ---------------------------------------------------------
|
|
|
|
The dashboard server runs on this machine, not on the robot, so the page
|
|
stays up when the X2 is switched off. That is the whole point of the gate:
|
|
instead of a dead link, the operator gets "power the robot on", and the
|
|
dashboard reattaches by itself the moment the agent answers again.
|
|
-------------------------------------------------------------------------- */
|
|
|
|
const gate = {
|
|
root: document.getElementById('gate'),
|
|
title: document.getElementById('gate-title'),
|
|
message: document.getElementById('gate-message'),
|
|
status: document.getElementById('gate-status-text'),
|
|
facts: document.getElementById('gate-facts'),
|
|
hint: document.getElementById('gate-hint'),
|
|
address: document.getElementById('gate-address'),
|
|
hostInput: document.getElementById('gate-host'),
|
|
results: document.getElementById('gate-results'),
|
|
visible: false,
|
|
since: 0,
|
|
poll: null,
|
|
busy: false,
|
|
};
|
|
|
|
function gateReason() {
|
|
// Simulation is a deliberate choice, not an outage - never gate on it.
|
|
if (store.bridge?.simulated) return null;
|
|
if (!store.connected) return 'server';
|
|
if (!store.state?.connection?.online) return 'robot';
|
|
return null;
|
|
}
|
|
|
|
function showGate(reason) {
|
|
const host = store.settings?.values?.robot_host || '—';
|
|
const port = store.settings?.values?.agent_port || 8781;
|
|
|
|
if (reason === 'server') {
|
|
gate.title.textContent = 'Dashboard server not responding';
|
|
gate.message.textContent =
|
|
'The page loaded but lost its connection to the dashboard server. '
|
|
+ 'It will reconnect automatically once the server is running again.';
|
|
gate.hint.textContent = 'This is the server on this computer, not the robot.';
|
|
} else {
|
|
gate.title.textContent = 'Robot is powered off';
|
|
gate.message.textContent =
|
|
'The dashboard cannot reach the X2. Switch the robot on and this page will '
|
|
+ 'connect by itself — no need to reload.';
|
|
gate.hint.textContent =
|
|
'If the robot is already on, check that it is on this Wi-Fi network and that '
|
|
+ 'the dashboard agent is running on it.';
|
|
}
|
|
|
|
const error = store.state?.connection?.error || store.bridge?.error || '';
|
|
clear(gate.facts);
|
|
const facts = [
|
|
['Robot address', host === '—' ? 'not set' : `${host}:${port}`],
|
|
['Last seen', gate.since ? new Date(gate.since).toLocaleTimeString([], { hour12: false }) : 'not yet this session'],
|
|
];
|
|
if (error) facts.push(['Detail', error]);
|
|
for (const [key, value] of facts) {
|
|
gate.facts.appendChild(el('dt', { text: key }));
|
|
gate.facts.appendChild(el('dd', { text: value }));
|
|
}
|
|
|
|
if (!gate.visible) {
|
|
gate.visible = true;
|
|
gate.root.hidden = false;
|
|
document.getElementById('app').setAttribute('inert', '');
|
|
if (!gate.hostInput.value) {
|
|
gate.hostInput.value = store.settings?.values?.robot_host || '';
|
|
}
|
|
if (!gate.poll) gate.poll = setInterval(pollRobot, 2500);
|
|
}
|
|
}
|
|
|
|
function hideGate() {
|
|
if (!gate.visible) return;
|
|
gate.visible = false;
|
|
gate.root.hidden = true;
|
|
document.getElementById('app').removeAttribute('inert');
|
|
if (gate.poll) { clearInterval(gate.poll); gate.poll = null; }
|
|
toast('Robot connected', 'Live telemetry is flowing again.', 'good');
|
|
// Re-render the current tab so it rebuilds against real data.
|
|
if (activeId) navigate(activeId, { replace: true });
|
|
}
|
|
|
|
async function pollRobot() {
|
|
// Anything the operator kicked off owns the status line until it finishes.
|
|
if (gate.busy) return;
|
|
try {
|
|
const status = await get('/api/robot/status');
|
|
if (status.online) {
|
|
gate.status.textContent = 'Robot answered — reconnecting…';
|
|
store.state = store.state || {};
|
|
store.state.connection = { ...(store.state.connection || {}), online: true };
|
|
updateGate();
|
|
return;
|
|
}
|
|
// The bridge reports what its automatic recovery is doing; showing that is
|
|
// far more use than a fixed "waiting" message.
|
|
gate.status.textContent = status.recovery
|
|
|| `Waiting for the robot at ${status.host || 'no address set'}…`;
|
|
|
|
if (!status.has_ssh_password && status.auto_start_agent) {
|
|
gate.hint.textContent =
|
|
'Tip: save the robot\'s SSH password in Settings and the dashboard can start the '
|
|
+ 'agent for you after a power cycle, instead of waiting for the robot to do it.';
|
|
}
|
|
} catch {
|
|
gate.status.textContent = 'Waiting for the dashboard server…';
|
|
}
|
|
}
|
|
|
|
function updateGate() {
|
|
const reason = gateReason();
|
|
if (reason) showGate(reason);
|
|
else {
|
|
if (store.state?.connection?.online) gate.since = Date.now();
|
|
hideGate();
|
|
}
|
|
}
|
|
|
|
document.getElementById('gate-retry').addEventListener('click', async (event) => {
|
|
const button = event.currentTarget;
|
|
button.disabled = true;
|
|
gate.busy = true;
|
|
gate.status.textContent = 'Reconnecting…';
|
|
try {
|
|
await post('/api/bridge/restart');
|
|
gate.busy = false;
|
|
await pollRobot();
|
|
} catch (err) {
|
|
gate.status.textContent = err.message;
|
|
} finally {
|
|
gate.busy = false;
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.getElementById('gate-toggle-address').addEventListener('click', (event) => {
|
|
const shown = !gate.address.hidden;
|
|
gate.address.hidden = shown;
|
|
event.currentTarget.textContent = shown ? 'Set address manually' : 'Hide address box';
|
|
if (!shown) gate.hostInput.focus();
|
|
});
|
|
|
|
async function saveGateHost(host) {
|
|
if (!host) return;
|
|
gate.busy = true;
|
|
gate.status.textContent = `Connecting to ${host}…`;
|
|
try {
|
|
await post('/api/settings', { robot_host: host });
|
|
await post('/api/bridge/restart');
|
|
// Give the bridge a moment to dial before we report anything.
|
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
gate.busy = false;
|
|
await pollRobot();
|
|
} catch (err) {
|
|
gate.status.textContent = `Could not connect: ${err.message}`;
|
|
} finally {
|
|
gate.busy = false;
|
|
}
|
|
}
|
|
|
|
document.getElementById('gate-save').addEventListener('click',
|
|
() => saveGateHost(gate.hostInput.value.trim()));
|
|
gate.hostInput.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') saveGateHost(gate.hostInput.value.trim());
|
|
});
|
|
|
|
document.getElementById('gate-find').addEventListener('click', async (event) => {
|
|
const button = event.currentTarget;
|
|
button.disabled = true;
|
|
gate.busy = true;
|
|
gate.results.hidden = true;
|
|
gate.status.textContent = 'Scanning this network for the robot…';
|
|
|
|
try {
|
|
const data = await post('/api/robot/find');
|
|
const found = data.candidates || [];
|
|
clear(gate.results);
|
|
gate.results.hidden = !found.length;
|
|
|
|
if (!found.length) {
|
|
gate.status.textContent =
|
|
'Nothing found. Is the robot powered on and joined to this Wi-Fi?';
|
|
return;
|
|
}
|
|
|
|
gate.status.textContent = `Found ${found.length} device${found.length > 1 ? 's' : ''}.`;
|
|
for (const item of found) {
|
|
const ready = item.agent;
|
|
gate.results.appendChild(el('div.gate-result', {},
|
|
el('span.host', { text: item.host }),
|
|
item.hostname ? el('span', { text: item.hostname, style: { color: 'var(--text-3)' } }) : null,
|
|
badge(ready ? 'agent running' : 'reachable, agent off', ready ? 'good' : 'warning'),
|
|
el('span.spacer'),
|
|
button_(ready ? 'Connect' : 'Start & connect', async (btn) => {
|
|
btn.disabled = true;
|
|
gate.busy = true;
|
|
try {
|
|
if (!ready) {
|
|
gate.status.textContent = `Starting the agent on ${item.host}…`;
|
|
const woke = await post('/api/robot/wake', { host: item.host });
|
|
if (!woke.ok) {
|
|
gate.status.textContent = woke.message;
|
|
return;
|
|
}
|
|
}
|
|
await saveGateHost(item.host);
|
|
} finally {
|
|
gate.busy = false;
|
|
btn.disabled = false;
|
|
}
|
|
}),
|
|
));
|
|
}
|
|
} catch (err) {
|
|
gate.status.textContent = `Scan failed: ${err.message}`;
|
|
} finally {
|
|
gate.busy = false;
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
/* Small local button helper - the gate is built before the UI module's styles
|
|
are relevant, and it only needs the one variant. */
|
|
function button_(label, onClick) {
|
|
const node = el('button.btn.btn-sm', { type: 'button', text: label });
|
|
node.addEventListener('click', () => onClick(node));
|
|
return node;
|
|
}
|
|
|
|
/* -- Theme ---------------------------------------------------------------- */
|
|
|
|
function initTheme() {
|
|
const saved = localStorage.getItem('x2-theme');
|
|
if (saved) document.documentElement.dataset.theme = saved;
|
|
|
|
document.getElementById('btn-theme').addEventListener('click', () => {
|
|
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.dataset.theme = next;
|
|
localStorage.setItem('x2-theme', next);
|
|
store.emit('theme', next);
|
|
// Charts read their colours from CSS variables, so a re-render picks up the
|
|
// new palette rather than keeping stale hexes.
|
|
if (activeId) navigate(activeId, { replace: true });
|
|
});
|
|
}
|
|
|
|
/* -- Global controls ------------------------------------------------------ */
|
|
|
|
function initStop() {
|
|
const stop = async () => {
|
|
socket.send('stop', {});
|
|
try {
|
|
await post('/api/stop');
|
|
toast('Stopped', 'Velocity zeroed', 'warning');
|
|
} catch (err) {
|
|
toast('Stop failed', err.message, 'critical');
|
|
}
|
|
};
|
|
|
|
document.getElementById('btn-stop').addEventListener('click', stop);
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName);
|
|
|
|
if (event.code === 'Space' && !typing) {
|
|
event.preventDefault();
|
|
stop();
|
|
return;
|
|
}
|
|
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
|
|
// Number keys jump between sections.
|
|
const index = Number(event.key) - 1;
|
|
if (Number.isInteger(index) && index >= 0 && index < TABS.length) {
|
|
navigate(TABS[index].id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* -- Boot ----------------------------------------------------------------- */
|
|
|
|
async function boot() {
|
|
initTheme();
|
|
initStop();
|
|
|
|
try {
|
|
const data = await get('/api/bootstrap');
|
|
store.spec = data.spec;
|
|
store.settings = data.settings;
|
|
store.state = data.state;
|
|
store.plugins = data.plugins;
|
|
store.bridge = data.bridge;
|
|
store.network = data.network;
|
|
store.server = data.server;
|
|
store.events = data.events || [];
|
|
} catch (err) {
|
|
clear(content);
|
|
content.appendChild(emptyState(
|
|
'Cannot reach the dashboard server',
|
|
`${err.message}\n\nThe page loaded but /api/bootstrap did not answer. Check that the server process is still running, then reload.`,
|
|
button('Reload', () => location.reload(), { style: 'primary' }),
|
|
));
|
|
return;
|
|
}
|
|
|
|
const name = store.settings?.values?.robot_label || 'AGIBOT X2';
|
|
document.getElementById('brand-name').textContent = name;
|
|
document.getElementById('brand-sub').textContent =
|
|
store.bridge?.simulated ? 'Simulation mode' : 'Live control';
|
|
|
|
buildRail();
|
|
updateTopbar();
|
|
updateGate();
|
|
|
|
store.on('state', () => { updateTopbar(); updateGate(); });
|
|
store.on('link', () => { updateTopbar(); updateGate(); });
|
|
store.on('settings', (data) => {
|
|
store.settings = data;
|
|
document.getElementById('brand-name').textContent = data.values.robot_label || 'AGIBOT X2';
|
|
updateTopbar();
|
|
});
|
|
store.on('plugins', (data) => {
|
|
store.plugins = data;
|
|
setRailBadge('extensions', data.errors?.length || 0, 'bad');
|
|
});
|
|
store.on('command_error', (data) => toast('Blocked', data.message, 'warning'));
|
|
|
|
setRailBadge('extensions', store.plugins?.errors?.length || 0, 'bad');
|
|
|
|
socket.connect();
|
|
|
|
const initial = location.hash.slice(1);
|
|
await navigate(TABS.some((t) => t.id === initial) ? initial : TABS[0].id, { replace: true });
|
|
|
|
if (store.bridge?.simulated) {
|
|
toast(
|
|
'Simulation mode',
|
|
'No robot address is configured, so the dashboard is driving a simulated X2. '
|
|
+ 'Set the address in Settings to attach to the real one.',
|
|
'warning', 9000,
|
|
);
|
|
}
|
|
}
|
|
|
|
boot();
|
|
|
|
export { navigate, TABS };
|