pull/59/head
SovietSpiderCat 2025-08-20 00:17:20 +03:00
parent 911bfb88d1
commit 421d95565c
3 changed files with 273 additions and 91 deletions

View File

@ -2,16 +2,28 @@ class MetricsService {
constructor() { constructor() {
this.baseUrl = '/metrics-ws'; this.baseUrl = '/metrics-ws';
this.socket = null; this.socket = null;
this.subscriptions = new Map(); this.subscriptions = new Map(); // Хранит подписки на real-time данные
this.pendingRequests = new Map(); this.pendingRequests = new Map(); // Для разовых запросов
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5; this.maxReconnectAttempts = 5;
this.reconnectDelay = 5000; this.reconnectDelay = 5000;
this.connectionCallbacks = new Set(); // Колбэки для событий подключения
window.addEventListener('beforeunload', () => this.cleanupAll()); window.addEventListener('beforeunload', () => this.cleanupAll());
window.addEventListener('pagehide', () => this.cleanupAll()); window.addEventListener('pagehide', () => this.cleanupAll());
} }
// Новый метод для отслеживания состояния подключения
onConnectionChange(callback) {
this.connectionCallbacks.add(callback);
return () => this.connectionCallbacks.delete(callback);
}
// Уведомление всех подписчиков о изменении состояния
notifyConnectionChange(connected) {
this.connectionCallbacks.forEach(cb => cb(connected));
}
handleServerMessage(msg) { handleServerMessage(msg) {
try { try {
if (!msg || typeof msg !== 'object') { if (!msg || typeof msg !== 'object') {
@ -22,25 +34,25 @@ class MetricsService {
const { event, data, requestId } = msg; const { event, data, requestId } = msg;
switch (event) { switch (event) {
case 'metrics-data': case 'connected':
if (requestId && this.pendingRequests.has(requestId)) { console.log('Server connection confirmed:', data);
const { resolve } = this.pendingRequests.get(requestId); this.notifyConnectionChange(true);
resolve(data);
this.pendingRequests.delete(requestId);
} else {
const metricKey = data.metric;
const callbacks = this.subscriptions.get(metricKey) || [];
callbacks.forEach(cb => cb(data));
}
break; break;
case 'metrics-error': case 'realtime-data':
if (requestId && this.pendingRequests.has(requestId)) { this.handleRealtimeData(data, requestId);
const { reject } = this.pendingRequests.get(requestId); break;
reject(new Error(data.error));
this.pendingRequests.delete(requestId); case 'historical-data':
} this.handleHistoricalData(data, requestId);
break;
case 'current-data':
this.handleCurrentData(data, requestId);
break;
case 'error':
this.handleError(data, requestId);
break; break;
default: default:
@ -51,6 +63,54 @@ class MetricsService {
} }
} }
handleRealtimeData(data, requestId) {
const { metric, filters, data: metricsData, type } = data;
const metricKey = this.getMetricKey(metric, filters);
if (requestId && this.pendingRequests.has(requestId)) {
// Это ответ на разовый запрос
const { resolve } = this.pendingRequests.get(requestId);
resolve(metricsData);
this.pendingRequests.delete(requestId);
} else {
// Это обновление по подписке
const callbacks = this.subscriptions.get(metricKey) || [];
callbacks.forEach(cb => cb({
data: metricsData,
type: type || 'update',
metric,
filters,
timestamp: Date.now()
}));
}
}
handleHistoricalData(data, requestId) {
if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId);
resolve(data.data || data);
this.pendingRequests.delete(requestId);
}
}
handleCurrentData(data, requestId) {
if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId);
resolve(data.data || data);
this.pendingRequests.delete(requestId);
}
}
handleError(data, requestId) {
if (requestId && this.pendingRequests.has(requestId)) {
const { reject } = this.pendingRequests.get(requestId);
reject(new Error(data.error || 'Unknown error'));
this.pendingRequests.delete(requestId);
} else {
console.error('Server error:', data.error);
}
}
connectWebSocket() { connectWebSocket() {
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
return; return;
@ -58,25 +118,27 @@ class MetricsService {
console.log('Connecting WebSocket...'); console.log('Connecting WebSocket...');
this.socket = new WebSocket(this.baseUrl); this.socket = new WebSocket(this.baseUrl);
this.notifyConnectionChange(false);
this.socket.addEventListener('open', () => { this.socket.addEventListener('open', () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.subscriptions.forEach((_, metricKey) => { this.notifyConnectionChange(true);
const filters = this.parseFiltersFromKey(metricKey);
const [metric] = metricKey.split('?'); // Переподписываемся на все активные подписки
this.sendMessage('subscribe-metric', { metric, filters }); this.resubscribeAll();
});
}); });
this.socket.addEventListener('close', () => { this.socket.addEventListener('close', (event) => {
console.log('WebSocket disconnected'); console.log('WebSocket disconnected', event.code, event.reason);
this.socket = null; this.socket = null;
this.notifyConnectionChange(false);
this.scheduleReconnect(); this.scheduleReconnect();
}); });
this.socket.addEventListener('error', (err) => { this.socket.addEventListener('error', (err) => {
console.error('WebSocket error:', err); console.error('WebSocket error:', err);
this.notifyConnectionChange(false);
}); });
this.socket.addEventListener('message', (event) => { this.socket.addEventListener('message', (event) => {
@ -89,6 +151,18 @@ class MetricsService {
}); });
} }
// Переподписка на все активные подписки после переподключения
resubscribeAll() {
this.subscriptions.forEach((_, metricKey) => {
const { metric, filters } = this.parseMetricKey(metricKey);
this.sendMessage('subscribe-realtime', {
metric,
filters,
interval: 10000 // Дефолтный интервал
});
});
}
scheduleReconnect() { scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn('Max reconnect attempts reached'); console.warn('Max reconnect attempts reached');
@ -104,12 +178,13 @@ class MetricsService {
}, delay); }, delay);
} }
sendMessage(event, data) { sendMessage(event, data, requestId) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
if (this.socket && this.socket.readyState === WebSocket.CONNECTING) { if (this.socket && this.socket.readyState === WebSocket.CONNECTING) {
// Ждем открытия соединения
const waitForOpen = () => { const waitForOpen = () => {
if (this.socket.readyState === WebSocket.OPEN) { if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ event, data })); this.doSendMessage(event, data, requestId);
} else if (this.socket.readyState === WebSocket.CONNECTING) { } else if (this.socket.readyState === WebSocket.CONNECTING) {
setTimeout(waitForOpen, 100); setTimeout(waitForOpen, 100);
} }
@ -118,29 +193,77 @@ class MetricsService {
} else { } else {
console.warn('WebSocket not connected, cannot send:', event); console.warn('WebSocket not connected, cannot send:', event);
this.connectWebSocket(); this.connectWebSocket();
// Сохраняем сообщение для отправки после подключения
setTimeout(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.doSendMessage(event, data, requestId);
}
}, 1000);
} }
return; return;
} }
this.socket.send(JSON.stringify({ event, data })); this.doSendMessage(event, data, requestId);
} }
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) { doSendMessage(event, data, requestId) {
const message = requestId ? { event, data, requestId } : { event, data };
this.socket.send(JSON.stringify(message));
}
// ============ ПУБЛИЧНЫЕ МЕТОДЫ ============
// Подписка на real-time данные
subscribeToMetric(metric, filters = {}, callback, interval = 10000) {
this.connectWebSocket();
const metricKey = this.getMetricKey(metric, filters);
if (!this.subscriptions.has(metricKey)) {
this.subscriptions.set(metricKey, []);
this.sendMessage('subscribe-realtime', {
metric,
filters,
interval
});
}
const callbacks = this.subscriptions.get(metricKey);
callbacks.push(callback);
// Возвращаем функцию для отписки
return () => this.unsubscribeFromMetric(metric, filters, callback);
}
// Отписка от real-time данных
unsubscribeFromMetric(metric, filters = {}, callback) {
const metricKey = this.getMetricKey(metric, filters);
const callbacks = this.subscriptions.get(metricKey) || [];
const filtered = callbacks.filter(cb => cb !== callback);
if (filtered.length === 0) {
this.subscriptions.delete(metricKey);
this.sendMessage('unsubscribe-realtime', { metric, filters });
} else {
this.subscriptions.set(metricKey, filtered);
}
}
// Запрос исторических данных (разовый)
async fetchMetricsRange(metric, start, end, step = 60, filters = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.connectWebSocket(); this.connectWebSocket();
const requestId = `range-${Date.now()}`; const requestId = `historical-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Таймаут для очистки
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('Request timeout')); reject(new Error('Historical data request timeout'));
this.pendingRequests.delete(requestId); this.pendingRequests.delete(requestId);
}, 12000); }, 30000); // 30 секунд таймаут для historical данных
this.pendingRequests.set(requestId, { this.pendingRequests.set(requestId, {
resolve: (responseData) => { resolve: (data) => {
clearTimeout(timeout); clearTimeout(timeout);
const data = Array.isArray(responseData) ? responseData :
(responseData?.data || []);
resolve(data); resolve(data);
}, },
reject: (err) => { reject: (err) => {
@ -149,64 +272,109 @@ class MetricsService {
} }
}); });
this.sendMessage('get-metrics', { this.sendMessage('get-historical', {
metric, start, end, step, filters, isRangeQuery: true, requestId metric,
}); start: Math.floor(start / 1000) * 1000, // Ensure milliseconds
end: Math.floor(end / 1000) * 1000,
step,
filters
}, requestId);
}); });
} }
subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) { // Запрос текущих данных (разовый)
async fetchCurrentMetrics(metric, filters = {}) {
return new Promise((resolve, reject) => {
this.connectWebSocket(); this.connectWebSocket();
const requestId = `current-${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (!this.subscriptions.has(metricKey)) { const timeout = setTimeout(() => {
this.subscriptions.set(metricKey, []); reject(new Error('Current data request timeout'));
const [metric] = metricKey.split('?'); this.pendingRequests.delete(requestId);
this.sendMessage('subscribe-metric', { metric, interval, filters }); }, 10000); // 10 секунд таймаут
this.pendingRequests.set(requestId, {
resolve: (data) => {
clearTimeout(timeout);
resolve(data);
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
}
});
this.sendMessage('get-current', {
metric,
filters
}, requestId);
});
} }
const callbacks = this.subscriptions.get(metricKey); // Отписка от всех подписок
callbacks.push(callback); unsubscribeAll() {
this.sendMessage('unsubscribe-all', {});
return () => this.unsubscribeFromMetric(metricKey, callback); this.subscriptions.clear();
} }
unsubscribeFromMetric(metricKey, callback) { // ============ ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ============
const callbacks = this.subscriptions.get(metricKey) || [];
const filtered = callbacks.filter(cb => cb !== callback);
if (filtered.length === 0) { getMetricKey(metric, filters) {
this.subscriptions.delete(metricKey); const sortedKeys = Object.keys(filters).sort();
const [metric] = metricKey.split('?'); const filterString = sortedKeys
this.sendMessage('unsubscribe-metric', { metric }); .map(key => `${key}=${encodeURIComponent(filters[key])}`)
} else { .join('&');
this.subscriptions.set(metricKey, filtered);
} return filterString ? `${metric}?${filterString}` : metric;
} }
parseFiltersFromKey(metricKey) { parseMetricKey(metricKey) {
const parts = metricKey.split('?'); const [metric, query] = metricKey.split('?');
if (parts.length < 2) return {}; const filters = {};
return parts[1].split('&').reduce((acc, pair) => {
if (query) {
query.split('&').forEach(pair => {
const [key, value] = pair.split('='); const [key, value] = pair.split('=');
if (key && value) acc[key] = value; if (key && value) {
return acc; filters[decodeURIComponent(key)] = decodeURIComponent(value);
}, {}); }
});
}
return { metric, filters };
} }
cleanupAll() { cleanupAll() {
this.sendMessage('unsubscribe-all', {}); this.unsubscribeAll();
this.subscriptions.clear();
this.disconnectWebSocket(); this.disconnectWebSocket();
} }
disconnectWebSocket() { disconnectWebSocket() {
if (this.socket) { if (this.socket) {
this.socket.close(); this.socket.close(1000, 'Client disconnected');
this.socket = null; this.socket = null;
} }
this.notifyConnectionChange(false);
}
// Проверка состояния подключения
isConnected() {
return this.socket?.readyState === WebSocket.OPEN;
}
// Получение текущего состояния
getConnectionState() {
return this.socket ? this.socket.readyState : WebSocket.CLOSED;
} }
} }
// Создаем глобальный экземпляр
const metricsService = new MetricsService(); const metricsService = new MetricsService();
// Экспорт для использования в модульной системе
export default metricsService; export default metricsService;
// Глобальный экспорт для прямого использования в браузере
if (typeof window !== 'undefined') {
window.MetricsService = metricsService;
}

View File

@ -34,6 +34,8 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
const TIME_WINDOW_MS = 3600 * 1000; const TIME_WINDOW_MS = 3600 * 1000;
// Эта функция может больше не понадобиться, так как
// сервис сам генерирует ключи, но оставьте для совместимости
const getSubscriptionKey = () => { const getSubscriptionKey = () => {
const filterParts = []; const filterParts = [];
if (device) filterParts.push(`device=${encodeURIComponent(device)}`); if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
@ -120,10 +122,12 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
}; };
const step = calculateStep(start, end); const step = calculateStep(start, end);
// Используем новый метод для исторических данных
const data = await metricsService.fetchMetricsRange( const data = await metricsService.fetchMetricsRange(
metricName, metricName,
Math.floor(start.getTime() / 1000), start.getTime(), // Теперь передаем timestamp в миллисекундах
Math.floor(end.getTime() / 1000), end.getTime(),
step, step,
extendedFilters extendedFilters
); );
@ -132,7 +136,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
const limitedData = formattedData.length > MAX_POINTS const limitedData = formattedData.length > MAX_POINTS
? formattedData.slice(-MAX_POINTS) ? downsampleData(formattedData, MAX_POINTS)
: formattedData; : formattedData;
if (limitedData.length > 0) { if (limitedData.length > 0) {
@ -163,12 +167,15 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
fetchHistoricalData(start, end).finally(() => setIsLoading(false)); fetchHistoricalData(start, end).finally(() => setIsLoading(false));
// Изменяем параметры подписки
return metricsService.subscribeToMetric( return metricsService.subscribeToMetric(
getSubscriptionKey(), metricName, // Теперь передаем просто имя метрики
(newData) => { { ...filters, device, source_id }, // Фильры отдельным параметром
console.log('Received WS update:', newData); (update) => { // Колбэк получает объект с данными
if (!Array.isArray(newData)) { console.log('Received WS update:', update);
console.error('Expected array in WS update, got:', typeof newData);
if (!update || !Array.isArray(update.data)) {
console.error('Invalid update format:', update);
return; return;
} }
@ -176,7 +183,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
const now = Date.now(); const now = Date.now();
const cutoffTime = now - TIME_WINDOW_MS; const cutoffTime = now - TIME_WINDOW_MS;
const formattedNew = formatMetricData(newData) const formattedNew = formatMetricData(update.data)
.filter(point => point.timestamp >= cutoffTime); .filter(point => point.timestamp >= cutoffTime);
const filteredPrev = prev.filter(point => const filteredPrev = prev.filter(point =>
@ -194,15 +201,18 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
: merged; : merged;
}); });
}, },
1000, 5000 // Интервал обновления (можно настроить)
{ ...filters, device, source_id }
); );
}; };
const stopRealtimeUpdates = () => { const stopRealtimeUpdates = () => {
setIsLiveUpdating(false); setIsLiveUpdating(false);
metricsService.unsubscribeFromMetric(getSubscriptionKey()); // Теперь отписываемся по метрике и фильтрам
metricsService.unsubscribeFromMetric(
metricName,
{ ...filters, device, source_id }
);
}; };
const handleCustomRangeApply = () => { const handleCustomRangeApply = () => {
@ -215,6 +225,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
console.log('Metric changed:', { metricName, device, source_id, filters }); console.log('Metric changed:', { metricName, device, source_id, filters });
let unsubscribe; let unsubscribe;
const init = async () => { const init = async () => {
if (mode === 'realtime') { if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates(); unsubscribe = startRealtimeUpdates();
@ -226,10 +237,14 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
init(); init();
return () => { return () => {
if (unsubscribe) unsubscribe(); if (unsubscribe) {
stopRealtimeUpdates(); unsubscribe(); // Вызываем функцию отписки
}
if (mode === 'realtime') {
stopRealtimeUpdates(); // Дополнительная очистка
}
}; };
}, [mode, metricName, device, source_id, filters]); }, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
const metaInfo = [ const metaInfo = [
metricMeta.instance && `Instance: ${metricMeta.instance}`, metricMeta.instance && `Instance: ${metricMeta.instance}`,

View File

@ -20,7 +20,6 @@ export default defineConfig({
}, },
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://localhost:3000',
ws: true,
changeOrigin: true, changeOrigin: true,
bypass(req, res, options) { bypass(req, res, options) {
console.log('Proxying request:', req.url); console.log('Proxying request:', req.url);