const { useState: useStateL, useEffect: useEffectL, useRef: useRefL, useCallback: useCallbackL } = React; const Live = (() => { const listeners = new Set(); const instancesByPad = new Map(); let socket = null; let status = "connecting"; let reconnectAttempt = 0; let reconnectTimer = null; function emit(msg) { listeners.forEach((fn) => { try { fn(msg); } catch (e) { console.error(e); } }); } function notifyStatus(next) { status = next; emit({ type: "__status", status: next }); } function connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/api/live`; notifyStatus("connecting"); try { socket = new WebSocket(url); } catch (e) { scheduleReconnect(); return; } socket.onopen = () => { reconnectAttempt = 0; notifyStatus("open"); }; socket.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === "snapshot") { instancesByPad.clear(); for (const inst of (msg.instances || [])) instancesByPad.set(inst.id, inst); } else if (msg.type === "instance_update") { instancesByPad.set(msg.instance.id, msg.instance); } emit(msg); }; socket.onclose = () => { notifyStatus("closed"); scheduleReconnect(); }; socket.onerror = () => { try { socket.close(); } catch {} }; } function scheduleReconnect() { if (reconnectTimer) return; const delay = Math.min(15000, 500 * Math.pow(1.6, reconnectAttempt++)); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } async function bootstrap() { try { const r = await fetch("/api/instances"); if (!r.ok) return; const list = await r.json(); instancesByPad.clear(); for (const inst of list) instancesByPad.set(inst.id, inst); emit({ type: "snapshot", instances: list }); } catch {} } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } function getInstances() { return [...instancesByPad.values()]; } function getStatus() { return status; } async function control(padCodes, action, args) { try { const r = await fetch("/api/control", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pad_codes: padCodes, action, args: args || {} }), }); if (!r.ok) { emit({ type: "notice", level: "bad", message: `control ${action}: HTTP ${r.status}` }); return null; } return await r.json(); } catch (e) { emit({ type: "notice", level: "bad", message: `control ${action}: сеть недоступна` }); return null; } } return { connect, bootstrap, subscribe, getInstances, getStatus, control, emit }; })(); function useLiveInstances() { const [tick, setTick] = useStateL(0); useEffectL(() => Live.subscribe((m) => { if (m.type === "snapshot" || m.type === "instance_update") setTick((t) => t + 1); }), []); return React.useMemo(() => Live.getInstances(), [tick]); } function useLiveStatus() { const [s, setS] = useStateL(Live.getStatus()); useEffectL(() => Live.subscribe((m) => { if (m.type === "__status") setS(m.status); }), []); return s; } function useLiveLogs(padCode, { limit = 500 } = {}) { const [logs, setLogs] = useStateL([]); const padRef = useRefL(padCode); padRef.current = padCode; useEffectL(() => { if (!padCode) { setLogs([]); return; } let cancelled = false; fetch(`/api/instances/${encodeURIComponent(padCode)}/logs?limit=${limit}`) .then((r) => r.ok ? r.json() : []) .then((rows) => { if (!cancelled) setLogs(rows); }) .catch(() => {}); const off = Live.subscribe((m) => { if (m.type === "log" && m.pad_code === padRef.current) { setLogs((prev) => { const next = prev.length >= 1000 ? prev.slice(-900) : prev.slice(); next.push(m.line); return next; }); } }); return () => { cancelled = true; off(); }; }, [padCode, limit]); return logs; } function useToasts() { const [toasts, setToasts] = useStateL([]); useEffectL(() => Live.subscribe((m) => { if (m.type !== "notice") return; const id = Math.random().toString(36).slice(2); const toast = { id, level: m.level || "warn", message: m.message || "", pads: m.pads || null, }; setToasts((prev) => [...prev.slice(-4), toast]); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6000); }), []); const dismiss = (id) => setToasts((prev) => prev.filter((t) => t.id !== id)); return [toasts, dismiss]; } Object.assign(window, { Live, useLiveInstances, useLiveStatus, useLiveLogs, useToasts });