初始化提交
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
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
This commit is contained in:
321
crates/openfang-api/static/js/api.js
Normal file
321
crates/openfang-api/static/js/api.js
Normal file
@@ -0,0 +1,321 @@
|
||||
// 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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user