diff --git a/src/Charts2/Components/LineChartComponent.jsx b/src/Charts2/Components/LineChartComponent.jsx index 4a5cea5..2f990f6 100644 --- a/src/Charts2/Components/LineChartComponent.jsx +++ b/src/Charts2/Components/LineChartComponent.jsx @@ -1,12 +1,12 @@ import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -const LineChartComponent = ({ - data, - title, - description, - metaInfo, - dataKey = 'value', +const LineChartComponent = ({ + data, + title, + description, + metaInfo, + dataKey = 'value', lineColor = '#8884d8', height = 400, showLegend = true, @@ -17,7 +17,7 @@ const LineChartComponent = ({ additionalLines = [] }) => { return ( -
+
{title &&

{title}

} {description && (

{description}

@@ -27,7 +27,7 @@ const LineChartComponent = ({ {metaInfo}
)} - + { + this.cleanupAll(); + }); } - // Инициализация WebSocket соединения connectWebSocket() { - if (this.socket && this.socket.connected) return; + if (this.socket) { + console.log('WebSocket already exists'); + return; + } + console.log('Connecting WebSocket...'); this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, { transports: ['websocket'], withCredentials: true, @@ -19,7 +26,6 @@ class MetricsService { this.socket.on('connect', () => { console.log('WebSocket connected'); - // Восстанавливаем подписки при переподключении this.subscriptions.forEach((_, metric) => { this.socket.emit('subscribe-metric', { metric }); }); @@ -27,10 +33,10 @@ class MetricsService { this.socket.on('disconnect', () => { console.log('WebSocket disconnected'); + this.socket = null; }); this.socket.on('metrics-data', ({ metric, data, requestId }) => { - // Обработка исторических данных if (requestId && this.pendingRequests.has(requestId)) { const { resolve } = this.pendingRequests.get(requestId); resolve(data); @@ -38,7 +44,6 @@ class MetricsService { return; } - // Обработка реального времени const callbacks = this.subscriptions.get(metric) || []; callbacks.forEach(cb => cb(data)); }); @@ -52,12 +57,11 @@ class MetricsService { }); } - // Запрос исторических данных через WebSocket - async fetchMetricsRange(metric, start, end, step = 15) { + async fetchMetricsRange(metric, start, end, step = 15, filters = {}) { return new Promise((resolve, reject) => { this.connectWebSocket(); - const requestId = `range-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const requestId = `range-${Date.now()}`; this.pendingRequests.set(requestId, { resolve, reject }); this.socket.emit('get-metrics', { @@ -65,35 +69,35 @@ class MetricsService { start, end, step, + filters, isRangeQuery: true, requestId }); - // Таймаут запроса setTimeout(() => { if (this.pendingRequests.has(requestId)) { - this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); + this.pendingRequests.delete(requestId); } }, 30000); }); } - // Подписка на обновления в реальном времени - subscribeToMetric(metric, callback, interval = 5000) { + subscribeToMetric(metric, callback, interval = 5000, filters = {}) { this.connectWebSocket(); - if (!this.subscriptions.has(metric)) { - this.subscriptions.set(metric, []); - this.socket.emit('subscribe-metric', { metric, interval }); - } + const alreadySubscribed = this.subscriptions.has(metric); + const callbacks = this.subscriptions.get(metric) || []; + callbacks.push(callback); + this.subscriptions.set(metric, callbacks); - this.subscriptions.get(metric).push(callback); + if (!alreadySubscribed) { + this.socket.emit('subscribe-metric', { metric, interval, filters }); + } return () => this.unsubscribeFromMetric(metric, callback); } - // Отписка от метрики unsubscribeFromMetric(metric, callback) { const callbacks = this.subscriptions.get(metric) || []; const filtered = callbacks.filter(cb => cb !== callback); @@ -108,12 +112,20 @@ class MetricsService { } } - // Закрытие соединения + cleanupAll() { + if (this.socket && this.socket.connected) { + this.socket.emit('unsubscribe-all'); + } + this.subscriptions.clear(); + this.disconnectWebSocket(); + } + disconnectWebSocket() { if (this.socket) { - this.socket.close(); + this.socket.disconnect(); + this.socket = null; } } } -export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); \ No newline at end of file +export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL); diff --git a/src/Charts2/PrometheusChart.jsx b/src/Charts2/PrometheusChart.jsx index 24c37a7..33b711e 100644 --- a/src/Charts2/PrometheusChart.jsx +++ b/src/Charts2/PrometheusChart.jsx @@ -2,43 +2,79 @@ import React, { useState, useEffect } from 'react'; import LineChartComponent from './Components/LineChartComponent'; import DateRangeSelector from './Components/DateRangeSelector'; import { metricsService } from './Components/metricsService'; -import { Button, Radio, message } from 'antd'; +import { Button, Radio, message, Tag } from 'antd'; import moment from 'moment'; -const PrometheusChart = ({ metricName, chartHeight = 560 }) => { +const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => { + const { + name: metricName, + filters = {}, + title = metricName, + description, + context = {} // Добавляем контекст из path + } = metricInfo || {}; + + console.log("⚙️ PrometheusChart -> metricInfo:", metricInfo); + console.log("📌 Контекст -> device:", context.device, "source_id:", context.source_id, "deviceId:", context.deviceId); + + // Получаем полный контекст из родительских элементов + const { device, source_id: module, deviceId, parent } = context; + const [chartData, setChartData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [metricInfo, setMetricInfo] = useState({}); + const [metricMeta, setMetricMeta] = useState({}); const [mode, setMode] = useState('realtime'); const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate()); const [endDate, setEndDate] = useState(moment().toDate()); const [isLiveUpdating, setIsLiveUpdating] = useState(false); - const fetchHistoricalData = async (start, end) => { - setIsLoading(true); - setError(null); - - try { - const startUnix = Math.floor(new Date(start).getTime() / 1000); - const endUnix = Math.floor(new Date(end).getTime() / 1000); - - const data = await metricsService.fetchMetricsRange(metricName, startUnix, endUnix, 15); - - const dataArray = Array.isArray(data) ? data : [data]; - const formattedData = dataArray.map(item => ({ + // Генерация уникального ключа для подписки + const getSubscriptionKey = () => { + return `${metricName}_${device || 'all'}_${module || 'all'}_${deviceId || 'all'}`; + }; + + const formatMetricData = (dataArray) => { + return dataArray + .map(item => ({ timestamp: item.timestamp, value: parseFloat(item.value), name: item.__name__ || metricName, - status: item.status - })); + status: item.status, + device: item.device?.trim() || null, + source_id: item.source_id || null + })) + .sort((a, b) => a.timestamp - b.timestamp); + }; - if (dataArray.length > 0) { - setMetricInfo({ - type: dataArray[0].type, - description: dataArray[0].description, - instance: dataArray[0].instance, - job: dataArray[0].job + const fetchHistoricalData = async (start, end) => { + setIsLoading(true); + setError(null); + + try { + const extendedFilters = { + ...filters, + ...(device && { device: device.toString() }), // убедитесь, что device строка + ...(source_id && { source_id: source_id.toString() }) + }; + + console.log('Fetching with filters:', extendedFilters); // для отладки + + const data = await metricsService.fetchMetricsRange( + metricName, + Math.floor(start.getTime() / 1000), + Math.floor(end.getTime() / 1000), + 15, + extendedFilters + ); + + const formattedData = formatMetricData(data); + if (formattedData.length > 0) { + setMetricMeta({ + type: data[0]?.type, + description: data[0]?.description || description, + instance: data[0]?.instance, + job: data[0]?.job }); } @@ -55,33 +91,43 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { const startRealtimeUpdates = () => { setIsLiveUpdating(true); setIsLoading(true); - + const end = new Date(); const start = new Date(end.getTime() - 3600 * 1000); - fetchHistoricalData(start, end).finally(() => { - setIsLoading(false); - }); + fetchHistoricalData(start, end).finally(() => setIsLoading(false)); return metricsService.subscribeToMetric( - metricName, + getSubscriptionKey(), // Уникальный ключ для подписки (newData) => { - const newDataArray = Array.isArray(newData) ? newData : [newData]; - const formattedNewData = newDataArray.map(item => ({ - timestamp: item.timestamp, - value: parseFloat(item.value), - name: item.__name__ || metricName, - status: item.status - })); + const filteredData = newData.filter(item => { + // Строгая проверка всех доступных фильтров + if (device && item.device?.trim() !== device) return false; + if (module && item.source_id !== module) return false; + return true; + }); - setChartData(prevData => [...prevData, ...formattedNewData].slice(-200)); + if (filteredData.length > 0) { + const formattedData = formatMetricData(filteredData); + setChartData(prev => { + const newChartData = [...prev, ...formattedData] + .filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i) + .slice(-200); + return newChartData; + }); + } }, - 5000 + 5000, + { + ...filters, + ...(device && { device }), + ...(module && { source_id: module }) + } ); }; const stopRealtimeUpdates = () => { setIsLiveUpdating(false); - metricsService.unsubscribeFromMetric(metricName); + metricsService.unsubscribeFromMetric(getSubscriptionKey()); }; const handleCustomRangeApply = () => { @@ -92,7 +138,6 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { useEffect(() => { let unsubscribe; - if (mode === 'realtime') { unsubscribe = startRealtimeUpdates(); } else { @@ -104,19 +149,30 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { if (unsubscribe) unsubscribe(); stopRealtimeUpdates(); }; - }, [mode, metricName]); + }, [mode, metricName, device, module]); + + // Рекурсивно собираем путь для отображения + const getFullPath = () => { + const path = []; + let current = parent; + while (current) { + path.unshift(current.title); + current = current.parent; + } + return path.join(' > '); + }; const metaInfo = [ - metricInfo.instance && `Instance: ${metricInfo.instance}`, - metricInfo.job && `Job: ${metricInfo.job}`, - metricInfo.type && `Type: ${metricInfo.type}` + metricMeta.instance && `Instance: ${metricMeta.instance}`, + metricMeta.job && `Job: ${metricMeta.job}`, + metricMeta.type && `Type: ${metricMeta.type}` ].filter(Boolean).join(' | '); return (
- setMode(e.target.value)} buttonStyle="solid" style={{ marginBottom: 10 }} @@ -124,7 +180,7 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => { Режим реального времени Исторические данные - + {mode === 'historical' && ( { onApply={handleCustomRangeApply} /> )} - + {mode === 'realtime' && isLiveUpdating && ( - )}
- + + {/* Отображаем полный путь к метрике */} + {parent && ( +
+ Путь: {getFullPath()} + {device && Устройство: {device}} + {module && Модуль: {module.split('$')[1]}} +
+ )} + {isLoading ? ( -
Loading chart data...
+
Загрузка графика...
) : error ? ( -
Error loading metric: {error}
+
Ошибка: {error}
) : chartData.length === 0 ? ( -
No data available for {metricName}
+
Нет данных для метрики: {metricName}
) : ( )}
diff --git a/src/Components/Layout/Dashboard.jsx b/src/Components/Layout/Dashboard.jsx index 5df3408..74565b0 100755 --- a/src/Components/Layout/Dashboard.jsx +++ b/src/Components/Layout/Dashboard.jsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; import { Box, styled } from "@mui/material"; -import SidebarMenu from "./SidebarMenu"; import { statusManager1, statusManager2 } from "../TreeChart/dataUtils"; import generateTabContent from "../TreeChart/tabContent"; import CustomTabs from "../UI/MUItabs"; @@ -8,6 +7,7 @@ import useTabs from "../hooks/useTabs"; import useSidebarResize from "../hooks/useSidebarResize"; import TabContent from "../hooks/TabContent"; import menuData from "../TreeChart/menuData.json"; +import SidebarMenuWrapper from "./SidebarMenuWrapper"; // Создаем стилизованные компоненты const DashboardContainer = styled(Box)(({ theme }) => ({ @@ -19,24 +19,6 @@ const DashboardContainer = styled(Box)(({ theme }) => ({ color: theme.palette.text.primary, })); -const SidebarWrapper = styled(Box)(({ theme }) => ({ - position: 'relative', - backgroundColor: theme.palette.custom.sidebar, - color: theme.palette.custom.sidebarText, -})); - -const SidebarResizer = styled(Box)(({ theme }) => ({ - position: 'absolute', - right: 0, - top: 0, - bottom: 0, - width: '4px', - cursor: 'col-resize', - '&:hover': { - backgroundColor: theme.palette.primary.main, - }, -})); - const MainContent = styled(Box)(({ theme }) => ({ flexGrow: 1, display: 'flex', @@ -56,11 +38,9 @@ const Content = styled(Box)(({ theme }) => ({ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); - const { sidebarWidth, startResizing } = useSidebarResize(290); const [tabContent, setTabContent] = useState({}); const [treeData1, setTreeData1] = useState(menuData); const [treeData2, setTreeData2] = useState(menuData); - const [collapsed, setCollapsed] = useState(false); const [statusHistories, setStatusHistories] = useState({ history1: [], history2: [], @@ -99,22 +79,53 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => { return () => clearInterval(interval); }, [treeData1, treeData2]); + const handleMenuSelect = (item) => { + const tabId = `tab_${item.id}`; + const tabTitle = item.title || 'Новая вкладка'; + + const generateTabContentForItem = (item) => { + return ( + + {item.title} + {item.description && ( + {item.description} + )} + {item.metric && ( + + Метрика: {item.metric} + {/* Здесь можно добавить визуализацию метрики */} + + )} + + ); + }; + + // Проверяем, существует ли уже такая вкладка + const existingTab = tabs.find(tab => tab.id === tabId); + + if (!existingTab) { + const newTab = { + id: tabId, + title: tabTitle, + content: generateTabContentForItem(item), + type: 'menuItem', + itemData: item // Сохраняем данные элемента для возможного обновления + }; + + handleOpenTab(newTab); + } else { + setActiveTab(tabId); + } + }; + return ( - {/* Сайдбар */} - - - - + {/* Сайдбар - теперь используется SidebarMenuWrapper */} + {/* Основной контент */} { flexGrow: 1, overflow: 'hidden' }}> - {/* Вкладки*/} + {/* Вкладки */} ({ - width: "5px", - cursor: "ew-resize", - backgroundColor: 'transparent', - height: "100%", - position: "absolute", - right: 0, - top: 0, - transition: 'background-color 0.2s', - '&:hover': { - backgroundColor: theme.palette.primary.main, - }, - zIndex: 2 -})); - -const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed, isDarkMode, setIsDarkMode }) => { +const SidebarMenu = ({ + data, + isDarkMode, + setIsDarkMode, + onEditItem, + onSelectItem, + editModalOpen, + editingItem, + onCloseEditModal, + onSaveChanges +}) => { + const [collapsed, setCollapsed] = useState(false); + const { sidebarWidth, startResizing } = useSidebarResize(290); const [hovered, setHovered] = useState(false); - const [menuData, setMenuData] = useState(data); - const contentCache = useRef({}); - - useEffect(() => { - if (data) { - const dataCopy = JSON.parse(JSON.stringify(data)); - statusManager1.updateStatuses(dataCopy); - setMenuData(dataCopy); - } - }, [data]); const handleToggleCollapse = () => { setCollapsed(!collapsed); }; - const handleSelectItem = (id, title, children) => { - onOpenTab(id, title); - - contentCache.current = tabContent({ items: children }, contentCache.current); - if (contentCache.current[id]) { - onOpenTab(id, title, contentCache.current[id].content); - } - }; - - - - const drawerWidth = collapsed ? 64 : sidebarWidth; + const SidebarResizer = styled('div')(({ theme }) => ({ + width: '4px', + cursor: 'ew-resize', + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + height: '100%', + position: 'absolute', + top: 0, + right: 0, + zIndex: 1000, + })); return ( setHovered(false)} sx={{ position: 'relative', - width: drawerWidth, + width: collapsed ? 64 : sidebarWidth, transition: 'width 0.3s ease', }} > - {/* Верхняя часть с логотипом и кнопкой */} + {/* Заголовок и кнопка сворачивания */} - {/* Логотип - центрируется в доступном пространстве */} - - - - - {/* Мини-логотип (только в свернутом состоянии) */} - {collapsed && ( - - - + {!collapsed && ( + + Меню + )} - {/* Кнопка сворачивания/разворачивания */} {collapsed ? : } @@ -156,56 +113,94 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, - {/* Содержимое меню */} - - - {!collapsed && ( - - Меню - - )} - {menuData && ( + {/* Основное содержимое меню */} + + + {data && ( )} - {/* Футер */} - - - {/* Ресайзер */} {!collapsed && ( )} + + {/* Модальное окно редактирования */} + ); }; +const EditMenuItemDialog = ({ open, item, onClose, onSave }) => { + const [formData, setFormData] = useState(item || {}); + + useEffect(() => { + setFormData(item || {}); + }, [item]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = () => { + onSave(formData); + }; + + if (!item) return null; + + return ( + + Редактирование элемента меню + + + + + {/* Дополнительные поля для редактирования */} + + + + + + + + ); +}; + export default SidebarMenu; \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx index 636a973..9b3e01e 100644 --- a/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx +++ b/src/Components/Layout/SidebarMenuComponents/MenuItem.jsx @@ -1,21 +1,24 @@ -import React from "react"; +// MenuItem.jsx +import React, { useState } from "react"; import { ListItem, ListItemIcon, ListItemText, Collapse, List, - styled + styled, + IconButton, + Menu, + MenuItem as MuiMenuItem } from "@mui/material"; -import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material"; +import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material"; import { getStatusColor } from "../../TreeChart/dataUtils"; - - +import StatusIndicator from "./StatusIndicator" const StyledListItem = styled(ListItem)(({ theme, level }) => ({ cursor: "pointer", paddingLeft: theme.spacing(2 + level * 2), - position: 'relative', + position: 'relative', '&:hover': { backgroundColor: theme.palette.action.hover, }, @@ -24,59 +27,64 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({ }, })); -const StatusIndicator = styled('div')(({ theme, status }) => ({ - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: '4px', - backgroundColor: status ? getStatusColor(status) : 'transparent', - borderTopRightRadius: '4px', - borderBottomRightRadius: '4px', -})); - -const IconWrapper = styled('div')(({ theme }) => ({ - cursor: "pointer", - borderRadius: theme.shape.borderRadius, - padding: theme.spacing(0.5), - '&:hover': { - backgroundColor: theme.palette.action.selected, - }, -})); - -const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { - const [isOpen, setIsOpen] = React.useState(false); +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 handleCloseContextMenu = () => { + setContextMenu(null); + }; + + const handleEditClick = () => { + onEdit(item); + handleCloseContextMenu(); + }; + const handleToggle = (e) => { e.stopPropagation(); setIsOpen(!isOpen); }; - const handleOpenTab = (e) => { + const handleItemClick = (e) => { e.stopPropagation(); - const allChildren = getAllChildren(item); - onSelectItem(item.id, item.title, allChildren); + + // Если есть обработчик выбора и элемент можно выбрать + if (onSelectItem && (!item.items || item.items.length === 0)) { + onSelectItem(item); + } + + // Если есть подэлементы - переключаем раскрытие + if (item.items && item.items.length > 0) { + setIsOpen(!isOpen); + } }; return ( <> - {/* Индикатор статуса */} {!collapsed && } - - {hasChildren ? (isOpen ? : ) : } - + {hasChildren ? (isOpen ? : ) : } {!collapsed && ( @@ -88,10 +96,39 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { }} /> {hasChildren && (isOpen ? : )} + + {level > 0 && ( + { + e.stopPropagation(); + handleEditClick(); + }} + sx={{ ml: 1 }} + > + + + )} )} + {/* Контекстное меню */} + + + Редактировать + + + {hasChildren && !collapsed && ( @@ -100,6 +137,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { key={index} item={child} onSelectItem={onSelectItem} + onEdit={onEdit} level={level + 1} collapsed={collapsed} /> @@ -111,15 +149,4 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => { ); }; -const getAllChildren = (node) => { - let children = []; - if (node.items && node.items.length > 0) { - node.items.forEach((child) => { - children.push(child); - children = children.concat(getAllChildren(child)); - }); - } - return children; -}; - export default MenuItem; \ No newline at end of file diff --git a/src/Components/Layout/SidebarMenuWrapper.jsx b/src/Components/Layout/SidebarMenuWrapper.jsx new file mode 100644 index 0000000..22255f9 --- /dev/null +++ b/src/Components/Layout/SidebarMenuWrapper.jsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from 'react'; +import SidebarMenu from './SidebarMenu'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import axios from 'axios'; + +const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => { + const [menuData, setMenuData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingItem, setEditingItem] = useState(null); + const [editModalOpen, setEditModalOpen] = useState(false); + + useEffect(() => { + const fetchMenuData = async () => { + try { + const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`); + setMenuData(response.data); // axios хранит данные в response.data + } catch (err) { + console.error('Error fetching menu data:', err); + setError(err.message || 'Failed to fetch menu data'); + } finally { + setLoading(false); + } + }; + + fetchMenuData(); + }, []); + + const handleSaveChanges = async (updatedItem) => { + try { + const response = await axios.put( + `${import.meta.env.VITE_BACK_URL}/api/menu/${updatedItem.id}`, + updatedItem, + { + headers: { + 'Content-Type': 'application/json', + } + } + ); + + // Обновляем локальное состояние + const updateItemInTree = (items) => { + return items.map(item => { + if (item.id === updatedItem.id) { + return { ...item, ...updatedItem }; + } + if (item.items) { + return { ...item, items: updateItemInTree(item.items) }; + } + return item; + }); + }; + + setMenuData(prev => ({ + ...prev, + items: updateItemInTree(prev.items), + })); + + setEditModalOpen(false); + } catch (err) { + console.error('Error updating menu item:', err); + setError(err.response?.data?.message || err.message || 'Failed to update menu item'); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error loading menu: {error} + + ); + } + + if (!menuData) { + return null; + } + + return ( + { + setEditingItem(item); + setEditModalOpen(true); + }} + onSelectItem={onMenuSelect} + editModalOpen={editModalOpen} + editingItem={editingItem} + onCloseEditModal={() => setEditModalOpen(false)} + onSaveChanges={handleSaveChanges} + /> + ); +}; + +export default SidebarMenuWrapper; \ No newline at end of file diff --git a/src/Components/TreeChart/menuData.json b/src/Components/TreeChart/menuData.json index fcbdd2f..b2526a2 100755 --- a/src/Components/TreeChart/menuData.json +++ b/src/Components/TreeChart/menuData.json @@ -11,16 +11,18 @@ "id": "media_server_1", "items": [ { - "id": "18", + "id": "device$18", "title": "Graviton S2082I (device$18)", "items": [ { - "id": "4", - "title": "OS Linux (module$4) АО", + "id": "module$11", + "title": "OS Linux (module$11) АО", "items": [ { - "id": "190", - "title": "Загрузка процессора за 1 минуту" + "id": "zvks_cpu1min", + "title": "Загрузка процессора за 1 минуту", + "metric": "zvks_cpu1min", + "description": "Загрузка процессора за 1 минуту" }, { "id": "191", @@ -399,16 +401,18 @@ "id": "media_server_2", "items": [ { - "id": "182", + "id": "device$19", "title": "Graviton S2082I (device$19)", "items": [ { - "id": "42", - "title": "OS Linux (module$6) АО", + "id": "module$13", + "title": "OS Linux (module$13) АО", "items": [ { - "id": "371", - "title": "Загрузка процессора за 1 минуту" + "id": "zvks_cpu1min", + "title": "Загрузка процессора за 1 минуту", + "metric": "zvks_cpu1min", + "description": "Загрузка процессора за 1 минуту" }, { "id": "372", diff --git a/src/Components/TreeChart/tabContent.jsx b/src/Components/TreeChart/tabContent.jsx index 6021f3a..f21b002 100755 --- a/src/Components/TreeChart/tabContent.jsx +++ b/src/Components/TreeChart/tabContent.jsx @@ -1,39 +1,22 @@ import React, { lazy, Suspense } from "react"; -import Skeleton from '@mui/material/Skeleton'; -import Box from '@mui/material/Box'; +import Skeleton from "@mui/material/Skeleton"; +import Box from "@mui/material/Box"; -const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart')); +const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart")); import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; -const getMetricName = (id) => { - return `zvks_apiforsnmp_measure_${id}`; -}; - -const getAllChildIds = (node) => { - let ids = []; - if (node.id) { - ids.push(node.id); - } - if (node.items && node.items.length > 0) { - node.items.forEach((child) => { - ids = ids.concat(getAllChildIds(child)); - }); - } - return ids; -}; - // Компонент Skeleton для графика const ChartSkeleton = () => ( - + ); -// Компонент Skeleton для родительского контейнера +// Компонент Skeleton для контейнера const ContainerSkeleton = () => ( - - {/* Заголовок */} + + {[...Array(3)].map((_, i) => ( @@ -43,62 +26,130 @@ const ContainerSkeleton = () => ( ); -const tabContent = (data) => { - const tabContent = {}; +// Утилита для извлечения контекста из пути +const parseContextFromPath = (node) => { + const context = {}; + let current = node; - // Функция для рекурсивного обхода и сбора данных - const generateContent = (nodes) => { - nodes.forEach((node) => { - if (node.items && node.items.length > 0) { - const childrenContent = generateContent(node.items); - - const content = ( -
-

{node.title}

- }> - tabContent[child.id].content)} /> - -

Контент для {node.title}.

-
- ); - - tabContent[node.id] = { - title: node.title, - content: content, - }; - } else { - const metricName = getMetricName(node.id); - const content = ( -
-

{node.title}

- }> - - -
- ); - tabContent[node.id] = { - title: node.title, - content: content, - }; - } - }); - - return ( -
- {nodes.map((node) => ( -
{tabContent[node.id].content}
- ))} -
- ); - }; - - if (data.items && data.items.length > 0) { - generateContent(data.items); - } else { - console.warn("Данные отсутствуют или массив items пуст"); + while (current) { + if (current.id.startsWith("device$")) { + context.device = current.id.split("$")[1]; + context.deviceId = current.id; + } + if (current.id.startsWith("module$")) { + context.module = current.id; + context.source_id = current.id; + } + current = current.parent; } - return tabContent; + return context; +}; + +// Основная функция построения контента вкладок +const tabContent = (data, cache = {}) => { + const tabContentMap = { ...cache }; + + if (!data || !data.items || data.items.length === 0) { + console.warn("Данные отсутствуют или массив items пуст", data); + return tabContentMap; + } + + const processNode = (node, parentContext = {}) => { + // Получаем полный контекст из всей цепочки родителей + const pathContext = parseContextFromPath(node); + const currentContext = { ...parentContext, ...pathContext }; + + // Генерируем уникальный ключ на основе пути + const path = []; + let current = node; + while (current) { + path.unshift(current.id); + current = current.parent; + } + const pathId = path.join('_'); + + if (Array.isArray(node.items) && node.items.length > 0) { + const children = node.items + .map((child) => processNode(child, currentContext)) + .filter(Boolean); + + const content = ( +
+

{node.title}

+ }> + c.content)} /> + +
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + } + + if (node.metric) { + const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`; + + const content = ( +
+

{node.title}

+ {currentContext.device &&

Устройство: {currentContext.device}

} + {currentContext.module &&

Модуль: {currentContext.module}

} + }> + + +
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + } + + // Узел без метрики и без вложенных — просто заголовок + const content = ( +
+

{node.title}

+
+ ); + + tabContentMap[pathId] = { + title: node.title, + content, + context: currentContext, + }; + + return { content, context: currentContext }; + }; + + try { + processNode(data); + } catch (error) { + console.error("Ошибка обработки данных:", error); + } + + return tabContentMap; }; export default tabContent; \ No newline at end of file diff --git a/src/Components/UI/MUItabs.jsx b/src/Components/UI/MUItabs.jsx index 4a03b60..d4ae078 100644 --- a/src/Components/UI/MUItabs.jsx +++ b/src/Components/UI/MUItabs.jsx @@ -1,9 +1,11 @@ import React from "react"; -import { Tabs, Tab, Box, styled } from "@mui/material"; +import { Tabs, Tab, Box, styled, Typography } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; const StyledTab = styled(Tab)(({ theme }) => ({ minHeight: 48, + padding: theme.spacing(1, 2), + textTransform: 'none', '&.Mui-selected': { color: theme.palette.primary.main, fontWeight: theme.typography.fontWeightMedium, @@ -14,9 +16,43 @@ const StyledTab = styled(Tab)(({ theme }) => ({ }, })); +const TabLabel = ({ title, onClose }) => { + return ( + + + {title} + + + + ); +}; + const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { const handleMouseDown = (e, id) => { - if (e.button === 1) { + if (e.button === 1) { // Средняя кнопка мыши e.preventDefault(); onCloseTab(id); } @@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => { onTabClick(newValue); }; + // Статические вкладки + const staticTabs = [ + { id: "Главная", title: "Главная" }, + { id: "Визуализация", title: "Визуализация" } + ]; + return ( { onChange={handleChange} variant="scrollable" scrollButtons="auto" + allowScrollButtonsMobile aria-label="tabs" > {/* Статические вкладки */} - handleMouseDown(e, "Главная")} - /> - handleMouseDown(e, "Визуализация")} - /> + {staticTabs.map(tab => ( + handleMouseDown(e, tab.id)} + /> + ))} {/* Динамические вкладки */} {tabs.map((tab) => ( - {tab.title} - { - e.stopPropagation(); - onCloseTab(tab.id); - }} - /> - + { + e.stopPropagation(); + onCloseTab(tab.id); + }} + /> } value={tab.id} onMouseDown={(e) => handleMouseDown(e, tab.id)}