350 lines
11 KiB
JavaScript
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();
|
|
}
|