From 421d95565c33a3feb0463d59bd481426d7687c7d Mon Sep 17 00:00:00 2001 From: SovietSpiderCat Date: Wed, 20 Aug 2025 00:17:20 +0300 Subject: [PATCH 1/5] fixed WS --- src/Charts2/Components/metricsService.jsx | 312 +++++++++++++++++----- src/Charts2/PrometheusChart.jsx | 51 ++-- vite.config.js | 1 - 3 files changed, 273 insertions(+), 91 deletions(-) diff --git a/src/Charts2/Components/metricsService.jsx b/src/Charts2/Components/metricsService.jsx index 60a58e9..f549d27 100644 --- a/src/Charts2/Components/metricsService.jsx +++ b/src/Charts2/Components/metricsService.jsx @@ -2,16 +2,28 @@ class MetricsService { constructor() { this.baseUrl = '/metrics-ws'; this.socket = null; - this.subscriptions = new Map(); - this.pendingRequests = new Map(); + this.subscriptions = new Map(); // Хранит подписки на real-time данные + this.pendingRequests = new Map(); // Для разовых запросов this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 5000; + this.connectionCallbacks = new Set(); // Колбэки для событий подключения window.addEventListener('beforeunload', () => 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) { try { if (!msg || typeof msg !== 'object') { @@ -22,25 +34,25 @@ class MetricsService { const { event, data, requestId } = msg; switch (event) { - case 'metrics-data': - if (requestId && this.pendingRequests.has(requestId)) { - const { resolve } = this.pendingRequests.get(requestId); - resolve(data); - this.pendingRequests.delete(requestId); - } else { - const metricKey = data.metric; - const callbacks = this.subscriptions.get(metricKey) || []; - callbacks.forEach(cb => cb(data)); - } - + case 'connected': + console.log('Server connection confirmed:', data); + this.notifyConnectionChange(true); break; - case 'metrics-error': - if (requestId && this.pendingRequests.has(requestId)) { - const { reject } = this.pendingRequests.get(requestId); - reject(new Error(data.error)); - this.pendingRequests.delete(requestId); - } + case 'realtime-data': + this.handleRealtimeData(data, requestId); + break; + + case 'historical-data': + this.handleHistoricalData(data, requestId); + break; + + case 'current-data': + this.handleCurrentData(data, requestId); + break; + + case 'error': + this.handleError(data, requestId); break; 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() { if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; @@ -58,25 +118,27 @@ class MetricsService { console.log('Connecting WebSocket...'); this.socket = new WebSocket(this.baseUrl); + this.notifyConnectionChange(false); this.socket.addEventListener('open', () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; - this.subscriptions.forEach((_, metricKey) => { - const filters = this.parseFiltersFromKey(metricKey); - const [metric] = metricKey.split('?'); - this.sendMessage('subscribe-metric', { metric, filters }); - }); + this.notifyConnectionChange(true); + + // Переподписываемся на все активные подписки + this.resubscribeAll(); }); - this.socket.addEventListener('close', () => { - console.log('WebSocket disconnected'); + this.socket.addEventListener('close', (event) => { + console.log('WebSocket disconnected', event.code, event.reason); this.socket = null; + this.notifyConnectionChange(false); this.scheduleReconnect(); }); this.socket.addEventListener('error', (err) => { console.error('WebSocket error:', err); + this.notifyConnectionChange(false); }); 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() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.warn('Max reconnect attempts reached'); @@ -104,12 +178,13 @@ class MetricsService { }, delay); } - sendMessage(event, data) { + sendMessage(event, data, requestId) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.CONNECTING) { + // Ждем открытия соединения const waitForOpen = () => { 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) { setTimeout(waitForOpen, 100); } @@ -118,29 +193,77 @@ class MetricsService { } else { console.warn('WebSocket not connected, cannot send:', event); this.connectWebSocket(); + // Сохраняем сообщение для отправки после подключения + setTimeout(() => { + if (this.socket?.readyState === WebSocket.OPEN) { + this.doSendMessage(event, data, requestId); + } + }, 1000); } 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) => { this.connectWebSocket(); - const requestId = `range-${Date.now()}`; + const requestId = `historical-${Date.now()}-${Math.random().toString(36).slice(2)}`; - // Таймаут для очистки const timeout = setTimeout(() => { - reject(new Error('Request timeout')); + reject(new Error('Historical data request timeout')); this.pendingRequests.delete(requestId); - }, 12000); + }, 30000); // 30 секунд таймаут для historical данных this.pendingRequests.set(requestId, { - resolve: (responseData) => { + resolve: (data) => { clearTimeout(timeout); - const data = Array.isArray(responseData) ? responseData : - (responseData?.data || []); resolve(data); }, reject: (err) => { @@ -149,64 +272,109 @@ class MetricsService { } }); - this.sendMessage('get-metrics', { - metric, start, end, step, filters, isRangeQuery: true, requestId - }); + this.sendMessage('get-historical', { + metric, + start: Math.floor(start / 1000) * 1000, // Ensure milliseconds + end: Math.floor(end / 1000) * 1000, + step, + filters + }, requestId); }); } - subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) { - this.connectWebSocket(); + // Запрос текущих данных (разовый) + async fetchCurrentMetrics(metric, filters = {}) { + return new Promise((resolve, reject) => { + this.connectWebSocket(); + const requestId = `current-${Date.now()}-${Math.random().toString(36).slice(2)}`; - if (!this.subscriptions.has(metricKey)) { - this.subscriptions.set(metricKey, []); - const [metric] = metricKey.split('?'); - this.sendMessage('subscribe-metric', { metric, interval, filters }); - } + const timeout = setTimeout(() => { + reject(new Error('Current data request timeout')); + this.pendingRequests.delete(requestId); + }, 10000); // 10 секунд таймаут - const callbacks = this.subscriptions.get(metricKey); - callbacks.push(callback); + this.pendingRequests.set(requestId, { + resolve: (data) => { + clearTimeout(timeout); + resolve(data); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + } + }); - return () => this.unsubscribeFromMetric(metricKey, callback); + this.sendMessage('get-current', { + metric, + filters + }, requestId); + }); } - unsubscribeFromMetric(metricKey, callback) { - const callbacks = this.subscriptions.get(metricKey) || []; - const filtered = callbacks.filter(cb => cb !== callback); - - if (filtered.length === 0) { - this.subscriptions.delete(metricKey); - const [metric] = metricKey.split('?'); - this.sendMessage('unsubscribe-metric', { metric }); - } else { - this.subscriptions.set(metricKey, filtered); - } + // Отписка от всех подписок + unsubscribeAll() { + this.sendMessage('unsubscribe-all', {}); + this.subscriptions.clear(); } - parseFiltersFromKey(metricKey) { - const parts = metricKey.split('?'); - if (parts.length < 2) return {}; - return parts[1].split('&').reduce((acc, pair) => { - const [key, value] = pair.split('='); - if (key && value) acc[key] = value; - return acc; - }, {}); + // ============ ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ============ + + getMetricKey(metric, filters) { + const sortedKeys = Object.keys(filters).sort(); + const filterString = sortedKeys + .map(key => `${key}=${encodeURIComponent(filters[key])}`) + .join('&'); + + return filterString ? `${metric}?${filterString}` : metric; + } + + parseMetricKey(metricKey) { + const [metric, query] = metricKey.split('?'); + const filters = {}; + + if (query) { + query.split('&').forEach(pair => { + const [key, value] = pair.split('='); + if (key && value) { + filters[decodeURIComponent(key)] = decodeURIComponent(value); + } + }); + } + + return { metric, filters }; } cleanupAll() { - this.sendMessage('unsubscribe-all', {}); - this.subscriptions.clear(); + this.unsubscribeAll(); this.disconnectWebSocket(); } disconnectWebSocket() { if (this.socket) { - this.socket.close(); + this.socket.close(1000, 'Client disconnected'); 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(); + +// Экспорт для использования в модульной системе export default metricsService; +// Глобальный экспорт для прямого использования в браузере +if (typeof window !== 'undefined') { + window.MetricsService = metricsService; +} \ No newline at end of file diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx index f3ea659..8240209 100644 --- a/src/Charts2/PrometheusChart.jsx +++ b/src/Charts2/PrometheusChart.jsx @@ -31,9 +31,11 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { const [showLogs, setShowLogs] = useState(false); const [statusLogs, setStatusLogs] = useState([]); const MAX_POINTS = 50; - const TIME_WINDOW_MS = 3600 * 1000; + const TIME_WINDOW_MS = 3600 * 1000; + // Эта функция может больше не понадобиться, так как + // сервис сам генерирует ключи, но оставьте для совместимости const getSubscriptionKey = () => { const filterParts = []; if (device) filterParts.push(`device=${encodeURIComponent(device)}`); @@ -63,7 +65,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { source_id: item.source_id || null, description: item.description || description }; - }).filter(Boolean) + }).filter(Boolean) .sort((a, b) => a.timestamp - b.timestamp); }; @@ -120,10 +122,12 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { }; const step = calculateStep(start, end); + + // Используем новый метод для исторических данных const data = await metricsService.fetchMetricsRange( metricName, - Math.floor(start.getTime() / 1000), - Math.floor(end.getTime() / 1000), + start.getTime(), // Теперь передаем timestamp в миллисекундах + end.getTime(), step, extendedFilters ); @@ -132,7 +136,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { .sort((a, b) => a.timestamp - b.timestamp); const limitedData = formattedData.length > MAX_POINTS - ? formattedData.slice(-MAX_POINTS) + ? downsampleData(formattedData, MAX_POINTS) : formattedData; if (limitedData.length > 0) { @@ -163,12 +167,15 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { fetchHistoricalData(start, end).finally(() => setIsLoading(false)); + // Изменяем параметры подписки return metricsService.subscribeToMetric( - getSubscriptionKey(), - (newData) => { - console.log('Received WS update:', newData); - if (!Array.isArray(newData)) { - console.error('Expected array in WS update, got:', typeof newData); + metricName, // Теперь передаем просто имя метрики + { ...filters, device, source_id }, // Фильры отдельным параметром + (update) => { // Колбэк получает объект с данными + console.log('Received WS update:', update); + + if (!update || !Array.isArray(update.data)) { + console.error('Invalid update format:', update); return; } @@ -176,7 +183,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { const now = Date.now(); const cutoffTime = now - TIME_WINDOW_MS; - const formattedNew = formatMetricData(newData) + const formattedNew = formatMetricData(update.data) .filter(point => point.timestamp >= cutoffTime); const filteredPrev = prev.filter(point => @@ -194,15 +201,18 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { : merged; }); }, - 1000, - { ...filters, device, source_id } + 5000 // Интервал обновления (можно настроить) ); }; const stopRealtimeUpdates = () => { setIsLiveUpdating(false); - metricsService.unsubscribeFromMetric(getSubscriptionKey()); + // Теперь отписываемся по метрике и фильтрам + metricsService.unsubscribeFromMetric( + metricName, + { ...filters, device, source_id } + ); }; const handleCustomRangeApply = () => { @@ -215,6 +225,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { console.log('Metric changed:', { metricName, device, source_id, filters }); let unsubscribe; + const init = async () => { if (mode === 'realtime') { unsubscribe = startRealtimeUpdates(); @@ -226,10 +237,14 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { init(); return () => { - if (unsubscribe) unsubscribe(); - stopRealtimeUpdates(); + if (unsubscribe) { + unsubscribe(); // Вызываем функцию отписки + } + if (mode === 'realtime') { + stopRealtimeUpdates(); // Дополнительная очистка + } }; - }, [mode, metricName, device, source_id, filters]); + }, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров const metaInfo = [ metricMeta.instance && `Instance: ${metricMeta.instance}`, @@ -322,4 +337,4 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { ); }; -export default PrometheusChart; \ No newline at end of file +export default PrometheusChart; diff --git a/vite.config.js b/vite.config.js index e391567..e8612bd 100755 --- a/vite.config.js +++ b/vite.config.js @@ -20,7 +20,6 @@ export default defineConfig({ }, '/api': { target: 'http://localhost:3000', - ws: true, changeOrigin: true, bypass(req, res, options) { console.log('Proxying request:', req.url); From 205ddc71e01816ff1cff4849c1c9dfc08d920274 Mon Sep 17 00:00:00 2001 From: SovietSpiderCat Date: Fri, 22 Aug 2025 09:57:16 +0300 Subject: [PATCH 2/5] added complex variables --- src/Charts/LineChartComponent.jsx | 66 +++----- src/Charts/SystemChart.jsx | 267 ++++++++++++++++++------------ src/Charts2/PrometheusChart.jsx | 12 +- 3 files changed, 201 insertions(+), 144 deletions(-) diff --git a/src/Charts/LineChartComponent.jsx b/src/Charts/LineChartComponent.jsx index ee9a4e3..88e0e29 100644 --- a/src/Charts/LineChartComponent.jsx +++ b/src/Charts/LineChartComponent.jsx @@ -16,13 +16,13 @@ const formatXAxis = (tickItem) => { }; const formatTooltip = (value, name, props) => { - return [`${value.toFixed(2)}`, ` ${name}`]; + return [`${value.toFixed(2)}`, `Устройство ${name}`]; }; const LineChartComponent = ({ data = [], - multipleLines = false, - lineKey = 'device', + multipleLines = true, // По умолчанию включаем множественные линии + lineKey = 'device', // Ключ для разделения линий title, description, height = 400, @@ -30,27 +30,25 @@ const LineChartComponent = ({ }) => { if (!data || data.length === 0) return
Нет данных для отображения
; - // Создаем массив уникальных линий - const lineKeys = [...new Set(data.map(item => item[lineKey] || 'default'))]; + // Создаем массив уникальных устройств + const devices = [...new Set(data.map(item => item.device))]; - // Преобразуем данные в формат, удобный для Recharts - const chartData = data.reduce((acc, item) => { - const timestamp = item.timestamp; - const existingPoint = acc.find(p => p.timestamp === timestamp); + // Группируем данные по timestamp для правильного отображения + const timestamps = [...new Set(data.map(item => item.timestamp))].sort(); - if (existingPoint) { - return acc.map(p => - p.timestamp === timestamp - ? { ...p, [item[lineKey] || 'default']: item.value } - : p + const chartData = timestamps.map(timestamp => { + const point = { timestamp }; + + // Для каждого устройства находим значение в этот timestamp + devices.forEach(device => { + const deviceData = data.find(item => + item.timestamp === timestamp && item.device === device ); - } + point[`device_${device}`] = deviceData ? deviceData.value : null; + }); - return [...acc, { - timestamp, - [item[lineKey] || 'default']: item.value - }]; - }, []).sort((a, b) => a.timestamp - b.timestamp); + return point; + }); return (
@@ -63,39 +61,27 @@ const LineChartComponent = ({ dataKey="timestamp" tickFormatter={formatXAxis} /> - + format(new Date(label), 'yyyy-MM-dd HH:mm:ss')} /> - {multipleLines ? ( - lineKeys.map(key => ( - - )) - ) : ( + {devices.map(device => ( - )} + ))} {/* Добавляем диапазоны если они есть */} {ranges.map((range, idx) => ( diff --git a/src/Charts/SystemChart.jsx b/src/Charts/SystemChart.jsx index 478d7cb..decba5b 100644 --- a/src/Charts/SystemChart.jsx +++ b/src/Charts/SystemChart.jsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import LineChartComponent from './LineChartComponent'; import DateRangeSelector from '../Charts2/Components/DateRangeSelector'; import metricsService from '../Charts2/Components/metricsService'; -import { Button, Radio, message, Tag, Spin } from 'antd'; +import { Button, Radio, message, Tag } from 'antd'; import moment from 'moment'; import StatusLogTable from '../Charts2/Components/StatusLogTable'; import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material'; @@ -15,14 +15,12 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { title = metricName, description, context = {}, - ranges = [], - multipleLines = false, - lineKey = 'device' + ranges = [] } = metricInfo || {}; const { device, source_id } = context; - const [rawData, setRawData] = useState([]); + const [chartData, setChartData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [metricMeta, setMetricMeta] = useState({}); @@ -32,43 +30,96 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { const [isLiveUpdating, setIsLiveUpdating] = useState(false); const [showLogs, setShowLogs] = useState(false); const [statusLogs, setStatusLogs] = useState([]); - - const MAX_POINTS = 1000; + const MAX_POINTS = 50; const TIME_WINDOW_MS = 3600 * 1000; - const subscriptionKey = useMemo(() => { + + // Эта функция может больше не понадобиться, так как + // сервис сам генерирует ключи, но оставьте для совместимости + const getSubscriptionKey = () => { const filterParts = []; if (device) filterParts.push(`device=${encodeURIComponent(device)}`); if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`); return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`; - }, [metricName, device, source_id]); + }; - const formatMetricData = (responseData) => { - const dataArray = Array.isArray(responseData) ? responseData : responseData.data; + const getStatusFromRanges = (value, ranges) => { + if (!ranges || ranges.length === 0) return 1; + for (const r of ranges) { + if (value >= r.min && value <= r.max) { + return r.status; + } + } + return 1; + }; + const formatMetricData = (dataArray) => { if (!Array.isArray(dataArray)) { - console.error('Expected array but got:', responseData); + console.error('Expected array in formatMetricData, got:', typeof dataArray); return []; } - return dataArray.map(item => ({ - ...item, - timestamp: item.timestamp, - value: parseFloat(item.value), - name: item.__name__ || metricName, - status: parseInt(item.status) || 0, - device: item.device?.trim() || null, - source_id: item.source_id || null, - description: item.description || description, - lineId: item[lineKey] || 'default' - })); + return dataArray.map(item => { + if (item.timestamp === undefined || item.value === undefined) { + console.warn('Invalid metric item:', item); + return null; + } + + return { + ...item, + timestamp: Number(item.timestamp), + value: parseFloat(item.value), + status: getStatusFromRanges(parseFloat(item.value), ranges), + name: item.__name__ || metricName, + device: item.device?.trim() || null, + source_id: item.source_id || null, + description: item.description || description + }; + }).filter(Boolean) + .sort((a, b) => a.timestamp - b.timestamp); }; - const calculateStep = (start, end) => { - const duration = end.getTime() - start.getTime(); - return Math.max(Math.floor(duration / (MAX_POINTS * 1000)), 1); + const calculateStep = (startTime, endTime, maxPoints = 10000) => { + const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000; + return Math.max(Math.ceil(durationSeconds / maxPoints), 1); }; + const downsampleData = (data, maxPoints = MAX_POINTS) => { + if (data.length <= maxPoints) return [...data]; + + const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp); + const step = Math.max(1, Math.floor(sortedData.length / maxPoints)); + + const result = []; + for (let i = 0; i < sortedData.length; i += step) { + if (result.length >= maxPoints) break; + result.push(sortedData[i]); + } + + if (result.length > 0) { + const lastOriginalPoint = sortedData[sortedData.length - 1]; + if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) { + result[result.length - 1] = lastOriginalPoint; + } + } + + return result; + }; + + + useEffect(() => { + if (chartData.length > 0) { + const newLogs = chartData.reduce((acc, point, index) => { + if (index === 0 || point.status !== chartData[index - 1].status) { + return [...acc, point]; + } + return acc; + }, []); + setStatusLogs(newLogs); + } + }, [chartData]); + + const fetchHistoricalData = async (start, end) => { setIsLoading(true); setError(null); @@ -82,19 +133,23 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { const step = calculateStep(start, end); + // Используем новый метод для исторических данных const data = await metricsService.fetchMetricsRange( metricName, - Math.floor(start.getTime() / 1000), - Math.floor(end.getTime() / 1000), + start.getTime(), // Теперь передаем timestamp в миллисекундах + end.getTime(), step, extendedFilters ); - const responseData = Array.isArray(data) ? data : data.data; - const formattedData = formatMetricData(responseData); - setRawData(formattedData); + const formattedData = formatMetricData(data) + .sort((a, b) => a.timestamp - b.timestamp); - if (formattedData.length > 0) { + const limitedData = formattedData.length > MAX_POINTS + ? downsampleData(formattedData, MAX_POINTS) + : formattedData; + + if (limitedData.length > 0) { setMetricMeta({ type: data[0]?.type, description: data[0]?.description || description, @@ -102,6 +157,8 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { job: data[0]?.job }); } + + setChartData(limitedData); } catch (err) { console.error(`Error loading historical data for ${metricName}:`, err); setError(err.message); @@ -117,42 +174,55 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { const end = new Date(); const start = new Date(end.getTime() - TIME_WINDOW_MS); - const cutoffTime = Date.now() - TIME_WINDOW_MS; + fetchHistoricalData(start, end).finally(() => setIsLoading(false)); + // Изменяем параметры подписки return metricsService.subscribeToMetric( - subscriptionKey, - (newData) => { - setRawData(prev => { - const actualData = Array.isArray(newData) ? newData : newData.data; - const formattedNewData = formatMetricData(actualData) + metricName, // Теперь передаем просто имя метрики + { ...filters, device, source_id }, // Фильры отдельным параметром + (update) => { // Колбэк получает объект с данными + console.log('Received WS update:', update); + + if (!update || !Array.isArray(update.data)) { + console.error('Invalid update format:', update); + return; + } + + setChartData(prev => { + const now = Date.now(); + const cutoffTime = now - TIME_WINDOW_MS; + + const formattedNew = formatMetricData(update.data) .filter(point => point.timestamp >= cutoffTime); - const filteredPrev = prev.filter(point => point.timestamp >= cutoffTime); + const filteredPrev = prev.filter(point => + point.timestamp >= cutoffTime + ); - const merged = [...filteredPrev, ...formattedNewData] + const merged = [...filteredPrev, ...formattedNew] .filter((v, i, a) => - a.findIndex(t => - t.timestamp === v.timestamp && - t[lineKey] === v[lineKey] - ) === i - ); + a.findIndex(t => t.timestamp === v.timestamp) === i + ) + .sort((a, b) => a.timestamp - b.timestamp); - return merged; + return merged.length > MAX_POINTS + ? merged.slice(-MAX_POINTS) + : merged; }); }, - 5000, - { - ...filters, - ...(device && { device }), - ...(source_id && { source_id }) - } + 5000 // Интервал обновления (можно настроить) ); }; + const stopRealtimeUpdates = () => { setIsLiveUpdating(false); - metricsService.unsubscribeFromMetric(subscriptionKey); + // Теперь отписываемся по метрике и фильтрам + metricsService.unsubscribeFromMetric( + metricName, + { ...filters, device, source_id } + ); }; const handleCustomRangeApply = () => { @@ -162,45 +232,29 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { }; useEffect(() => { - if (rawData.length > 0) { - const logs = []; - const devices = [...new Set(rawData.map(item => item[lineKey]))]; + console.log('Metric changed:', { metricName, device, source_id, filters }); - devices.forEach(dev => { - const deviceData = rawData - .filter(item => item[lineKey] === dev) - .sort((a, b) => a.timestamp - b.timestamp); - - if (deviceData.length > 0) { - logs.push(deviceData[0]); // Первая точка - - for (let i = 1; i < deviceData.length; i++) { - if (deviceData[i].status !== deviceData[i - 1].status) { - logs.push(deviceData[i]); - } - } - } - }); - - setStatusLogs(logs.sort((a, b) => b.timestamp - a.timestamp)); - } - }, [rawData, lineKey]); - - useEffect(() => { let unsubscribe; - if (mode === 'realtime') { - unsubscribe = startRealtimeUpdates(); - } else { - stopRealtimeUpdates(); - fetchHistoricalData(startDate, endDate); - } + const init = async () => { + if (mode === 'realtime') { + unsubscribe = startRealtimeUpdates(); + } else { + await fetchHistoricalData(startDate, endDate); + } + }; + + init(); return () => { - if (unsubscribe) unsubscribe(); - stopRealtimeUpdates(); + if (unsubscribe) { + unsubscribe(); // Вызываем функцию отписки + } + if (mode === 'realtime') { + stopRealtimeUpdates(); // Дополнительная очистка + } }; - }, [mode, metricName, device, source_id]); + }, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров const metaInfo = [ metricMeta.instance && `Instance: ${metricMeta.instance}`, @@ -209,7 +263,7 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { ].filter(Boolean).join(' | '); return ( -
+
{ /> )} - {mode === 'realtime' && ( - - {isLiveUpdating ? 'Обновление в реальном времени' : 'Режим реального времени остановлен'} - + {mode === 'realtime' && isLiveUpdating && ( + )}
@@ -259,26 +318,28 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { {isLoading ? ( -
- -
+
Загрузка графика...
) : error ? ( -
Ошибка: {error}
- ) : rawData.length === 0 ? ( -
Нет данных для метрики: {metricName}
+
Ошибка: {error}
+ ) : chartData.length === 0 ? ( +
Нет данных для метрики: {metricName}
) : ( <> - {showLogs && } + {showLogs && ( + + )} )} @@ -286,4 +347,4 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => { ); }; -export default SystemChart; \ No newline at end of file +export default SystemChart; diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx index 8240209..4ff71de 100644 --- a/src/Charts2/PrometheusChart.jsx +++ b/src/Charts2/PrometheusChart.jsx @@ -43,6 +43,16 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`; }; + const getStatusFromRanges = (value, ranges) => { + if (!ranges || ranges.length === 0) return 1; + for (const r of ranges) { + if (value >= r.min && value <= r.max) { + return r.status; + } + } + return 1; + }; + const formatMetricData = (dataArray) => { if (!Array.isArray(dataArray)) { console.error('Expected array in formatMetricData, got:', typeof dataArray); @@ -59,7 +69,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => { ...item, timestamp: Number(item.timestamp), value: parseFloat(item.value), - status: parseInt(item.status || '0'), + status: getStatusFromRanges(parseFloat(item.value), ranges), name: item.__name__ || metricName, device: item.device?.trim() || null, source_id: item.source_id || null, From 34f2010caef659cfb2c3a98aafef681fbed52819 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Thu, 28 Aug 2025 09:20:42 -0400 Subject: [PATCH 3/5] added menu editor --- .dockerignore | 7 + .../Layout/SettingsComponents/MenuEditor.jsx | 303 ++++++++++++++++++ src/Components/Layout/SettingsModal.jsx | 23 +- vite.config.js | 6 +- 4 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 src/Components/Layout/SettingsComponents/MenuEditor.jsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7267500 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +Dockerfile +.dockerignore +dist +npm-debug.log diff --git a/src/Components/Layout/SettingsComponents/MenuEditor.jsx b/src/Components/Layout/SettingsComponents/MenuEditor.jsx new file mode 100644 index 0000000..5e45c9e --- /dev/null +++ b/src/Components/Layout/SettingsComponents/MenuEditor.jsx @@ -0,0 +1,303 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + IconButton, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Chip, + Collapse, + CircularProgress +} from '@mui/material'; +import { + Edit as EditIcon, + Delete as DeleteIcon, + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon +} from '@mui/icons-material'; +import axios from 'axios'; + +const MenuItemComponent = ({ item, level = 0, onEdit, onDelete }) => { + const [expanded, setExpanded] = useState(false); + const hasChildren = item.items && item.items.length > 0; + + const handleToggle = () => { + if (hasChildren) { + setExpanded(!expanded); + } + }; + + return ( + <> + + + {item.title} + {item.isDynamic && ( + + )} + + } + secondary={item.id} + /> + + {/* */} + <> + onEdit(item)} + sx={{ mr: 1 }} + > + + + onDelete(item)} + color="error" + > + + + + {hasChildren && ( + + {expanded ? : } + + )} + + + {hasChildren && ( + + + {item.items.map((child) => ( + + ))} + + + )} + + ); +}; + +const EditDialog = ({ open, item, onClose, onSave }) => { + const [title, setTitle] = useState(item?.title || ''); + + useEffect(() => { + setTitle(item?.title || ''); + }, [item]); + + const handleSave = () => { + onSave(item.id, { title }); + onClose(); + }; + + return ( + + Редактировать элемент меню + + setTitle(e.target.value)} + sx={{ mt: 2 }} + /> + + + + + + + ); +}; + +const MenuEditor = ({ onSave }) => { + const [menuData, setMenuData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + fetchMenuData(); + }, []); + + const fetchMenuData = async () => { + try { + setLoading(true); + const response = await axios.get('/api/menu/full'); + setMenuData(response.data); + setError(null); + } catch (err) { + setError('Ошибка загрузки меню'); + console.error('Error fetching menu:', err); + } finally { + setLoading(false); + } + }; + + const handleEdit = (item) => { + setSelectedItem(item); + setEditDialogOpen(true); + }; + + const handleDelete = (item) => { + setSelectedItem(item); + setDeleteDialogOpen(true); + }; + + const handleEditSave = async (id, updates) => { + try { + await axios.put(`/api/menu/${id}`, updates); + setHasChanges(true); + fetchMenuData(); + } catch (err) { + console.error('Error updating menu item:', err); + alert('Ошибка при сохранении изменений'); + } + }; + + const handleDeleteConfirm = async () => { + try { + await axios.delete(`/api/menu/items/${selectedItem.id}`); + setHasChanges(true); + setDeleteDialogOpen(false); + fetchMenuData(); + } catch (err) { + console.error('Error deleting menu item:', err); + alert('Ошибка при удалении элемента'); + } + }; + + const handleSave = async () => { + if (hasChanges) { + onSave({ + hasChanges: true, saveChanges: async () => { + // Принудительно обновляем кэш + try { + await axios.post('/api/menu/invalidate-cache'); + return true; + } catch (err) { + console.error('Error invalidating cache:', err); + return false; + } + } + }); + setHasChanges(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + Редактирование меню + + + Вы можете редактировать названия и удалять элементы меню. Динамические элементы (помечены синим) нельзя редактировать. + + + + {menuData.items.map((item) => ( + + ))} + + + setEditDialogOpen(false)} + onSave={handleEditSave} + /> + + setDeleteDialogOpen(false)} + > + Подтверждение удаления + + + Вы уверены, что хотите удалить элемент "{selectedItem?.title}"? + + + + + + + + + + + + + ); +}; + +export default MenuEditor; \ No newline at end of file diff --git a/src/Components/Layout/SettingsModal.jsx b/src/Components/Layout/SettingsModal.jsx index 54d7b04..f52cf56 100644 --- a/src/Components/Layout/SettingsModal.jsx +++ b/src/Components/Layout/SettingsModal.jsx @@ -21,6 +21,7 @@ import CloseIcon from '@mui/icons-material/Close'; import SaveIcon from '@mui/icons-material/Save'; import MetricRangeEditor from './SettingsComponents/MetricRangeEditor'; import UserManagement from './SettingsComponents/UserManagement'; +import MenuEditor from './SettingsComponents/MenuEditor' const Transition = React.forwardRef(function Transition(props, ref) { return ; @@ -64,6 +65,10 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => { hasChanges: false, save: () => { } }); + const [menuEditorState, setMenuEditorState] = useState({ + hasChanges: false, + save: () => Promise.resolve(true) + }); const handleTabChange = (event, newValue) => { if (hasChanges) { @@ -73,12 +78,22 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => { } }; + const handleMenuEditorChange = ({ hasChanges, saveChanges }) => { + setMenuEditorState({ hasChanges, save: saveChanges }); + setHasChanges(hasChanges); + }; + const handleSave = async () => { setIsSaving(true); try { let success = true; + + if (tabValue === 0 && menuEditorState.hasChanges) { + success = await menuEditorState.save(); + } + if (tabValue === 1 && metricEditorState.hasChanges) { - success = await metricEditorState.save(); + success = success && await metricEditorState.save(); } if (success) { @@ -113,7 +128,6 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => { } }; - // Пример обработчика изменений const handleSettingChange = () => { setHasChanges(true); }; @@ -149,14 +163,13 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => { - {/* Добавляйте новые вкладки здесь */} + {/* Добавить новые вкладки здесь */} - Настройки меню - {/* Добавьте содержимое для вкладки меню */} + diff --git a/vite.config.js b/vite.config.js index e8612bd..a57ee5f 100755 --- a/vite.config.js +++ b/vite.config.js @@ -14,12 +14,12 @@ export default defineConfig({ rewrite: (path) => path.replace(/^\/ai-api/, ''), }, '/metrics-ws': { - target: 'ws://localhost:3001', + target: 'ws://192.168.2.39:3001', ws: true, changeOrigin: true, }, '/api': { - target: 'http://localhost:3000', + target: 'http://192.168.2.39:3000', changeOrigin: true, bypass(req, res, options) { console.log('Proxying request:', req.url); @@ -27,4 +27,4 @@ export default defineConfig({ } } } -}); \ No newline at end of file +}); From 933ceb2547f7a9ea5653957cfab52817a8fc729a Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 1 Sep 2025 08:16:59 -0400 Subject: [PATCH 4/5] added drag-and-drop --- package.json | 7 +- src/Components/Layout/SidebarMenu.jsx | 332 +++++++++++++----- .../SortableMenuItem.jsx | 115 ++++++ 3 files changed, 373 insertions(+), 81 deletions(-) create mode 100644 src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx diff --git a/package.json b/package.json index ca982a3..f44909a 100755 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "reactflow": "^11.11.4", "recharts": "^2.15.1", "socket.io-client": "^4.8.1", - "vite-plugin-svgr": "^4.3.0" + "vite-plugin-svgr": "^4.3.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/core": "^6.3.1" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -45,4 +48,4 @@ "globals": "^15.14.0", "vite": "^7.1.0" } -} +} \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 3c07130..87b61e2 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -1,5 +1,4 @@ -// SidebarMenu.jsx -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Drawer, List, @@ -8,13 +7,27 @@ import { Tooltip, Box, } from "@mui/material"; -import MenuItem from "./SidebarMenuComponents/MenuItem"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import useSidebarResize from "../hooks/useSidebarResize"; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import ChevronRight from '@mui/icons-material/ChevronRight'; -import LogoFull from '../../assets/images/logo.svg?react'; -import LogoSmall from '../../assets/images/system_monitor_icon.svg?react'; +import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import LogoFull from "../../assets/images/logo.svg?react"; +import LogoSmall from "../../assets/images/system_monitor_icon.svg?react"; + +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragOverlay +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; + +import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem"; const SidebarMenu = ({ data, @@ -22,25 +35,161 @@ const SidebarMenu = ({ setIsDarkMode, onSelectItem, forceRefreshMenu, - user + user, }) => { const [collapsed, setCollapsed] = useState(false); const { sidebarWidth, startResizing } = useSidebarResize(290); - const [hovered, setHovered] = useState(false); + const [menuItems, setMenuItems] = useState(data.items || []); + const [activeItem, setActiveItem] = useState(null); - const handleToggleCollapse = () => { - setCollapsed(!collapsed); + const sensors = useSensors(useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + })); + + useEffect(() => { + const cached = localStorage.getItem("menuTree"); + if (cached) { + try { + setMenuItems(JSON.parse(cached)); + } catch { + setMenuItems(data.items || []); + } + } else { + setMenuItems(data.items || []); + } + }, [data]); + + const handleToggleCollapse = () => setCollapsed(!collapsed); + + // Функция для поиска элемента по ID во всем дереве + const findItemInTree = (items, id) => { + for (const item of items) { + if (item.id === id) return item; + if (item.items) { + const found = findItemInTree(item.items, id); + if (found) return found; + } + } + return null; }; - const SidebarResizer = styled('div')(({ theme }) => ({ - width: '4px', - cursor: 'ew-resize', - backgroundColor: 'transparent', - '&:hover': { + // Функция для удаления элемента из дерева + const removeItemFromTree = (items, id) => { + return items.filter(item => { + if (item.id === id) return false; + if (item.items) { + item.items = removeItemFromTree(item.items, id); + } + return true; + }); + }; + + // Функция для добавления элемента в конкретную папку + const addItemToFolder = (items, folderId, newItem) => { + return items.map(item => { + if (item.id === folderId) { + return { + ...item, + items: [...(item.items || []), newItem] + }; + } + if (item.items) { + return { + ...item, + items: addItemToFolder(item.items, folderId, newItem) + }; + } + return item; + }); + }; + + // Функция для поиска родителя элемента + const findParent = (items, childId, parent = null) => { + for (const item of items) { + if (item.id === childId) return parent; + if (item.items) { + const found = findParent(item.items, childId, item); + if (found) return found; + } + } + return null; + }; + + // Функция для добавления элемента на тот же уровень + const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => { + return items.map(item => { + if (item.id === parentId) { + const children = item.items || []; + const insertIndex = afterId ? children.findIndex(i => i.id === afterId) + 1 : children.length; + + const newChildren = [ + ...children.slice(0, insertIndex), + newItem, + ...children.slice(insertIndex) + ]; + + return { ...item, items: newChildren }; + } + if (item.items) { + return { ...item, items: addItemAtSameLevel(item.items, parentId, newItem, afterId) }; + } + return item; + }); + }; + + const handleDragStart = (event) => { + const { active } = event; + const item = findItemInTree(menuItems, active.id); + setActiveItem(item); + }; + + const handleDragEnd = (event) => { + const { active, over } = event; + setActiveItem(null); + + if (!over) return; + if (active.id === over.id) return; + + const draggedItem = findItemInTree(menuItems, active.id); + if (!draggedItem) return; + + // Определяем тип целевого элемента (папка или элемент) + const overItem = findItemInTree(menuItems, over.id); + const isOverFolder = overItem && Array.isArray(overItem.items); + + let newTree; + + if (isOverFolder) { + // Перемещаем в папку + newTree = removeItemFromTree([...menuItems], active.id); + newTree = addItemToFolder(newTree, over.id, draggedItem); + } else { + // Перемещаем на тот же уровень + const overParent = findParent(menuItems, over.id); + if (!overParent) return; + + // Удаляем из старого места + newTree = removeItemFromTree([...menuItems], active.id); + + // Добавляем на новый уровень + newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, over.id); + } + + setMenuItems(newTree); + localStorage.setItem("menuTree", JSON.stringify(newTree)); + }; + + const SidebarResizer = styled("div")(({ theme }) => ({ + width: "4px", + cursor: "ew-resize", + backgroundColor: "transparent", + "&:hover": { backgroundColor: theme.palette.action.hover, }, - height: '100%', - position: 'absolute', + height: "100%", + position: "absolute", top: 0, right: 0, zIndex: 1000, @@ -48,12 +197,10 @@ const SidebarMenu = ({ return ( setHovered(true)} - onMouseLeave={() => setHovered(false)} sx={{ - position: 'relative', + position: "relative", width: collapsed ? 64 : sidebarWidth, - transition: 'width 0.3s ease', + transition: "width 0.3s ease", }} > {/* Заголовок с логотипом */} - - {/* Логотип (занимает все пространство) */} - + + {collapsed ? ( - + ) : ( - + )} - {/* Кнопка сворачивания (абсолютное позиционирование) */} {collapsed ? : } @@ -131,18 +276,48 @@ const SidebarMenu = ({ {/* Основное содержимое меню */} - - - {data && ( - + + i.id)} strategy={verticalListSortingStrategy}> + + {menuItems.map((item) => ( + + ))} + + - onSelectItem={onSelectItem} - /> - )} - + + {activeItem ? ( + + {activeItem.title} + + ) : null} + + - {!collapsed && ( - - )} + + {!collapsed && } ); diff --git a/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx new file mode 100644 index 0000000..20ae851 --- /dev/null +++ b/src/Components/Layout/SidebarMenuComponents/SortableMenuItem.jsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { + ListItem, + ListItemIcon, + ListItemText, + Collapse, + List, + IconButton, + Box +} from "@mui/material"; +import { Folder, FolderOpen, ExpandLess, ExpandMore, DragIndicator } from "@mui/icons-material"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => { + const [isOpen, setIsOpen] = useState(false); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ + id: item.id, + data: { + type: 'menu-item', + item + } + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const hasChildren = Array.isArray(item.items) && item.items.length > 0; + + const handleClick = (e) => { + e.stopPropagation(); + if (hasChildren) { + setIsOpen(!isOpen); + } else { + onSelectItem?.(item); + } + }; + + return ( + + + {!collapsed && ( + + + + )} + + + {hasChildren ? (isOpen ? : ) : } + + + {!collapsed && ( + <> + + {hasChildren && (isOpen ? : )} + + )} + + + {hasChildren && !collapsed && ( + + + {item.items.map((child) => ( + + ))} + + + )} + + ); +}; + +export default SortableMenuItem; \ No newline at end of file From 06249fce3a61f10fd4bb3f5788d92b5663d722a9 Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 1 Sep 2025 09:20:23 -0400 Subject: [PATCH 5/5] sidebar redesign --- src/Components/Layout/SidebarMenu.jsx | 309 +++++++++++++++--- .../Layout/SidebarMenuComponents/MenuItem.jsx | 216 ++++++------ .../SidebarMenuComponents/SidebarFooter.jsx | 142 ++++---- .../SortableMenuItem.jsx | 175 ++++++++-- 4 files changed, 589 insertions(+), 253 deletions(-) diff --git a/src/Components/Layout/SidebarMenu.jsx b/src/Components/Layout/SidebarMenu.jsx index 87b61e2..70058ed 100644 --- a/src/Components/Layout/SidebarMenu.jsx +++ b/src/Components/Layout/SidebarMenu.jsx @@ -6,6 +6,7 @@ import { IconButton, Tooltip, Box, + alpha } from "@mui/material"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import useSidebarResize from "../hooks/useSidebarResize"; @@ -20,7 +21,8 @@ import { PointerSensor, useSensor, useSensors, - DragOverlay + DragOverlay, + MeasuringStrategy } from "@dnd-kit/core"; import { SortableContext, @@ -38,13 +40,15 @@ const SidebarMenu = ({ user, }) => { const [collapsed, setCollapsed] = useState(false); - const { sidebarWidth, startResizing } = useSidebarResize(290); + const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину const [menuItems, setMenuItems] = useState(data.items || []); const [activeItem, setActiveItem] = useState(null); + const [hoveredItem, setHoveredItem] = useState(null); + const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null }); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { - distance: 8, + distance: 4, }, })); @@ -61,9 +65,12 @@ const SidebarMenu = ({ } }, [data]); - const handleToggleCollapse = () => setCollapsed(!collapsed); + const handleToggleCollapse = () => { + setCollapsed(!collapsed); + setHoveredItem(null); + }; - // Функция для поиска элемента по ID во всем дереве + // Функции для работы с деревом (остаются без изменений) const findItemInTree = (items, id) => { for (const item of items) { if (item.id === id) return item; @@ -75,7 +82,6 @@ const SidebarMenu = ({ return null; }; - // Функция для удаления элемента из дерева const removeItemFromTree = (items, id) => { return items.filter(item => { if (item.id === id) return false; @@ -86,7 +92,6 @@ const SidebarMenu = ({ }); }; - // Функция для добавления элемента в конкретную папку const addItemToFolder = (items, folderId, newItem) => { return items.map(item => { if (item.id === folderId) { @@ -105,7 +110,6 @@ const SidebarMenu = ({ }); }; - // Функция для поиска родителя элемента const findParent = (items, childId, parent = null) => { for (const item of items) { if (item.id === childId) return parent; @@ -117,7 +121,6 @@ const SidebarMenu = ({ return null; }; - // Функция для добавления элемента на тот же уровень const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => { return items.map(item => { if (item.id === parentId) { @@ -143,11 +146,13 @@ const SidebarMenu = ({ const { active } = event; const item = findItemInTree(menuItems, active.id); setActiveItem(item); + setDropIndicator({ show: false, position: null, targetId: null }); }; - const handleDragEnd = (event) => { const { active, over } = event; setActiveItem(null); + setHoveredItem(null); + setDropIndicator({ show: false, position: null, targetId: null }); if (!over) return; if (active.id === over.id) return; @@ -155,69 +160,190 @@ const SidebarMenu = ({ const draggedItem = findItemInTree(menuItems, active.id); if (!draggedItem) return; - // Определяем тип целевого элемента (папка или элемент) const overItem = findItemInTree(menuItems, over.id); - const isOverFolder = overItem && Array.isArray(overItem.items); + + // Проверяем, не пытаемся ли переместить элемент в его же потомка + if (isDescendant(draggedItem, overItem)) { + return; + } let newTree; - if (isOverFolder) { - // Перемещаем в папку + if (dropIndicator.position === 'inside' && overItem && Array.isArray(overItem.items)) { + // Вставка внутрь папки newTree = removeItemFromTree([...menuItems], active.id); newTree = addItemToFolder(newTree, over.id, draggedItem); } else { - // Перемещаем на тот же уровень + // Вставка на том же уровне const overParent = findParent(menuItems, over.id); if (!overParent) return; - // Удаляем из старого места newTree = removeItemFromTree([...menuItems], active.id); - // Добавляем на новый уровень - newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, over.id); + // Определяем позицию для вставки + let insertAfterId = null; + if (dropIndicator.position === 'below') { + insertAfterId = over.id; + } else if (dropIndicator.position === 'above') { + const siblings = overParent.items || []; + const overIndex = siblings.findIndex(item => item.id === over.id); + if (overIndex > 0) { + insertAfterId = siblings[overIndex - 1].id; + } + } + + newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, insertAfterId); } setMenuItems(newTree); localStorage.setItem("menuTree", JSON.stringify(newTree)); }; + const handleDragOver = (event) => { + const { active, over } = event; + + if (!over) { + setDropIndicator({ show: false, position: null, targetId: null }); + return; + } + + const overItem = findItemInTree(menuItems, over.id); + const activeItem = findItemInTree(menuItems, active.id); + + if (!overItem || !activeItem || active.id === over.id) { + setDropIndicator({ show: false, position: null, targetId: null }); + return; + } + + // Проверяем, можно ли перемещать элемент + if (isDescendant(activeItem, overItem)) { + setDropIndicator({ show: false, position: null, targetId: null }); + return; + } + + const overRect = over.rect.current; + if (!overRect) return; + + const relativeY = event.delta.y; + const isOverFolder = overItem && Array.isArray(overItem.items); + const isTopHalf = relativeY < overRect.height * 0.4; + const isBottomHalf = relativeY > overRect.height * 0.6; + + if (isOverFolder && !isTopHalf && !isBottomHalf) { + // Показываем индикатор для вставки в папку + setDropIndicator({ + show: true, + position: 'inside', + targetId: over.id + }); + setHoveredItem(over.id); + } else if (isTopHalf) { + // Показываем индикатор для вставки выше + setDropIndicator({ + show: true, + position: 'above', + targetId: over.id + }); + setHoveredItem(null); + } else if (isBottomHalf) { + // Показываем индикатор для вставки ниже + setDropIndicator({ + show: true, + position: 'below', + targetId: over.id + }); + setHoveredItem(null); + } else { + setDropIndicator({ show: false, position: null, targetId: null }); + setHoveredItem(null); + } + }; + + const isDescendant = (parent, child) => { + if (!parent || !child || !parent.items) return false; + + const checkChildren = (items, targetId) => { + for (const item of items) { + if (item.id === targetId) return true; + if (item.items && checkChildren(item.items, targetId)) return true; + } + return false; + }; + + return checkChildren(parent.items, child.id); + }; + const SidebarResizer = styled("div")(({ theme }) => ({ - width: "4px", - cursor: "ew-resize", - backgroundColor: "transparent", + width: "3px", + cursor: "col-resize", + backgroundColor: alpha(theme.palette.primary.main, 0.3), "&:hover": { - backgroundColor: theme.palette.action.hover, + backgroundColor: theme.palette.primary.main, }, height: "100%", position: "absolute", top: 0, right: 0, zIndex: 1000, + transition: "background-color 0.2s ease", })); + const DropIndicator = ({ position, targetId }) => { + if (!targetId) return null; + + return ( + + ); + }; + + return ( @@ -227,12 +353,14 @@ const SidebarMenu = ({ display: "flex", alignItems: "center", justifyContent: "center", - p: 1, + p: 2, borderBottom: "1px solid", borderColor: "divider", - backgroundColor: "custom.sidebar", + backgroundColor: "background.paper", height: 80, position: "relative", + transition: "all 0.2s ease", + minHeight: 80, }} > {collapsed ? ( - + ) : ( - + )} - + {collapsed ? : } @@ -277,23 +425,67 @@ const SidebarMenu = ({ {/* Основное содержимое меню */} i.id)} strategy={verticalListSortingStrategy}> - + {menuItems.map((item) => ( - + + {dropIndicator.show && dropIndicator.targetId === item.id && + dropIndicator.position !== 'inside' && ( + + )} + + ))} @@ -304,13 +496,18 @@ const SidebarMenu = ({ sx={{ backgroundColor: 'primary.main', color: 'white', - padding: 1, - borderRadius: 1, - boxShadow: 3, - maxWidth: 200, + padding: '8px 12px', + borderRadius: '8px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)', + maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + fontSize: '0.875rem', + fontWeight: 500, + backdropFilter: 'blur(10px)', + border: '1px solid rgba(255, 255, 255, 0.1)', + transform: 'rotate(5deg)', }} > {activeItem.title} @@ -328,7 +525,11 @@ const SidebarMenu = ({ /> - {!collapsed && } + {!collapsed && ( + + + + )} ); diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index afb8801..3d7235b 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -1,121 +1,121 @@ -// MenuItem.jsx -import React, { useState } from "react"; -import { - ListItem, - ListItemIcon, - ListItemText, - Collapse, - List, - styled, - Menu, - MenuItem as MuiMenuItem -} from "@mui/material"; -import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; -import StatusIndicator from "./StatusIndicator"; +// // MenuItem.jsx +// import React, { useState } from "react"; +// import { +// ListItem, +// ListItemIcon, +// ListItemText, +// Collapse, +// List, +// styled, +// Menu, +// MenuItem as MuiMenuItem +// } from "@mui/material"; +// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; +// import StatusIndicator from "./StatusIndicator"; -const StyledListItem = styled(ListItem)(({ theme, level }) => ({ - cursor: "pointer", - paddingLeft: theme.spacing(2 + level * 2), - position: 'relative', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - '&.Mui-selected': { - backgroundColor: theme.palette.custom.sidebarHover, - }, -})); +// const StyledListItem = styled(ListItem)(({ theme, level }) => ({ +// cursor: "pointer", +// paddingLeft: theme.spacing(2 + level * 2), +// position: 'relative', +// '&:hover': { +// backgroundColor: theme.palette.action.hover, +// }, +// '&.Mui-selected': { +// backgroundColor: theme.palette.custom.sidebarHover, +// }, +// })); -const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => { - const [isOpen, setIsOpen] = useState(false); - const [contextMenu, setContextMenu] = useState(null); - const hasChildren = Array.isArray(item.items) && item.items.length > 0; +// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => { +// const [isOpen, setIsOpen] = useState(false); +// const [contextMenu, setContextMenu] = useState(null); +// const hasChildren = Array.isArray(item.items) && item.items.length > 0; - const handleContextMenu = (e) => { - e.preventDefault(); - setContextMenu( - contextMenu === null - ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } - : null - ); - }; +// const handleContextMenu = (e) => { +// e.preventDefault(); +// setContextMenu( +// contextMenu === null +// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } +// : null +// ); +// }; - const handleCloseContextMenu = () => { - setContextMenu(null); - }; +// const handleCloseContextMenu = () => { +// setContextMenu(null); +// }; - const handleToggle = (e) => { - e.stopPropagation(); - setIsOpen(!isOpen); - }; +// const handleToggle = (e) => { +// e.stopPropagation(); +// setIsOpen(!isOpen); +// }; - const handleClick = () => { - if (onSelectItem) { - onSelectItem(item); - } - }; +// const handleClick = () => { +// if (onSelectItem) { +// onSelectItem(item); +// } +// }; - return ( - <> - - {!collapsed && } +// return ( +// <> +// +// {!collapsed && } - - {hasChildren ? (isOpen ? : ) : } - +// +// {hasChildren ? (isOpen ? : ) : } +// - {!collapsed && ( - <> - - {hasChildren && (isOpen ? : )} - - )} - +// {!collapsed && ( +// <> +// +// {hasChildren && (isOpen ? : )} +// +// )} +// - +// - +// - {hasChildren && !collapsed && ( - - - {item.items.map((child, index) => ( - - ))} - - - )} - - ); -}; +// {hasChildren && !collapsed && ( +// +// +// {item.items.map((child, index) => ( +// +// ))} +// +// +// )} +// +// ); +// }; -export default MenuItem; +// export default MenuItem; diff --git a/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx b/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx index 0ed21f7..3f08a11 100644 --- a/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx +++ b/src/Components/Layout/SidebarMenuComponents/SidebarFooter.jsx @@ -1,20 +1,24 @@ import React, { useState } from "react"; -import { Brightness4, Brightness7 } from "@mui/icons-material"; -import { IconButton, Tooltip } from "@mui/material"; +import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material"; +import { + IconButton, + Tooltip, + Box, + Button, + alpha +} from "@mui/material"; import { List, ListItem, ListItemText, styled, Switch, - Box, - Button } from "@mui/material"; import SettingsModal from "../SettingsModal"; import { RoleBasedRender } from "../../UI/RoleBasedRender"; const FooterList = styled(List)(({ theme }) => ({ - backgroundColor: theme.palette.custom.sidebar, + backgroundColor: 'background.paper', padding: theme.spacing(1, 0), borderTop: `1px solid ${theme.palette.divider}`, marginTop: 'auto' @@ -22,12 +26,15 @@ const FooterList = styled(List)(({ theme }) => ({ const FooterListItem = styled(ListItem)(({ theme }) => ({ '&:hover': { - backgroundColor: theme.palette.custom.sidebarHover, + backgroundColor: alpha(theme.palette.action.hover, 0.4), }, padding: theme.spacing(1, 2), display: 'flex', justifyContent: 'space-between', - alignItems: 'center' + alignItems: 'center', + borderRadius: '8px', + margin: '0 8px 4px', + transition: 'all 0.2s ease', })); const SidebarFooter = ({ @@ -46,72 +53,93 @@ const SidebarFooter = ({ const handleSettingsClose = () => { setSettingsOpen(false); }; - /*console.log('SidebarFooter user with role:', { - ...user, - hasRole: 'role' in user, - roleValue: user?.role - }); */ + return ( <> - {!collapsed && ( - - - - )} - - {/* кнопка настроек */} - - {!collapsed && ( + {!collapsed ? ( + <> + - )} - - - - setIsDarkMode(!isDarkMode)} - sx={{ color: 'custom.sidebarText' }} + + + setIsDarkMode(!isDarkMode)} + sx={{ + color: 'text.secondary', + '&:hover': { + color: 'text.primary', + backgroundColor: alpha('#000000', 0.1) + } + }} + > + {isDarkMode ? : } + + + setIsDarkMode(!isDarkMode)} + size="small" + color="primary" + /> + + + + + + + + ) : ( + + + + - {!collapsed && ( - setIsDarkMode(!isDarkMode)} - size="small" - /> - )} - - + + )} - {/* Используем RoleBasedRender для модального окна */} { +const SortableMenuItem = ({ + item, + collapsed, + onSelectItem, + level = 0, + isHovered = false, + showDropIndicator = false, + sidebarWidth = 300 +}) => { const [isOpen, setIsOpen] = useState(false); + const [isLocalHovered, setIsLocalHovered] = useState(false); const { attributes, @@ -21,22 +33,34 @@ const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => { setNodeRef, transform, transition, - isDragging + isDragging, + isOver } = useSortable({ id: item.id, data: { type: 'menu-item', - item + item, + level } }); const style = { transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, + transition: transition || 'all 0.2s ease', + opacity: isDragging ? 0.6 : 1, + zIndex: isDragging ? 1000 : 1, }; const hasChildren = Array.isArray(item.items) && item.items.length > 0; + const isFolder = hasChildren; + const isHighlighted = isHovered || isOver; + + // Рассчитываем максимальную ширину текста в зависимости от уровня вложенности + const calculateMaxTextWidth = () => { + const baseWidth = sidebarWidth - 40; // Отступы и иконки + const levelOffset = level * 24; // Отступ для каждого уровня + return baseWidth - levelOffset - 60; // Оставляем место для иконок и отступов + }; const handleClick = (e) => { e.stopPropagation(); @@ -47,63 +71,146 @@ const SortableMenuItem = ({ item, collapsed, onSelectItem, level = 0 }) => { } }; + const handleMouseEnter = () => { + setIsLocalHovered(true); + }; + + const handleMouseLeave = () => { + setIsLocalHovered(false); + }; + + const getBackgroundColor = (theme) => { + if (isDragging) return alpha(theme.palette.primary.main, 0.1); + if (isHighlighted) return alpha(theme.palette.primary.main, 0.08); + if (isLocalHovered) return alpha(theme.palette.action.hover, 0.4); + return 'transparent'; + }; + return ( - + alpha(theme.palette.primary.main, 0.1), + border: (theme) => `2px dashed ${theme.palette.primary.main}`, + borderRadius: '8px', + }) + }} + > getBackgroundColor(theme), + borderRadius: '6px', + margin: '1px 4px', + transition: 'all 0.2s ease', }} onClick={handleClick} > + {!collapsed && ( )} - - {hasChildren ? (isOpen ? : ) : } - - {!collapsed && ( <> - - {hasChildren && (isOpen ? : )} + + + {item.title} + + } + sx={{ mr: 0.5, flex: '1 1 auto', minWidth: 0 }} + /> + + {hasChildren && ( + + )} )} {hasChildren && !collapsed && ( - + `1px solid ${alpha(theme.palette.divider, 0.1)}`, + marginLeft: 2, + position: 'relative', + }} + > {item.items.map((child) => ( - + + + ))}