2026-08-13 16:23:18 +04:00

350 lines
11 KiB
JavaScript

/* ==========================================================================
Core: store, transport, notifications, formatting.
Every URL here is derived from window.location, so the dashboard works from
whatever address you happened to open it on - laptop, phone, tablet, or a
hostname. Nothing is hardcoded.
========================================================================== */
export const API = `${location.origin}`;
export const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
/* -- Store ---------------------------------------------------------------- */
class Store {
constructor() {
this.state = null; // live robot state
this.spec = null; // constants from the backend
this.settings = null;
this.plugins = { plugins: [], errors: [] };
this.bridge = null;
this.network = null;
this.server = null;
this.events = [];
this.connected = false;
this._subs = new Map();
this._nextId = 1;
}
on(channel, fn) {
if (!this._subs.has(channel)) this._subs.set(channel, new Map());
const id = this._nextId++;
this._subs.get(channel).set(id, fn);
return () => this._subs.get(channel)?.delete(id);
}
emit(channel, payload) {
const subs = this._subs.get(channel);
if (!subs) return;
for (const fn of subs.values()) {
try { fn(payload); } catch (err) { console.error(`[${channel}]`, err); }
}
}
}
export const store = new Store();
/* -- HTTP ----------------------------------------------------------------- */
export async function api(path, options = {}) {
const { method = 'GET', body, timeout = 15000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`${API}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const message = data?.detail || data?.message || `${res.status} ${res.statusText}`;
const error = new Error(typeof message === 'string' ? message : JSON.stringify(message));
error.status = res.status;
error.data = data;
throw error;
}
return data;
} catch (err) {
if (err.name === 'AbortError') throw new Error(`Request timed out after ${timeout / 1000}s`);
throw err;
} finally {
clearTimeout(timer);
}
}
export const get = (path) => api(path);
export const post = (path, body) => api(path, { method: 'POST', body: body ?? {} });
/**
* POST that reports its own outcome as a toast. Returns the payload on success
* and null on failure, so callers can `if (!await command(...)) return;`.
*/
export async function command(path, body, { silent = false, successTitle } = {}) {
try {
const data = await post(path, body);
if (!silent) toast(successTitle || 'Done', data?.message || '', 'good');
return data;
} catch (err) {
if (!silent) toast('Command failed', err.message, 'critical');
return null;
}
}
/* -- WebSocket ------------------------------------------------------------ */
class Socket {
constructor() {
this.ws = null;
this.attempts = 0;
this.closing = false;
this._pending = null;
this._pendingTimer = null;
}
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.closing = false;
try {
this.ws = new WebSocket(WS_URL);
} catch (err) {
this._retry();
return;
}
this.ws.onopen = () => {
this.attempts = 0;
store.connected = true;
store.emit('link', { connected: true });
};
this.ws.onmessage = (event) => {
let message;
try { message = JSON.parse(event.data); } catch { return; }
this._route(message);
};
this.ws.onclose = () => {
store.connected = false;
store.emit('link', { connected: false });
if (!this.closing) this._retry();
};
this.ws.onerror = () => { /* onclose handles recovery */ };
}
_route(message) {
const { type, data } = message;
switch (type) {
case 'state':
store.state = data;
store.emit('state', data);
break;
case 'event':
store.events.push(data);
if (store.events.length > 500) store.events.splice(0, store.events.length - 500);
store.emit('event', data);
if (data.level === 'error') toast('Robot', data.message, 'critical');
else if (data.level === 'warn') toast('Robot', data.message, 'warning');
break;
case 'plugins':
store.plugins = data;
store.emit('plugins', data);
break;
case 'settings':
store.settings = data;
store.emit('settings', data);
break;
case 'scan_progress':
store.emit('scan_progress', data);
break;
case 'command_error':
store.emit('command_error', data);
break;
default:
store.emit(type, data);
}
}
_retry() {
// Back off to 8 s so a robot that is off overnight does not spam the network.
const delay = Math.min(8000, 500 * Math.pow(1.6, this.attempts++));
setTimeout(() => this.connect(), delay);
}
send(type, data) {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify({ type, data }));
return true;
}
/**
* Rate-limited send for the joystick. Coalesces to ~25 Hz so dragging never
* floods the socket, but always delivers the final position.
*/
sendThrottled(type, data, interval = 40) {
this._pending = { type, data };
if (this._pendingTimer) return;
const flush = () => {
if (!this._pending) { this._pendingTimer = null; return; }
const { type: t, data: d } = this._pending;
this._pending = null;
this.send(t, d);
this._pendingTimer = setTimeout(flush, interval);
};
flush();
}
}
export const socket = new Socket();
/* -- Toasts --------------------------------------------------------------- */
const toastHost = () => document.getElementById('toasts');
export function toast(title, message = '', tone = 'default', ttl = 4200) {
const host = toastHost();
if (!host) return;
const node = document.createElement('div');
node.className = 'toast';
node.dataset.tone = tone;
node.innerHTML = `
<div class="toast-body">
<div class="toast-title"></div>
${message ? '<div class="toast-msg"></div>' : ''}
</div>`;
node.querySelector('.toast-title').textContent = title;
if (message) node.querySelector('.toast-msg').textContent = message;
host.appendChild(node);
while (host.children.length > 5) host.firstElementChild.remove();
setTimeout(() => {
node.classList.add('leaving');
setTimeout(() => node.remove(), 200);
}, ttl);
}
/* -- Confirm modal -------------------------------------------------------- */
export function confirmDialog(title, body, { confirmLabel = 'Confirm', danger = true } = {}) {
return new Promise((resolve) => {
const backdrop = document.getElementById('modal-backdrop');
const confirmBtn = document.getElementById('modal-confirm');
const cancelBtn = document.getElementById('modal-cancel');
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').textContent = body;
confirmBtn.textContent = confirmLabel;
confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
backdrop.hidden = false;
confirmBtn.focus();
const finish = (value) => {
backdrop.hidden = true;
confirmBtn.removeEventListener('click', onYes);
cancelBtn.removeEventListener('click', onNo);
backdrop.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(value);
};
const onYes = () => finish(true);
const onNo = () => finish(false);
const onBackdrop = (e) => { if (e.target === backdrop) finish(false); };
const onKey = (e) => { if (e.key === 'Escape') finish(false); };
confirmBtn.addEventListener('click', onYes);
cancelBtn.addEventListener('click', onNo);
backdrop.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
});
}
/* -- Formatting ----------------------------------------------------------- */
export const RAD2DEG = 180 / Math.PI;
export const DEG2RAD = Math.PI / 180;
export function num(value, precision = 2, fallback = '—') {
if (value === null || value === undefined || Number.isNaN(Number(value))) return fallback;
return Number(value).toFixed(precision);
}
export function compact(value) {
if (value === null || value === undefined) return '—';
const abs = Math.abs(value);
if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
if (abs >= 1e4) return `${(value / 1e3).toFixed(1)}K`;
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
export function clockTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour12: false });
}
export function duration(seconds) {
if (!seconds || seconds < 0) return '—';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
export function batteryTone(pct) {
if (pct === null || pct === undefined) return 'default';
if (pct <= 10) return 'critical';
if (pct <= 25) return 'warning';
return 'good';
}
export function tempTone(celsius, warn = 55, crit = 70) {
if (celsius === null || celsius === undefined) return 'default';
if (celsius >= crit) return 'critical';
if (celsius >= warn) return 'warning';
return 'good';
}
export function throttle(fn, ms) {
let last = 0, timer = null, queued = null;
return (...args) => {
const now = Date.now();
queued = args;
if (now - last >= ms) { last = now; fn(...queued); queued = null; return; }
if (timer) return;
timer = setTimeout(() => {
timer = null; last = Date.now();
if (queued) { fn(...queued); queued = null; }
}, ms - (now - last));
};
}
export function debounce(fn, ms) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
/* Series colours, read from CSS so the theme toggle stays authoritative. */
export function seriesColor(index) {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue(`--series-${(index % 8) + 1}`).trim() || '#3987e5';
}
export function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}