// OpenFang API Client — Fetch wrapper, WebSocket manager, auth injection, toast notifications 'use strict'; // ── Toast Notification System ── var OpenFangToast = (function() { var _container = null; var _toastId = 0; function getContainer() { if (!_container) { _container = document.getElementById('toast-container'); if (!_container) { _container = document.createElement('div'); _container.id = 'toast-container'; _container.className = 'toast-container'; document.body.appendChild(_container); } } return _container; } function toast(message, type, duration) { type = type || 'info'; duration = duration || 4000; var id = ++_toastId; var el = document.createElement('div'); el.className = 'toast toast-' + type; el.setAttribute('data-toast-id', id); var msgSpan = document.createElement('span'); msgSpan.className = 'toast-msg'; msgSpan.textContent = message; el.appendChild(msgSpan); var closeBtn = document.createElement('button'); closeBtn.className = 'toast-close'; closeBtn.textContent = '\u00D7'; closeBtn.onclick = function() { dismissToast(el); }; el.appendChild(closeBtn); el.onclick = function(e) { if (e.target === el) dismissToast(el); }; getContainer().appendChild(el); // Auto-dismiss if (duration > 0) { setTimeout(function() { dismissToast(el); }, duration); } return id; } function dismissToast(el) { if (!el || el.classList.contains('toast-dismiss')) return; el.classList.add('toast-dismiss'); setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 300); } function success(msg, duration) { return toast(msg, 'success', duration); } function error(msg, duration) { return toast(msg, 'error', duration || 6000); } function warn(msg, duration) { return toast(msg, 'warn', duration || 5000); } function info(msg, duration) { return toast(msg, 'info', duration); } // Styled confirmation modal — replaces native confirm() function confirm(title, message, onConfirm) { var overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; var modal = document.createElement('div'); modal.className = 'confirm-modal'; var titleEl = document.createElement('div'); titleEl.className = 'confirm-title'; titleEl.textContent = title; modal.appendChild(titleEl); var msgEl = document.createElement('div'); msgEl.className = 'confirm-message'; msgEl.textContent = message; modal.appendChild(msgEl); var actions = document.createElement('div'); actions.className = 'confirm-actions'; var cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-ghost confirm-cancel'; cancelBtn.textContent = 'Cancel'; actions.appendChild(cancelBtn); var okBtn = document.createElement('button'); okBtn.className = 'btn btn-danger confirm-ok'; okBtn.textContent = 'Confirm'; actions.appendChild(okBtn); modal.appendChild(actions); overlay.appendChild(modal); function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); document.removeEventListener('keydown', onKey); } cancelBtn.onclick = close; okBtn.onclick = function() { close(); if (onConfirm) onConfirm(); }; overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); }); function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey); document.body.appendChild(overlay); okBtn.focus(); } return { toast: toast, success: success, error: error, warn: warn, info: info, confirm: confirm }; })(); // ── Friendly Error Messages ── function friendlyError(status, serverMsg) { if (status === 0 || !status) return 'Cannot reach daemon — is openfang running?'; if (status === 401) return 'Not authorized — check your API key'; if (status === 403) return 'Permission denied'; if (status === 404) return serverMsg || 'Resource not found'; if (status === 429) return 'Rate limited — slow down and try again'; if (status === 413) return 'Request too large'; if (status === 500) return 'Server error — check daemon logs'; if (status === 502 || status === 503) return 'Daemon unavailable — is it running?'; return serverMsg || 'Unexpected error (' + status + ')'; } // ── API Client ── var OpenFangAPI = (function() { var BASE = window.location.origin; var WS_BASE = BASE.replace(/^http/, 'ws'); var _authToken = ''; // Connection state tracking var _connectionState = 'connected'; var _reconnectAttempt = 0; var _connectionListeners = []; function setAuthToken(token) { _authToken = token; } function headers() { var h = { 'Content-Type': 'application/json' }; if (_authToken) h['Authorization'] = 'Bearer ' + _authToken; return h; } function setConnectionState(state) { if (_connectionState === state) return; _connectionState = state; _connectionListeners.forEach(function(fn) { fn(state); }); } function onConnectionChange(fn) { _connectionListeners.push(fn); } function request(method, path, body) { var opts = { method: method, headers: headers() }; if (body !== undefined) opts.body = JSON.stringify(body); return fetch(BASE + path, opts).then(function(r) { if (_connectionState !== 'connected') setConnectionState('connected'); if (!r.ok) { return r.text().then(function(text) { var msg = ''; try { var json = JSON.parse(text); msg = json.error || r.statusText; } catch(e) { msg = r.statusText; } throw new Error(friendlyError(r.status, msg)); }); } var ct = r.headers.get('content-type') || ''; if (ct.indexOf('application/json') >= 0) return r.json(); return r.text().then(function(t) { try { return JSON.parse(t); } catch(e) { return { text: t }; } }); }).catch(function(e) { if (e.name === 'TypeError' && e.message.includes('Failed to fetch')) { setConnectionState('disconnected'); throw new Error('Cannot connect to daemon — is openfang running?'); } throw e; }); } function get(path) { return request('GET', path); } function post(path, body) { return request('POST', path, body); } function put(path, body) { return request('PUT', path, body); } function patch(path, body) { return request('PATCH', path, body); } function del(path) { return request('DELETE', path); } // WebSocket manager with auto-reconnect var _ws = null; var _wsCallbacks = {}; var _wsConnected = false; var _wsAgentId = null; var _reconnectTimer = null; var _reconnectAttempts = 0; var MAX_RECONNECT = 5; function wsConnect(agentId, callbacks) { wsDisconnect(); _wsCallbacks = callbacks || {}; _wsAgentId = agentId; _reconnectAttempts = 0; _doConnect(agentId); } function _doConnect(agentId) { try { var url = WS_BASE + '/api/agents/' + agentId + '/ws'; if (_authToken) url += '?token=' + encodeURIComponent(_authToken); _ws = new WebSocket(url); _ws.onopen = function() { _wsConnected = true; _reconnectAttempts = 0; setConnectionState('connected'); if (_reconnectAttempt > 0) { OpenFangToast.success('Reconnected'); _reconnectAttempt = 0; } if (_wsCallbacks.onOpen) _wsCallbacks.onOpen(); }; _ws.onmessage = function(e) { try { var data = JSON.parse(e.data); if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data); } catch(err) { /* ignore parse errors */ } }; _ws.onclose = function(e) { _wsConnected = false; _ws = null; if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) { _reconnectAttempts++; _reconnectAttempt = _reconnectAttempts; setConnectionState('reconnecting'); if (_reconnectAttempts === 1) { OpenFangToast.warn('Connection lost, reconnecting...'); } var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 10000); _reconnectTimer = setTimeout(function() { _doConnect(_wsAgentId); }, delay); return; } if (_wsAgentId && _reconnectAttempts >= MAX_RECONNECT) { setConnectionState('disconnected'); OpenFangToast.error('Connection lost — switched to HTTP mode', 0); } if (_wsCallbacks.onClose) _wsCallbacks.onClose(); }; _ws.onerror = function() { _wsConnected = false; if (_wsCallbacks.onError) _wsCallbacks.onError(); }; } catch(e) { _wsConnected = false; } } function wsDisconnect() { _wsAgentId = null; _reconnectAttempts = MAX_RECONNECT; if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } if (_ws) { _ws.close(1000); _ws = null; } _wsConnected = false; } function wsSend(data) { if (_ws && _ws.readyState === WebSocket.OPEN) { _ws.send(JSON.stringify(data)); return true; } return false; } function isWsConnected() { return _wsConnected; } function getConnectionState() { return _connectionState; } function getToken() { return _authToken; } function upload(agentId, file) { var hdrs = { 'Content-Type': file.type || 'application/octet-stream', 'X-Filename': file.name }; if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken; return fetch(BASE + '/api/agents/' + agentId + '/upload', { method: 'POST', headers: hdrs, body: file }).then(function(r) { if (!r.ok) throw new Error('Upload failed'); return r.json(); }); } return { setAuthToken: setAuthToken, getToken: getToken, get: get, post: post, put: put, patch: patch, del: del, delete: del, upload: upload, wsConnect: wsConnect, wsDisconnect: wsDisconnect, wsSend: wsSend, isWsConnected: isWsConnected, getConnectionState: getConnectionState, onConnectionChange: onConnectionChange }; })();