/** * Brain-5D Operator Console — ES Module * * This module is a pure ES module. It does NOT self-initialize and does NOT * register any DOMContentLoaded listeners. The sole lifecycle owner is * `app.js`, which imports and instantiates `OperatorConsole` exactly once when * the Console tab is first activated. * * Canonical command contract (unified with ControlPanel): * POST /api/control { "command": "run_ticks", "ticks": 100 } * * No CommonJS fallbacks. No `module.exports`. No global side effects on import. * * @version 2.1.0 * @license MIT */ "use strict"; import { readJson } from "./api-client.js"; import { StructuralProposalPanel } from './structural-proposal-panel.js'; // ============================================================================ // DOM Helpers // ============================================================================ /** * Get element by ID with type safety. * @param {string} id - Element ID * @returns {HTMLElement | null} */ function byId(id) { return document.getElementById(id); } /** * Get element by ID or throw. * @param {string} id - Element ID * @param {string} context - Context for error message * @returns {HTMLElement} * @throws {Error} If element not found */ function requireElement(id, context = 'console') { const el = byId(id); if (!el) { throw new Error(`Required element "#${id}" not found in ${context}`); } return el; } /** * Set text content safely. * @param {string} id - Element ID * @param {string} text - Text to set */ function setText(id, text) { const el = byId(id); if (el) el.textContent = String(text); } /** * Set HTML content safely. * @param {string} id - Element ID * @param {string} html - HTML content */ function setHTML(id, html) { const el = byId(id); if (el) el.innerHTML = html; } // ============================================================================ // Utilities // ============================================================================ /** * Format a timestamp. * @param {Date} date - Date object * @returns {string} Formatted time */ function formatTime(date = new Date()) { return date.toLocaleTimeString('en-US', { hour12: false }); } /** * Escape HTML to prevent XSS. * @param {string} str - String to escape * @returns {string} Escaped string */ function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** * Debounce a function. * @param {Function} fn - Function to debounce * @param {number} delay - Delay in milliseconds * @returns {Function} Debounced function */ function debounce(fn, delay) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } // ============================================================================ // API Client // ============================================================================ export class OperatorAPI { /** * Fetch JSON from the dashboard API. * @param {string} url - API endpoint * @param {object} options - Fetch options * @returns {Promise} JSON response * @throws {Error} On HTTP error or invalid response */ static async fetchJSON(url, options = {}) { const response = await fetch(url, { ...options, headers: { 'Cache-Control': 'no-store', ...(options.headers || {}), }, }); let data; try { data = await readJson(response); } catch { throw new Error(`Invalid JSON response from ${url}`); } if (!response.ok) { const message = data.error || data.message || `HTTP ${response.status}`; throw new Error(message); } return data; } /** * Send a control command. * @param {string} command - Command name * @param {object} params - Command parameters * @returns {Promise} Command result */ static async sendCommand(command, params = {}) { const body = { command, ...params }; return this.fetchJSON('/api/control', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } /** * Get system status. * @returns {Promise} Status data */ static async getStatus() { return this.fetchJSON('/api/status'); } /** * Get structural proposals. * @returns {Promise} Proposals data */ static async getProposals() { return this.fetchJSON('/api/structural/proposals'); } /** * Approve a structural proposal. * @param {string} proposalId - Proposal ID * @returns {Promise} Result */ static async approveProposal(proposalId) { return this.fetchJSON('/api/structural/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proposal_id: proposalId }), }); } /** * Reject a structural proposal. * @param {string} proposalId - Proposal ID * @returns {Promise} Result */ static async rejectProposal(proposalId) { return this.fetchJSON('/api/structural/reject', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proposal_id: proposalId }), }); } /** * Undo the last structural change. * @returns {Promise} Result */ static async undoStructural() { return this.fetchJSON('/api/structural/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); } /** * Set auto-approval mode. * @param {boolean} enabled - Whether to enable * @returns {Promise} Result */ static async setAutoApproval(enabled) { return this.fetchJSON('/api/structural/auto-approval', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), }); } /** * Run ticks. * @param {number} count - Number of ticks * @returns {Promise} Result */ static async runTicks(count) { return this.sendCommand('run_ticks', { ticks: count }); } /** * Single step. * @returns {Promise} Result */ static async step() { return this.sendCommand('step'); } /** * Start the runtime. * @returns {Promise} Result */ static async start() { return this.sendCommand('start'); } /** * Pause the runtime. * @returns {Promise} Result */ static async pause() { return this.sendCommand('pause'); } /** * Resume the runtime. * @returns {Promise} Result */ static async resume() { return this.sendCommand('resume'); } /** * Stop the runtime. * @returns {Promise} Result */ static async stop() { return this.sendCommand('stop'); } /** * Request a snapshot. * @returns {Promise} Result */ static async snapshot() { return this.sendCommand('snapshot'); } } // ============================================================================ // Console Logger (delegates to shared console-log.js) // ============================================================================ export class ConsoleLogger { constructor(containerId) { this.container = byId(containerId); this.entries = []; this.maxEntries = 1000; this.sharedLog = null; } /** * Bind to the shared console log singleton. */ async bindSharedLog() { if (this.sharedLog) return; try { const { consoleLog } = await import('./console-log.js'); this.sharedLog = consoleLog; } catch { // Shared log unavailable; fall back to local rendering. } } /** * Log a message to the console. * @param {string} message - Message to log * @param {string} type - Log type ('info', 'success', 'error', 'warning') */ log(message, type = 'info') { const entry = { timestamp: new Date(), message: String(message), type: type, }; this.entries.push(entry); if (this.entries.length > this.maxEntries) { this.entries.shift(); } if (this.sharedLog) { this.sharedLog.log(entry.message, entry.type); } else { this.render(entry); } } /** * Render a single log entry. * @param {object} entry - Log entry */ render(entry) { if (!this.container) return; const time = formatTime(entry.timestamp); const classes = `log-entry log-${entry.type}`; const div = document.createElement('div'); div.className = classes; div.innerHTML = `[${time}] ${escapeHtml(entry.message)}`; this.container.appendChild(div); // Auto-scroll this.container.scrollTop = this.container.scrollHeight; // Limit DOM entries while (this.container.children.length > this.maxEntries) { this.container.removeChild(this.container.firstChild); } } /** * Clear the console. */ clear() { if (this.container) { this.container.innerHTML = ''; } this.entries = []; } /** * Get all log entries. * @returns {Array} Log entries */ getEntries() { return [...this.entries]; } } // ============================================================================ // Operator Console // ============================================================================ export class OperatorConsole { constructor() { this.logger = null; this.pollingInterval = null; this.pollingRate = 1000; this.status = null; this.proposalPanel = null; // Bind methods this.handleCommand = this.handleCommand.bind(this); this.refreshStatus = this.refreshStatus.bind(this); this.init(); } /** * Initialize the console. */ init() { // Initialize logger and bind to shared console log this.logger = new ConsoleLogger('console-output'); this.logger.bindSharedLog().then(() => { this.logger.log('🧠 Brain-5D Operator Console initialized', 'info'); this.logger.log(`📡 API endpoint: /api/control`, 'info'); this.proposalPanel = new StructuralProposalPanel({ api: OperatorAPI, logger: this.logger, onChanged: this.refreshStatus, }); this.proposalPanel.load(); }); // Bind event listeners this.bindEvents(); this.bindKeyboardShortcuts(); // Initial load this.refreshStatus(); // Start polling this.startPolling(); } /** * Bind DOM event listeners. */ bindEvents() { // Runtime controls and structural proposals are owned by their panels. // Clear console const clearBtn = byId('b5d-clear-console'); if (clearBtn) { clearBtn.addEventListener('click', () => this.logger.clear()); } } /** * Bind keyboard shortcuts. */ bindKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Ctrl+L = Clear console (if focus is on console) if (e.ctrlKey && e.key === 'l' && document.activeElement?.id === 'console-output') { e.preventDefault(); this.logger.clear(); } }); } /** * Handle a command. * @deprecated Runtime commands are now owned by ControlPanel. */ async handleCommand(action, params = {}) { this.logger.log(`â„šī¸ Runtime commands moved to Runtime Control panel (${action})`, 'info'); } /** * Handle undo structural change. * @deprecated Structural undo is now owned by ControlPanel. */ async handleUndo() { this.logger.log('â„šī¸ Structural undo moved to Runtime Control panel', 'info'); } /** * Refresh system status. */ async refreshStatus() { try { const data = await OperatorAPI.getStatus(); this.status = data; this.renderStatus(data); } catch (error) { this.logger.log(`âš ī¸ Failed to refresh status: ${error.message}`, 'warning'); } } /** * Render system status. * @param {object} data - Status data */ renderStatus(data) { // Update status badge const badge = byId('b5d-state-badge'); if (badge) { const state = data.state || 'idle'; badge.textContent = state; badge.className = `status-badge status-${state}`; } // Update tick count const tickEl = byId('b5d-tick-display'); if (tickEl) { tickEl.textContent = String(data.tick || 0); } // Update metrics const metrics = byId('b5d-metrics'); if (metrics && data.system) { const s = data.system; metrics.innerHTML = ` 🧠 ${(s.neurons || 0).toLocaleString()} 🔗 ${(s.synapses || 0).toLocaleString()} ⚡ ${(s.spikes_total || 0).toLocaleString()} `; } } /** * Start polling for status updates. */ startPolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval); } this.pollingInterval = setInterval(() => { this.refreshStatus().catch(() => {}); }, this.pollingRate); } /** * Stop polling. */ stopPolling() { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } /** * Set polling rate. * @param {number} ms - Milliseconds */ setPollingRate(ms) { if (ms < 100) ms = 100; this.pollingRate = ms; if (this.pollingInterval) { this.startPolling(); } } /** * Destroy the console instance. */ destroy() { this.stopPolling(); this.logger.log('🛑 Operator Console shutting down', 'info'); } }