Brain-5D-Space / src /dashboard /static /operator_console.js
github-actions[bot]
Sync: publish Space API fix
5e0b58b
Raw History Blame Contribute Delete
13.6 kB
/**
* 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<object>} 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<object>} 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<object>} Status data
*/
static async getStatus() {
return this.fetchJSON('/api/status');
}
/**
* Get structural proposals.
* @returns {Promise<object>} Proposals data
*/
static async getProposals() {
return this.fetchJSON('/api/structural/proposals');
}
/**
* Approve a structural proposal.
* @param {string} proposalId - Proposal ID
* @returns {Promise<object>} 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<object>} 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<object>} 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<object>} 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<object>} Result
*/
static async runTicks(count) {
return this.sendCommand('run_ticks', { ticks: count });
}
/**
* Single step.
* @returns {Promise<object>} Result
*/
static async step() {
return this.sendCommand('step');
}
/**
* Start the runtime.
* @returns {Promise<object>} Result
*/
static async start() {
return this.sendCommand('start');
}
/**
* Pause the runtime.
* @returns {Promise<object>} Result
*/
static async pause() {
return this.sendCommand('pause');
}
/**
* Resume the runtime.
* @returns {Promise<object>} Result
*/
static async resume() {
return this.sendCommand('resume');
}
/**
* Stop the runtime.
* @returns {Promise<object>} Result
*/
static async stop() {
return this.sendCommand('stop');
}
/**
* Request a snapshot.
* @returns {Promise<object>} 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 = `<span class="log-time">[${time}]</span> ${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 = `
<span>🧠 ${(s.neurons || 0).toLocaleString()}</span>
<span>🔗 ${(s.synapses || 0).toLocaleString()}</span>
<span>⚡ ${(s.spikes_total || 0).toLocaleString()}</span>
`;
}
}
/**
* 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');
}
}