Demo / dashboard /js /api.js
Ajayyy00
Sync frontend step bugfix
042a09a
/* ============================================================
CyberSOC Dashboard β€” WebSocket API Client
Maintains a persistent WebSocket connection to /ws/{session_id}.
Each browser tab gets its own session_id (UUID in sessionStorage),
giving every tab an isolated CyberSOCEnvironment on the server.
Public surface (unchanged from the old fetch-based API):
API.reset(taskId) β†’ Promise<observation>
API.step(action) β†’ Promise<observation>
API.getState() β†’ { active, session_id }
API.checkConnection() β†’ Promise<boolean>
Internal protocol (client β†’ server):
{ type: "reset", task_id: "hard" }
{ type: "step", ...action fields }
{ type: "ping" }
Internal protocol (server β†’ client):
{ type: "reset_ok", observation: {...}, reward, done }
{ type: "step_ok", observation: {...}, reward, done }
{ type: "error", message: "..." }
{ type: "pong" }
============================================================ */
const API = (() => {
// ── Session ID ─────────────────────────────────────────────────────────────
// UUIDs are stored in sessionStorage so each tab has its own session but
// the same tab survives a page refresh.
function _uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for older browsers
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
const _sessionId = (() => {
try {
let id = sessionStorage.getItem('soc_session_id');
if (!id) { id = _uuid(); sessionStorage.setItem('soc_session_id', id); }
return id;
} catch {
return _uuid(); // sessionStorage blocked (e.g. private mode with strict settings)
}
})();
// ── External backend override (set by dashboard/js/config.js in Demo mode) ─
// config.js sets window.CYBERSOC_BACKEND_URL to the trainer Space URL.
// Empty string β†’ auto-detect from page origin (default for full-stack mode).
const _backendOverride = (
typeof window !== 'undefined' &&
typeof window.CYBERSOC_BACKEND_URL === 'string' &&
window.CYBERSOC_BACKEND_URL.trim()
) ? window.CYBERSOC_BACKEND_URL.trim().replace(/\/$/, '') : '';
// ── WebSocket URL ──────────────────────────────────────────────────────────
function _wsUrl() {
if (_backendOverride) {
const wsProto = _backendOverride.startsWith('https') ? 'wss:' : 'ws:';
const host = _backendOverride.replace(/^https?:\/\//, '');
return `${wsProto}//${host}/ws/${_sessionId}`;
}
if (typeof window === 'undefined') {
return `ws://localhost:8000/ws/${_sessionId}`;
}
const { protocol, hostname, port } = window.location;
if (protocol === 'file:') return `ws://localhost:8000/ws/${_sessionId}`;
const wsProto = protocol === 'https:' ? 'wss:' : 'ws:';
const host = port ? `${hostname}:${port}` : hostname;
return `${wsProto}//${host}/ws/${_sessionId}`;
}
// HTTP base URL β€” used only by checkConnection() which pings /health over HTTP
function _httpBase() {
if (_backendOverride) return _backendOverride;
if (typeof window === 'undefined') return 'http://localhost:8000';
const { protocol, hostname, port } = window.location;
if (protocol === 'file:') return 'http://localhost:8000';
return port ? `${protocol}//${hostname}:${port}` : `${protocol}//${hostname}`;
}
// ── StateStore reference (injected by app.js via setStore) ──────────────────
let _store = null;
// ── WebSocket state ────────────────────────────────────────────────────────
let _ws = null;
let _connected = false;
let _reconnectAttempts = 0;
let _reconnectTimer = null;
let _pingInterval = null;
// At most one request is in-flight at a time; the dashboard actions are sequential.
// _pending holds the callbacks and a timeout handle for the current in-flight request.
let _pending = null; // { resolve, reject, timeoutId } | null
const MAX_RECONNECT = 8;
const BACKOFF_MS = [500, 1000, 2000, 4000, 8000, 16000, 30000, 60000];
const REQUEST_TIMEOUT_MS = 30_000;
const PING_INTERVAL_MS = 25_000; // keep connection alive through proxies/HF Spaces
// ── Pending promise helpers ────────────────────────────────────────────────
function _resolvePending(data) {
if (!_pending) return;
clearTimeout(_pending.timeoutId);
_pending.resolve(data);
_pending = null;
}
function _rejectPending(reason) {
if (!_pending) return;
clearTimeout(_pending.timeoutId);
_pending.reject(new Error(reason));
_pending = null;
}
// ── Ping keepalive ─────────────────────────────────────────────────────────
function _startPing() {
_stopPing();
_pingInterval = setInterval(() => {
if (_ws && _ws.readyState === WebSocket.OPEN && !_pending) {
_ws.send(JSON.stringify({ type: 'ping' }));
}
}, PING_INTERVAL_MS);
}
function _stopPing() {
if (_pingInterval !== null) { clearInterval(_pingInterval); _pingInterval = null; }
}
// ── Connection ─────────────────────────────────────────────────────────────
function _connect() {
if (_ws && (_ws.readyState === WebSocket.CONNECTING ||
_ws.readyState === WebSocket.OPEN)) return;
const url = _wsUrl();
_ws = new WebSocket(url);
_ws.onopen = () => {
_connected = true;
_reconnectAttempts = 0;
_reconnectTimer = null;
console.log('[WS] connected β†’', url);
_startPing();
};
_ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case 'reset_ok':
case 'step_ok':
_resolvePending(msg);
break;
case 'error':
_rejectPending(msg.message || 'Server error');
break;
case 'pong':
break; // keepalive reply β€” nothing to do
default:
console.warn('[WS] unknown message type:', msg.type);
}
};
_ws.onclose = (ev) => {
_connected = false;
_stopPing();
_rejectPending('WebSocket disconnected');
console.warn(`[WS] closed (code ${ev.code}) β€” scheduling reconnect`);
_scheduleReconnect();
};
_ws.onerror = () => {
// onclose always fires after onerror; handle everything there
console.warn('[WS] connection error');
};
}
function _scheduleReconnect() {
if (_reconnectTimer !== null) return; // already pending
if (_reconnectAttempts >= MAX_RECONNECT) {
console.error('[WS] max reconnect attempts reached β€” giving up');
return;
}
const delay = BACKOFF_MS[Math.min(_reconnectAttempts, BACKOFF_MS.length - 1)];
_reconnectAttempts++;
console.log(`[WS] reconnect attempt ${_reconnectAttempts}/${MAX_RECONNECT} in ${delay}ms`);
_reconnectTimer = setTimeout(() => { _reconnectTimer = null; _connect(); }, delay);
}
// ── Send helper ────────────────────────────────────────────────────────────
// Returns a Promise that resolves with the server's response message,
// or rejects on error / timeout / disconnect.
function _send(msg) {
return new Promise((resolve, reject) => {
if (_pending) {
reject(new Error('Another request is already in-flight β€” try again'));
return;
}
const timeoutId = setTimeout(() => {
_pending = null;
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`));
}, REQUEST_TIMEOUT_MS);
_pending = { resolve, reject, timeoutId };
const payload = JSON.stringify(msg);
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(payload);
return;
}
// Not open yet β€” ensure we're connecting, then poll until open or failed
if (!_ws || _ws.readyState === WebSocket.CLOSED ||
_ws.readyState === WebSocket.CLOSING) {
_connect();
}
const poll = setInterval(() => {
if (!_pending) { clearInterval(poll); return; } // timed out or rejected already
if (_ws && _ws.readyState === WebSocket.OPEN) {
clearInterval(poll);
_ws.send(payload);
} else if (!_ws || _ws.readyState === WebSocket.CLOSED) {
clearInterval(poll);
_rejectPending('WebSocket closed before message could be sent');
}
}, 100);
});
}
// ── Response parser (same shape as the old fetch-based version) ────────────
function _parseResponse(msg) {
if (!msg) return null;
const obs = msg.observation || msg;
return {
episode_id: obs.episode_id || '',
alert_queue: obs.alert_queue || [],
network_topology: obs.network_topology || { total_hosts: 0, subnets: {}, compromised_count: 0, isolated_count: 0, online_count: 0 },
host_forensics: obs.host_forensics || null,
timeline: obs.timeline || [],
business_impact_score: obs.business_impact_score ?? 0,
step_count: obs.step_count ?? 0,
active_threats: obs.active_threats || [],
max_steps: obs.max_steps || 30,
task_id: obs.task_id || 'hard',
total_reward: obs.total_reward ?? 0,
final_score: obs.final_score ?? null,
grade_breakdown: obs.grade_breakdown || null,
correlation_results: obs.correlation_results || null,
ioc_enrichment: obs.ioc_enrichment || null,
vulnerability_results: obs.vulnerability_results || null,
playbook_result: obs.playbook_result || null,
threat_graph_summary: obs.threat_graph_summary || null,
available_playbooks: obs.available_playbooks || [],
done: msg.done ?? obs.done ?? false,
reward: msg.reward ?? obs.reward ?? 0,
active_turn: obs.active_turn || null,
};
}
// Eagerly open the WebSocket so it's ready before the user clicks Start
_connect();
// ── Public API ─────────────────────────────────────────────────────────────
return {
// Inject the StateStore so every parsed response is pushed into it.
// Called once from CyberSOCDashboard.init() before any episode starts.
setStore(store) {
_store = store;
},
// Send a reset message, push parsed observation into the store, return it.
async reset(taskId = 'hard') {
const msg = await _send({ type: 'reset', task_id: taskId });
const parsed = _parseResponse(msg);
_store?.applyObservation(parsed, null);
return parsed;
},
// Send a step message, push parsed observation into the store, return it.
async step(action) {
const msg = await _send({ type: 'step', action: action });
const parsed = _parseResponse(msg);
_store?.applyObservation(parsed, action);
return parsed;
},
// Local state β€” no server round-trip needed
getState() {
return { active: _connected, session_id: _sessionId };
},
// HTTP /health ping β€” used by the connection overlay on page load.
// Deliberately stays on HTTP so it never races with the WS handshake.
async checkConnection() {
try {
const r = await fetch(`${_httpBase()}/health`, {
signal: AbortSignal.timeout(3000),
});
return r.ok;
} catch {
return false;
}
},
};
})();