- Fixed status indicator UI mapping in websocket.js and index.html. - Added missing CSS for connection status indicator and pulse animation. - Made initial model registry fetch asynchronous to prevent blocking server startup. - Improved configuration loading to correctly handle LLM_PROXY__SERVER__PORT from environment.
493 lines
16 KiB
JavaScript
493 lines
16 KiB
JavaScript
// WebSocket Manager for Real-time Updates
|
|
|
|
class WebSocketManager {
|
|
constructor() {
|
|
this.ws = null;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 5;
|
|
this.reconnectDelay = 1000;
|
|
this.isConnected = false;
|
|
this.subscribers = new Map();
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.connect();
|
|
this.setupStatusIndicator();
|
|
this.setupAutoReconnect();
|
|
}
|
|
|
|
connect() {
|
|
try {
|
|
// Determine WebSocket URL
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const host = window.location.host;
|
|
const wsUrl = `${protocol}//${host}/ws`;
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => this.onOpen();
|
|
this.ws.onclose = () => this.onClose();
|
|
this.ws.onerror = (error) => this.onError(error);
|
|
this.ws.onmessage = (event) => this.onMessage(event);
|
|
|
|
} catch (error) {
|
|
console.error('WebSocket connection error:', error);
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
onOpen() {
|
|
console.log('WebSocket connected');
|
|
this.isConnected = true;
|
|
this.reconnectAttempts = 0;
|
|
this.updateStatus('connected');
|
|
|
|
// Notify subscribers
|
|
this.notify('connection', { status: 'connected' });
|
|
|
|
// Send authentication if needed
|
|
if (window.authManager && window.authManager.token) {
|
|
this.send({
|
|
type: 'auth',
|
|
token: window.authManager.token
|
|
});
|
|
}
|
|
|
|
// Subscribe to default channels
|
|
this.send({
|
|
type: 'subscribe',
|
|
channels: ['requests', 'metrics', 'logs']
|
|
});
|
|
}
|
|
|
|
onClose() {
|
|
console.log('WebSocket disconnected');
|
|
this.isConnected = false;
|
|
this.updateStatus('disconnected');
|
|
|
|
// Notify subscribers
|
|
this.notify('connection', { status: 'disconnected' });
|
|
|
|
// Schedule reconnection
|
|
this.scheduleReconnect();
|
|
}
|
|
|
|
onError(error) {
|
|
console.error('WebSocket error:', error);
|
|
this.updateStatus('error');
|
|
}
|
|
|
|
onMessage(event) {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handleMessage(data);
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
}
|
|
|
|
handleMessage(data) {
|
|
// Handle request events
|
|
if (data.type === 'request') {
|
|
this.notify('requests', data.payload);
|
|
}
|
|
|
|
// Notify specific channel subscribers
|
|
const channel = data.channel || data.type;
|
|
if (channel && this.subscribers.has(channel)) {
|
|
this.subscribers.get(channel).forEach(callback => {
|
|
try {
|
|
callback(data.payload);
|
|
} catch (error) {
|
|
console.error('Error in WebSocket callback:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
handleRequest(request) {
|
|
// Update request counters
|
|
this.updateRequestCounters(request);
|
|
|
|
// Add to recent requests if on overview page
|
|
if (window.dashboard && window.dashboard.currentPage === 'overview') {
|
|
this.addRecentRequest(request);
|
|
}
|
|
|
|
// Update monitoring stream if on monitoring page
|
|
if (window.dashboard && window.dashboard.currentPage === 'monitoring') {
|
|
this.addToMonitoringStream(request);
|
|
}
|
|
}
|
|
|
|
handleMetric(metric) {
|
|
// Update charts with new metric data
|
|
this.updateCharts(metric);
|
|
|
|
// Update system metrics display
|
|
this.updateSystemMetrics(metric);
|
|
}
|
|
|
|
handleLog(log) {
|
|
// Add to logs table if on logs page
|
|
if (window.dashboard && window.dashboard.currentPage === 'logs') {
|
|
this.addLogEntry(log);
|
|
}
|
|
|
|
// Add to monitoring logs if on monitoring page
|
|
if (window.dashboard && window.dashboard.currentPage === 'monitoring') {
|
|
this.addToLogStream(log);
|
|
}
|
|
}
|
|
|
|
handleSystem(system) {
|
|
// Update system health indicators
|
|
this.updateSystemHealth(system);
|
|
}
|
|
|
|
handleError(error) {
|
|
console.error('Server error:', error);
|
|
|
|
// Show error toast
|
|
if (window.authManager) {
|
|
window.authManager.showToast(error.message || 'Server error', 'error');
|
|
}
|
|
}
|
|
|
|
send(data) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(data));
|
|
return true;
|
|
} else {
|
|
console.warn('WebSocket not connected, message not sent:', data);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
subscribe(channel, callback) {
|
|
if (!this.subscribers.has(channel)) {
|
|
this.subscribers.set(channel, new Set());
|
|
}
|
|
this.subscribers.get(channel).add(callback);
|
|
|
|
// Send subscription to server
|
|
this.send({
|
|
type: 'subscribe',
|
|
channels: [channel]
|
|
});
|
|
|
|
// Return unsubscribe function
|
|
return () => this.unsubscribe(channel, callback);
|
|
}
|
|
|
|
unsubscribe(channel, callback) {
|
|
if (this.subscribers.has(channel)) {
|
|
this.subscribers.get(channel).delete(callback);
|
|
|
|
// If no more subscribers, unsubscribe from server
|
|
if (this.subscribers.get(channel).size === 0) {
|
|
this.send({
|
|
type: 'unsubscribe',
|
|
channels: [channel]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
notify(channel, data) {
|
|
if (this.subscribers.has(channel)) {
|
|
this.subscribers.get(channel).forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error('Error in notification callback:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
scheduleReconnect() {
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.warn('Max reconnection attempts reached');
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
console.log(`Scheduling reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
|
|
setTimeout(() => {
|
|
if (!this.isConnected) {
|
|
this.connect();
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
setupAutoReconnect() {
|
|
// Reconnect when browser comes online
|
|
window.addEventListener('online', () => {
|
|
if (!this.isConnected) {
|
|
console.log('Browser online, attempting to reconnect...');
|
|
this.connect();
|
|
}
|
|
});
|
|
|
|
// Keepalive ping
|
|
setInterval(() => {
|
|
if (this.isConnected) {
|
|
this.send({ type: 'ping' });
|
|
}
|
|
}, 30000);
|
|
}
|
|
|
|
setupStatusIndicator() {
|
|
// Status indicator is already in the HTML
|
|
// This function just ensures it's properly styled
|
|
}
|
|
|
|
updateStatus(status) {
|
|
const statusElement = document.getElementById('connection-status');
|
|
if (!statusElement) return;
|
|
|
|
const dot = statusElement.querySelector('.status-dot');
|
|
const text = statusElement.querySelector('.status-text');
|
|
|
|
if (!dot || !text) return;
|
|
|
|
// Remove all status classes
|
|
dot.classList.remove('connected', 'disconnected', 'error', 'connecting');
|
|
|
|
// Add new status class
|
|
dot.classList.add(status);
|
|
|
|
// Update text
|
|
const statusText = {
|
|
'connected': 'Connected',
|
|
'disconnected': 'Disconnected',
|
|
'connecting': 'Connecting...',
|
|
'error': 'Connection Error'
|
|
};
|
|
|
|
text.textContent = statusText[status] || status;
|
|
}
|
|
|
|
// Helper methods for updating UI
|
|
updateRequestCounters(request) {
|
|
// Update request counters in overview stats
|
|
const requestCountElement = document.querySelector('[data-stat="total-requests"]');
|
|
if (requestCountElement) {
|
|
const currentCount = parseInt(requestCountElement.textContent) || 0;
|
|
requestCountElement.textContent = currentCount + 1;
|
|
}
|
|
|
|
// Update token counters
|
|
const tokenCountElement = document.querySelector('[data-stat="total-tokens"]');
|
|
if (tokenCountElement && (request.total_tokens || request.tokens)) {
|
|
const currentTokens = parseInt(tokenCountElement.textContent) || 0;
|
|
tokenCountElement.textContent = currentTokens + (request.total_tokens || request.tokens);
|
|
}
|
|
}
|
|
|
|
addRecentRequest(request) {
|
|
const tableBody = document.querySelector('#recent-requests tbody');
|
|
if (!tableBody) return;
|
|
|
|
const row = document.createElement('tr');
|
|
|
|
// Format time
|
|
const time = new Date(request.timestamp || Date.now()).toLocaleTimeString();
|
|
|
|
// Format status badge
|
|
const statusClass = request.status === 'success' ? 'success' :
|
|
request.status === 'error' ? 'danger' : 'warning';
|
|
const statusIcon = request.status === 'success' ? 'check-circle' :
|
|
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
|
|
|
row.innerHTML = `
|
|
<td>${time}</td>
|
|
<td>${request.client_id || 'Unknown'}</td>
|
|
<td>${request.provider || 'Unknown'}</td>
|
|
<td>${request.model || 'Unknown'}</td>
|
|
<td>${(request.total_tokens || request.tokens || 0)}</td>
|
|
<td>
|
|
<span class="status-badge ${statusClass}">
|
|
<i class="fas fa-${statusIcon}"></i>
|
|
${request.status || 'unknown'}
|
|
</span>
|
|
</td>
|
|
`;
|
|
|
|
// Add to top of table
|
|
tableBody.insertBefore(row, tableBody.firstChild);
|
|
|
|
// Limit to 50 rows
|
|
const rows = tableBody.querySelectorAll('tr');
|
|
if (rows.length > 50) {
|
|
tableBody.removeChild(rows[rows.length - 1]);
|
|
}
|
|
}
|
|
|
|
addToMonitoringStream(request) {
|
|
const streamElement = document.getElementById('request-stream');
|
|
if (!streamElement) return;
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = 'stream-entry';
|
|
|
|
// Format time
|
|
const time = new Date().toLocaleTimeString();
|
|
|
|
// Determine icon based on status
|
|
let icon = 'question-circle';
|
|
let color = 'var(--text-secondary)';
|
|
|
|
if (request.status === 'success') {
|
|
icon = 'check-circle';
|
|
color = 'var(--success)';
|
|
} else if (request.status === 'error') {
|
|
icon = 'exclamation-circle';
|
|
color = 'var(--danger)';
|
|
}
|
|
|
|
entry.innerHTML = `
|
|
<div class="stream-entry-time">${time}</div>
|
|
<div class="stream-entry-icon" style="color: ${color}">
|
|
<i class="fas fa-${icon}"></i>
|
|
</div>
|
|
<div class="stream-entry-content">
|
|
<strong>${request.client_id || 'Unknown'}</strong> →
|
|
${request.provider || 'Unknown'} (${request.model || 'Unknown'})
|
|
<div class="stream-entry-details">
|
|
${(request.total_tokens || request.tokens || 0)} tokens • ${(request.duration_ms || request.duration || 0)}ms
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add to top of stream
|
|
streamElement.insertBefore(entry, streamElement.firstChild);
|
|
|
|
// Limit to 20 entries
|
|
const entries = streamElement.querySelectorAll('.stream-entry');
|
|
if (entries.length > 20) {
|
|
streamElement.removeChild(entries[entries.length - 1]);
|
|
}
|
|
|
|
// Add highlight animation
|
|
entry.classList.add('highlight');
|
|
setTimeout(() => entry.classList.remove('highlight'), 1000);
|
|
}
|
|
|
|
updateCharts(metric) {
|
|
// This would update Chart.js charts with new data
|
|
// Implementation depends on specific chart setup
|
|
}
|
|
|
|
updateSystemMetrics(metric) {
|
|
const metricsElement = document.getElementById('system-metrics');
|
|
if (!metricsElement) return;
|
|
|
|
// Update specific metric displays
|
|
// This is a simplified example
|
|
}
|
|
|
|
addLogEntry(log) {
|
|
const tableBody = document.querySelector('#logs-table tbody');
|
|
if (!tableBody) return;
|
|
|
|
const row = document.createElement('tr');
|
|
|
|
// Format time
|
|
const time = new Date(log.timestamp || Date.now()).toLocaleString();
|
|
|
|
// Determine log level class
|
|
const levelClass = log.level || 'info';
|
|
|
|
row.innerHTML = `
|
|
<td>${time}</td>
|
|
<td>
|
|
<span class="status-badge ${levelClass}">
|
|
${levelClass.toUpperCase()}
|
|
</span>
|
|
</td>
|
|
<td>${log.source || 'Unknown'}</td>
|
|
<td>${log.message || ''}</td>
|
|
`;
|
|
|
|
// Add to top of table
|
|
tableBody.insertBefore(row, tableBody.firstChild);
|
|
|
|
// Limit to 100 rows
|
|
const rows = tableBody.querySelectorAll('tr');
|
|
if (rows.length > 100) {
|
|
tableBody.removeChild(rows[rows.length - 1]);
|
|
}
|
|
}
|
|
|
|
addToLogStream(log) {
|
|
const logStreamElement = document.getElementById('system-logs');
|
|
if (!logStreamElement) return;
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = `log-entry log-${log.level || 'info'}`;
|
|
|
|
// Format time
|
|
const time = new Date().toLocaleTimeString();
|
|
|
|
// Determine icon based on level
|
|
let icon = 'info-circle';
|
|
if (log.level === 'error') icon = 'exclamation-circle';
|
|
if (log.level === 'warn') icon = 'exclamation-triangle';
|
|
if (log.level === 'debug') icon = 'bug';
|
|
|
|
entry.innerHTML = `
|
|
<div class="log-time">${time}</div>
|
|
<div class="log-level">
|
|
<i class="fas fa-${icon}"></i>
|
|
</div>
|
|
<div class="log-message">${log.message || ''}</div>
|
|
`;
|
|
|
|
// Add to top of stream
|
|
logStreamElement.insertBefore(entry, logStreamElement.firstChild);
|
|
|
|
// Limit to 50 entries
|
|
const entries = logStreamElement.querySelectorAll('.log-entry');
|
|
if (entries.length > 50) {
|
|
logStreamElement.removeChild(entries[entries.length - 1]);
|
|
}
|
|
}
|
|
|
|
updateSystemHealth(system) {
|
|
const healthElement = document.getElementById('system-health');
|
|
if (!healthElement) return;
|
|
|
|
// Update system health indicators
|
|
// This is a simplified example
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.isConnected = false;
|
|
this.updateStatus('disconnected');
|
|
}
|
|
|
|
reconnect() {
|
|
this.disconnect();
|
|
this.connect();
|
|
}
|
|
}
|
|
|
|
// Initialize WebSocket manager when DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.wsManager = new WebSocketManager();
|
|
});
|
|
|
|
// Export for use in other modules
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = WebSocketManager;
|
|
} |