goatifi / frontend /js /api.js
KonoDioDaa's picture
Initial FlowTwin deployment
e7a9f02
Raw
History Blame Contribute Delete
3.66 kB
/* REST + WebSocket client.
*
* The dashboard never polls for frames. It asks for state once when a session
* starts and then consumes the push stream; REST is only used for commands and
* for things that are not per-frame.
*/
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(BASE + path, {
headers: options.body ? { 'content-type': 'application/json' } : undefined,
...options,
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || body.error || detail;
} catch { /* non-JSON error body */ }
const err = new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
err.status = res.status;
throw err;
}
return res.status === 204 ? null : res.json();
}
export const api = {
meta: () => request('/meta'),
venues: () => request('/venues'),
venue: id => request(`/venues/${id}`),
scenarios: () => request('/scenarios'),
benchmarks: () => request('/benchmarks'),
start: payload => request('/simulation/start', {
method: 'POST', body: JSON.stringify(payload),
}),
state: (id, agents = true) => request(`/simulation/${id}/state?agents=${agents}`),
control: (id, payload) => request(`/simulation/${id}/control`, {
method: 'POST', body: JSON.stringify(payload),
}),
stop: id => request(`/simulation/${id}`, { method: 'DELETE' }),
simulateStrategies: (id, payload = {}) => request(`/simulation/${id}/strategy/simulate`, {
method: 'POST', body: JSON.stringify(payload),
}),
applyStrategy: (id, strategyId) => request(`/simulation/${id}/strategy/apply`, {
method: 'POST', body: JSON.stringify({ strategy_id: strategyId }),
}),
perceptionStatus: () => request('/perception/status'),
perceptionAnalyze: (formData) =>
fetch(`${BASE}/perception/analyze`, { method: 'POST', body: formData })
.then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.detail || 'perception failed');
return body;
}),
};
/** Auto-reconnecting frame stream. */
export class FrameStream {
constructor(sessionId, handlers) {
this.sessionId = sessionId;
this.handlers = handlers;
this.ws = null;
this.closed = false;
this.retries = 0;
this._connect();
}
_connect() {
if (this.closed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${BASE}/ws/simulation/${this.sessionId}`;
let ws;
try {
ws = new WebSocket(url);
} catch {
this._scheduleRetry();
return;
}
this.ws = ws;
ws.onopen = () => {
this.retries = 0;
this.handlers.onStatus?.('live');
};
ws.onmessage = ev => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'frame') this.handlers.onFrame?.(msg);
else if (msg.type === 'strategy') this.handlers.onStrategy?.(msg.payload);
else if (msg.type === 'error') this.handlers.onError?.(msg.detail);
};
ws.onclose = () => {
this.handlers.onStatus?.(this.closed ? 'closed' : 'reconnecting');
this._scheduleRetry();
};
ws.onerror = () => { /* onclose handles recovery */ };
}
_scheduleRetry() {
if (this.closed) return;
this.retries += 1;
if (this.retries > 8) {
this.handlers.onStatus?.('offline');
return;
}
setTimeout(() => this._connect(), Math.min(600 * this.retries, 4000));
}
close() {
this.closed = true;
try { this.ws?.close(); } catch { /* already gone */ }
}
}