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

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