/**
* RL Dashboard - Abstract model cloud visualization.
* Canvas is intentionally NOT linked to current action execution.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, setText, empty } from '../core/dom.js';
let tracker = null;
let statsPoller = null;
let historyPoller = null;
let metricsGraph = null;
let modelCloud = null;
export async function mount(container) {
tracker = new ResourceTracker('rl-dashboard');
container.innerHTML = '';
container.appendChild(buildLayout());
await fetchStats();
await fetchHistory();
await fetchExperiences();
statsPoller = new Poller(fetchStats, 5000);
historyPoller = new Poller(async () => {
await fetchHistory();
await fetchExperiences();
}, 10000);
statsPoller.start();
historyPoller.start();
}
export function unmount() {
if (statsPoller) {
statsPoller.stop();
statsPoller = null;
}
if (historyPoller) {
historyPoller.stop();
historyPoller = null;
}
if (metricsGraph) {
metricsGraph.destroy();
metricsGraph = null;
}
if (modelCloud) {
modelCloud.destroy();
modelCloud = null;
}
if (tracker) {
tracker.cleanupAll();
tracker = null;
}
}
/* ======================== Mini Metrics Canvas ======================== */
class MultiMetricGraph {
constructor(canvasId) {
this.data = {
epsilon: new Array(100).fill(0),
reward: new Array(100).fill(0),
loss: new Array(100).fill(0),
};
this.colors = {
epsilon: '#00d4ff',
reward: '#00ff6a',
loss: '#ff4169',
};
this.canvas = document.getElementById(canvasId);
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this._onResize = () => this.resize();
window.addEventListener('resize', this._onResize);
this.resize();
this.animate();
}
destroy() {
window.removeEventListener('resize', this._onResize);
if (this._raf) cancelAnimationFrame(this._raf);
}
resize() {
const p = this.canvas.parentElement;
this.canvas.width = Math.max(1, p.offsetWidth);
this.canvas.height = Math.max(1, p.offsetHeight);
this.width = this.canvas.width;
this.height = this.canvas.height;
}
update(stats) {
if (!stats) return;
this.data.epsilon.shift();
this.data.reward.shift();
this.data.loss.shift();
this.data.epsilon.push(Number(stats.epsilon || 0));
const recent = Array.isArray(stats.recent_activity) ? stats.recent_activity : [];
const r = recent.length ? Number(recent[0].reward || 0) : 0;
const prevR = this.data.reward[this.data.reward.length - 1] || 0;
this.data.reward.push(prevR * 0.8 + r * 0.2);
const l = Number(stats.last_loss || 0);
const prevL = this.data.loss[this.data.loss.length - 1] || 0;
this.data.loss.push(prevL * 0.9 + l * 0.1);
}
animate() {
this._raf = requestAnimationFrame(() => this.animate());
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawLine(this.data.epsilon, this.colors.epsilon, 1.0);
this.drawLine(this.data.reward, this.colors.reward, 10.0);
this.drawLine(this.data.loss, this.colors.loss, 5.0);
}
drawLine(data, color, maxVal) {
if (data.length < 2) return;
const stepX = this.width / (data.length - 1);
this.ctx.beginPath();
data.forEach((val, i) => {
const x = i * stepX;
const y = this.height - (Math.max(0, val) / Math.max(0.001, maxVal)) * this.height * 0.8 - 5;
if (i === 0) this.ctx.moveTo(x, y);
else this.ctx.lineTo(x, y);
});
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 2;
this.ctx.stroke();
}
}
/* ======================== Abstract Model Cloud ======================== */
class ModelCloud {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this.tooltip = document.getElementById('brain-tooltip');
this.nodes = [];
this.tick = 0;
this.hoverIndex = -1;
this.meta = {
model_loaded: false,
model_version: null,
model_param_count: 0,
model_layer_count: 0,
model_feature_count: 0,
};
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(this.canvas.parentElement);
this.resize();
this.onMouseMove = (e) => this.handleMouseMove(e);
this.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.addEventListener('mouseleave', () => {
this.hoverIndex = -1;
if (this.tooltip) this.tooltip.style.display = 'none';
});
this.reseedNodes(30);
this.animate();
}
destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.canvas && this.onMouseMove) this.canvas.removeEventListener('mousemove', this.onMouseMove);
if (this.raf) cancelAnimationFrame(this.raf);
}
resize() {
const p = this.canvas.parentElement;
this.width = Math.max(1, p.offsetWidth);
this.height = Math.max(1, p.offsetHeight);
this.canvas.width = this.width;
this.canvas.height = this.height;
}
updateFromStats(stats) {
this.meta = {
model_loaded: !!stats.model_loaded,
model_version: stats.model_version || null,
model_param_count: Number(stats.model_param_count || 0),
model_layer_count: Number(stats.model_layer_count || 0),
model_feature_count: Number(stats.model_feature_count || 0),
};
const nTarget = this.computeNodeTarget(this.meta);
this.adjustPopulation(nTarget);
this.updateNodeEncoding();
}
computeNodeTarget(meta) {
if (!meta.model_loaded) return 26;
const pScore = Math.log10(Math.max(10, meta.model_param_count));
const lScore = Math.max(1, meta.model_layer_count);
const fScore = Math.log10(Math.max(10, meta.model_feature_count * 100));
const raw = 18 + pScore * 14 + lScore * 2 + fScore * 8;
return Math.max(25, Math.min(180, Math.round(raw)));
}
reseedNodes(count) {
this.nodes = [];
for (let i = 0; i < count; i++) {
this.nodes.push(this.makeNode());
}
}
makeNode() {
const r = 2 + Math.random() * 4;
return {
x: Math.random() * this.width,
y: Math.random() * this.height,
vx: (Math.random() - 0.5) * 0.35,
vy: (Math.random() - 0.5) * 0.35,
r,
energy: 0.2 + Math.random() * 0.8,
phase: Math.random() * Math.PI * 2,
cluster: Math.floor(Math.random() * 4),
};
}
adjustPopulation(target) {
const current = this.nodes.length;
if (current < target) {
for (let i = 0; i < target - current; i++) this.nodes.push(this.makeNode());
} else if (current > target) {
this.nodes.length = target;
}
}
updateNodeEncoding() {
const layers = Math.max(1, this.meta.model_layer_count || 1);
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
n.cluster = i % layers;
n.energy = 0.25 + ((i % (layers + 3)) / (layers + 3));
n.r = 2 + (n.energy * 4.5);
}
}
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.hoverIndex = -1;
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
const dx = mx - n.x;
const dy = my - n.y;
if (dx * dx + dy * dy <= (n.r + 4) * (n.r + 4)) {
this.hoverIndex = i;
break;
}
}
if (!this.tooltip || this.hoverIndex < 0) {
if (this.tooltip) this.tooltip.style.display = 'none';
return;
}
const n = this.nodes[this.hoverIndex];
this.tooltip.style.display = 'block';
this.tooltip.innerHTML = `
Model Cloud Node
Cluster ${n.cluster + 1}
Energy ${(n.energy * 100).toFixed(1)}%
`;
const tx = Math.min(this.width - 180, mx + 12);
const ty = Math.min(this.height - 80, my + 12);
this.tooltip.style.left = `${Math.max(8, tx)}px`;
this.tooltip.style.top = `${Math.max(8, ty)}px`;
}
animate() {
this.raf = requestAnimationFrame(() => this.animate());
this.tick += 0.01;
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawLinks();
this.updateAndDrawNodes();
this.drawOverlay();
}
drawLinks() {
const maxDist = 70;
for (let i = 0; i < this.nodes.length; i++) {
const a = this.nodes[i];
for (let j = i + 1; j < this.nodes.length; j++) {
const b = this.nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > maxDist * maxDist) continue;
const d = Math.sqrt(d2);
const alpha = (1 - d / maxDist) * 0.2;
this.ctx.strokeStyle = `rgba(90,200,255,${alpha})`;
this.ctx.lineWidth = 0.6;
this.ctx.beginPath();
this.ctx.moveTo(a.x, a.y);
this.ctx.lineTo(b.x, b.y);
this.ctx.stroke();
}
}
}
updateAndDrawNodes() {
for (let i = 0; i < this.nodes.length; i++) {
const n = this.nodes[i];
n.x += n.vx + Math.cos(this.tick + n.phase) * 0.08;
n.y += n.vy + Math.sin(this.tick * 1.2 + n.phase) * 0.08;
if (n.x < 0 || n.x > this.width) n.vx *= -1;
if (n.y < 0 || n.y > this.height) n.vy *= -1;
n.x = Math.max(0, Math.min(this.width, n.x));
n.y = Math.max(0, Math.min(this.height, n.y));
const pulse = 0.55 + Math.sin(this.tick * 2 + n.phase) * 0.45;
const rr = n.r * (0.9 + pulse * 0.2);
const isHover = i === this.hoverIndex;
const color = clusterColor(n.cluster, n.energy);
this.ctx.beginPath();
this.ctx.arc(n.x, n.y, rr + (isHover ? 1.8 : 0), 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.shadowBlur = isHover ? 14 : 6;
this.ctx.shadowColor = color;
this.ctx.fill();
this.ctx.shadowBlur = 0;
}
}
drawOverlay() {
const m = this.meta;
this.ctx.fillStyle = 'rgba(5,8,12,0.7)';
this.ctx.fillRect(10, 10, 270, 68);
this.ctx.strokeStyle = 'rgba(85,120,145,0.35)';
this.ctx.strokeRect(10, 10, 270, 68);
this.ctx.fillStyle = '#d1ecff';
this.ctx.font = '11px "Fira Code", monospace';
this.ctx.fillText(`Model: ${m.model_version || 'none'}`, 18, 28);
this.ctx.fillText(`Params: ${fmtInt(m.model_param_count)} | Layers: ${m.model_layer_count || 0}`, 18, 46);
this.ctx.fillText(`Features: ${m.model_feature_count || 0} | Nodes: ${this.nodes.length}`, 18, 64);
}
}
function fmtInt(v) {
try {
return Number(v || 0).toLocaleString();
} catch {
return String(v || 0);
}
}
function clusterColor(cluster, energy) {
const palette = [
[0, 220, 255],
[0, 255, 160],
[180, 140, 255],
[255, 120, 180],
[255, 200, 90],
];
const base = palette[Math.abs(cluster) % palette.length];
const a = 0.25 + Math.max(0.0, Math.min(1.0, energy)) * 0.7;
return `rgba(${base[0]},${base[1]},${base[2]},${a})`;
}
/* ======================== Layout ======================== */
function buildLayout() {
const mobileStyle = `
@media (max-width: 768px) {
.brain-hero { height: 220px !important; margin-bottom: 12px !important; border-radius: 14px !important; }
.kpi-cards { grid-template-columns: 1fr 1fr !important; gap: 8px !important; }
.grid-stack { grid-template-columns: 1fr !important; gap: 12px !important; }
.title { font-size: 1.25rem !important; }
}
`;
return el('div', { class: 'dashboard-container' }, [
el('style', {}, [mobileStyle]),
el('div', { class: 'head', style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px' }, [
el('h2', { class: 'title' }, ['AI Brain Cloud']),
]),
el('div', {
class: 'brain-hero',
style: 'position:relative; width:min(860px,96%); height:360px; margin:0 auto 20px; border-radius:18px; background:#030507; border:1px solid #233036; overflow:hidden; box-shadow: 0 0 28px rgba(0,170,255,0.16)',
}, [
el('canvas', { id: 'brain-canvas', style: 'width:100%;height:100%' }),
el('div', { id: 'brain-tooltip', style: 'position:absolute; top:0; left:0; background:rgba(0,0,0,0.85); border:1px solid var(--acid); color:#fff; padding:8px 12px; border-radius:4px; font-size:0.8em; pointer-events:none; display:none; z-index:10; white-space:nowrap;' }),
]),
el('div', { class: 'kpi-cards', style: 'display:flex; gap:10px; margin-bottom:20px; overflow-x:auto; padding-bottom:5px' }, [
el('div', { class: 'kpi', style: 'flex:0 0 250px; display:flex; flex-direction:column; justify-content:center' }, [
el('div', { class: 'label', style: 'margin-bottom:5px' }, ['Operation Mode']),
el('div', { class: 'mode-selector', style: 'display:flex; gap:2px; background:#111; padding:2px; border-radius:4px; border:1px solid #333' }, [
el('button', { class: 'mode-btn', id: 'mode-manual', onclick: () => setOperationMode('MANUAL'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['MANUAL']),
el('button', { class: 'mode-btn', id: 'mode-auto', onclick: () => setOperationMode('AUTO'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AUTO']),
el('button', { class: 'mode-btn', id: 'mode-ai', onclick: () => setOperationMode('AI'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AI']),
]),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Episodes']),
el('div', { class: 'val', id: 'val-episodes', style: 'font-size:1.5em' }, ['0']),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Epsilon']),
el('div', { class: 'val', id: 'val-epsilon', style: 'font-size:1.5em; color:cyan' }, ['0.00']),
]),
el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [
el('div', { class: 'label' }, ['Q-Size']),
el('div', { class: 'val', id: 'val-qsize', style: 'font-size:1.5em' }, ['0']),
]),
el('div', { id: 'mini-graph-container', style: 'flex:2; border-left:1px solid #333; padding-left:15px; position:relative; min-width:300px' }, [
el('canvas', { id: 'metrics-canvas', style: 'width:100%; height:100%' }),
]),
]),
el('div', { class: 'grid-stack', style: 'display:grid;grid-template-columns:1fr 1fr; gap:20px;' }, [
el('div', { class: 'card' }, [
el('h3', {}, ['Model Manifest']),
el('div', { id: 'model-manifest', style: 'display:flex; flex-wrap:wrap; gap:5px; margin-top:10px; max-height:250px; overflow-y:auto' }),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Recent Confidence Signals']),
el('div', { id: 'confidence-bars', style: 'margin-top:10px; display:flex; flex-direction:column; gap:8px' }),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Data Sync History']),
el('div', { class: 'table-responsive', style: 'max-height:400px;overflow-y:auto' }, [
el('table', { class: 'table' }, [
el('thead', {}, [el('tr', {}, [el('th', {}, ['Time']), el('th', {}, ['Records']), el('th', {}, ['Sync Status'])])]),
el('tbody', { id: 'history-body' }),
]),
]),
]),
el('div', { class: 'card' }, [
el('h3', {}, ['Recent Experiences']),
el('div', { id: 'experience-feed', style: 'display:flex;flex-direction:column;gap:10px;max-height:400px;overflow-y:auto' }),
]),
]),
]);
}
/* ======================== Fetchers ======================== */
async function fetchStats() {
try {
const data = await api.get('/api/rl/stats');
if (!data) return;
if (!metricsGraph && document.getElementById('metrics-canvas')) {
metricsGraph = new MultiMetricGraph('metrics-canvas');
if (tracker) tracker.trackResource(() => metricsGraph && metricsGraph.destroy());
}
if (metricsGraph) metricsGraph.update(data);
if (!modelCloud && document.getElementById('brain-canvas')) {
modelCloud = new ModelCloud('brain-canvas');
if (tracker) tracker.trackResource(() => modelCloud && modelCloud.destroy());
}
if (modelCloud) modelCloud.updateFromStats(data);
setText($('#val-episodes'), data.episodes ?? 0);
setText($('#val-epsilon'), Number(data.epsilon || 0).toFixed(4));
setText($('#val-qsize'), data.q_table_size ?? 0);
updateModeUI(data.mode || (data.ai_mode ? 'AI' : data.manual_mode ? 'MANUAL' : 'AUTO'));
updateManifest(data);
if (Array.isArray(data.recent_activity) && data.recent_activity.length) {
renderConfidenceBars(data.recent_activity);
}
} catch (e) {
console.error(e);
}
}
function updateManifest(data) {
const manifest = $('#model-manifest');
if (!manifest) return;
empty(manifest);
const tags = [
`MODEL: ${data.model_loaded ? 'LOADED' : 'HEURISTIC'}`,
`VERSION: ${data.model_version || 'N/A'}`,
`PARAMS: ${fmtInt(data.model_param_count || 0)}`,
`LAYERS: ${data.model_layer_count || 0}`,
`FEATURES: ${data.model_feature_count || 0}`,
`SAMPLES: ${fmtInt(data.training_samples || 0)}`,
];
tags.forEach((txt) => {
manifest.appendChild(el('div', {
style: 'background:#111; border:1px solid #333; padding:3px 8px; border-radius:4px; font-size:0.72em; color:var(--text-main); white-space:nowrap',
}, [txt]));
});
}
function renderConfidenceBars(activity) {
const container = $('#confidence-bars');
if (!container) return;
empty(container);
activity.forEach((act) => {
const reward = Number(act.reward || 0);
const color = reward > 0 ? 'var(--acid)' : '#ff3333';
const success = reward > 0;
container.appendChild(el('div', { style: 'display:flex; flex-direction:column; gap:2px' }, [
el('div', { style: 'display:flex; justify-content:space-between; font-size:0.8em' }, [
el('span', {}, [act.action || '-']),
el('span', { style: `color:${color}` }, [success ? 'CONFIDENT' : 'UNCERTAIN']),
]),
el('div', { style: 'height:4px; background:#222; border-radius:3px; overflow:hidden' }, [
el('div', { style: `height:100%; background:${color}; width:${Math.min(Math.abs(reward) * 5, 100)}%; transition:width 0.45s ease-out` }),
]),
]));
});
}
async function fetchHistory() {
try {
const data = await api.get('/api/rl/history');
if (!data || !Array.isArray(data.history)) return;
const tbody = $('#history-body');
empty(tbody);
data.history.forEach((row) => {
const ts = String(row.timestamp || '');
const parsed = new Date(ts.includes('Z') ? ts : `${ts}Z`);
tbody.appendChild(el('tr', {}, [
el('td', {}, [Number.isFinite(parsed.getTime()) ? parsed.toLocaleTimeString() : ts]),
el('td', {}, [String(row.record_count || 0)]),
el('td', { style: 'color:var(--acid)' }, ['COMPLETED']),
]));
});
} catch (e) {
console.error(e);
}
}
async function fetchExperiences() {
try {
const data = await api.get('/api/rl/experiences');
if (!data || !Array.isArray(data.experiences)) return;
const container = $('#experience-feed');
empty(container);
data.experiences.forEach((exp) => {
let color = 'var(--text-main)';
if (exp.reward > 0) color = 'var(--acid)';
if (exp.reward < 0) color = 'var(--glitch)';
container.appendChild(el('div', {
class: 'exp-item',
style: `padding:8px; background:rgba(255,255,255,0.05); border-radius:4px; border-left:3px solid ${color}`,
}, [
el('div', { style: 'display:flex;justify-content:space-between' }, [
el('strong', {}, [exp.action_name || '-']),
el('span', { style: `color:${color};font-weight:bold` }, [exp.reward > 0 ? `+${exp.reward}` : `${exp.reward}`]),
]),
el('div', { style: 'font-size:0.85em; opacity:0.7; margin-top:4px' }, [
el('span', {}, [new Date(String(exp.timestamp || '').includes('Z') ? exp.timestamp : `${exp.timestamp}Z`).toLocaleString()]),
' - ',
el('span', {}, [exp.success ? 'SUCCESS' : 'FAIL']),
]),
]));
});
} catch (e) {
console.error(e);
}
}
function updateModeUI(mode) {
if (!mode) return;
const m = String(mode).toUpperCase().trim();
['MANUAL', 'AUTO', 'AI'].forEach((v) => {
const btn = $(`#mode-${v.toLowerCase()}`);
if (!btn) return;
if (v === m) {
btn.style.background = 'var(--acid)';
btn.style.color = '#000';
btn.style.fontWeight = 'bold';
} else {
btn.style.background = 'none';
btn.style.color = '#666';
btn.style.fontWeight = 'normal';
}
});
}
async function setOperationMode(mode) {
try {
const data = await api.post('/api/rl/config', { mode });
if (data.status === 'ok') {
updateModeUI(data.mode);
if (window.toast) window.toast(`Operation Mode: ${data.mode}`);
const bc = new BroadcastChannel('bjorn_mode_sync');
bc.postMessage({ mode: data.mode });
bc.close();
} else if (window.toast) {
window.toast(`Error: ${data.message}`, 'error');
}
} catch (err) {
console.error(err);
if (window.toast) window.toast('Communication Error', 'error');
}
}