Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
// 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
|
|
};
|
|
})();
|