/* ========================================================================== 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 };