automatic formation of the side menu

pull/40/head
DmitriyA 2025-05-27 20:45:30 -04:00
parent 2b79159d35
commit 069cea21b0
10 changed files with 709 additions and 405 deletions

View File

@ -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 (
<div style={{ width: '100%', height: '560' }}>
<div style={{ width: '100%', height: `${height}px` }}>
{title && <h3>{title}</h3>}
{description && (
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
@ -27,7 +27,7 @@ const LineChartComponent = ({
{metaInfo}
</div>
)}
<ResponsiveContainer width="100%" height="80%">
<LineChart
data={data}

View File

@ -6,12 +6,19 @@ class MetricsService {
this.socket = null;
this.subscriptions = new Map();
this.pendingRequests = new Map();
window.addEventListener('beforeunload', () => {
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);
export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL);

View File

@ -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 (
<div>
<div style={{ marginBottom: 16 }}>
<Radio.Group
value={mode}
<Radio.Group
value={mode}
onChange={(e) => setMode(e.target.value)}
buttonStyle="solid"
style={{ marginBottom: 10 }}
@ -124,7 +180,7 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
<Radio.Button value="realtime">Режим реального времени</Radio.Button>
<Radio.Button value="historical">Исторические данные</Radio.Button>
</Radio.Group>
{mode === 'historical' && (
<DateRangeSelector
startDate={startDate}
@ -134,10 +190,10 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
onApply={handleCustomRangeApply}
/>
)}
{mode === 'realtime' && isLiveUpdating && (
<Button
type="primary"
<Button
type="primary"
danger
onClick={() => setMode('historical')}
style={{ marginTop: 10 }}
@ -146,20 +202,33 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
</Button>
)}
</div>
{/* Отображаем полный путь к метрике */}
{parent && (
<div style={{ marginBottom: 16 }}>
<Tag color="blue">Путь: {getFullPath()}</Tag>
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
{module && <Tag color="purple">Модуль: {module.split('$')[1]}</Tag>}
</div>
)}
{isLoading ? (
<div>Loading chart data...</div>
<div>Загрузка графика...</div>
) : error ? (
<div>Error loading metric: {error}</div>
<div>Ошибка: {error}</div>
) : chartData.length === 0 ? (
<div>No data available for {metricName}</div>
<div>Нет данных для метрики: {metricName}</div>
) : (
<LineChartComponent
data={chartData}
title={metricName}
description={metricInfo.description}
title={title}
description={description}
metaInfo={metaInfo}
height={chartHeight}
additionalFilters={{
device,
module
}}
/>
)}
</div>

View File

@ -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 (
<Box p={2}>
<Typography variant="h6">{item.title}</Typography>
{item.description && (
<Typography color="textSecondary">{item.description}</Typography>
)}
{item.metric && (
<Box mt={2}>
<Typography variant="subtitle1">Метрика: {item.metric}</Typography>
{/* Здесь можно добавить визуализацию метрики */}
</Box>
)}
</Box>
);
};
// Проверяем, существует ли уже такая вкладка
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 (
<DashboardContainer>
{/* Сайдбар */}
<SidebarWrapper sx={{ width: collapsed ? 64 : sidebarWidth }}>
<SidebarMenu
data={treeData1}
onOpenTab={handleOpenTab}
sidebarWidth={sidebarWidth}
startResizing={startResizing}
collapsed={collapsed}
setCollapsed={setCollapsed}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
<SidebarResizer onMouseDown={startResizing} />
</SidebarWrapper>
{/* Сайдбар - теперь используется SidebarMenuWrapper */}
<SidebarMenuWrapper
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
onSelectItem={handleMenuSelect}
/>
{/* Основной контент */}
<Box sx={{
@ -123,7 +134,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
flexGrow: 1,
overflow: 'hidden'
}}>
{/* Вкладки*/}
{/* Вкладки */}
<Box sx={{
borderBottom: 1,
borderColor: 'divider',

View File

@ -1,7 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import FullLogo from '../../assets/images/logo.svg?react';
import MiniLogo from '../../assets/images/system_monitor_icon.svg?react';
// SidebarMenu.jsx
import React, { useState, useEffect } from "react";
import {
Drawer,
List,
@ -9,63 +7,52 @@ import {
styled,
IconButton,
Tooltip,
Box
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField
} from "@mui/material";
import {
ChevronLeft,
ChevronRight,
Menu as MenuIcon
} from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import { statusManager1 } from "../TreeChart/dataUtils";
import tabContent from "../TreeChart/tabContent";
import useSidebarResize from "../hooks/useSidebarResize";
import ChevronLeft from '@mui/icons-material/ChevronLeft';
import ChevronRight from '@mui/icons-material/ChevronRight';
const SidebarResizer = styled('div')(({ theme }) => ({
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 (
<Box
@ -73,17 +60,17 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
onMouseLeave={() => setHovered(false)}
sx={{
position: 'relative',
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
transition: 'width 0.3s ease',
}}
>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
width: collapsed ? 64 : sidebarWidth,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
@ -95,7 +82,7 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
},
}}
>
{/* Верхняя часть с логотипом и кнопкой */}
{/* Заголовок и кнопка сворачивания */}
<Box sx={{
display: 'flex',
alignItems: 'center',
@ -105,50 +92,20 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
borderColor: 'divider',
backgroundColor: 'custom.sidebar'
}}>
{/* Логотип - центрируется в доступном пространстве */}
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
overflow: 'hidden',
transition: 'opacity 0.3s ease',
opacity: collapsed ? 0 : 1,
width: collapsed ? 0 : 'auto'
}}>
<FullLogo style={{
height: '32px',
width: 'auto',
maxWidth: '100%'
}} />
</Box>
{/* Мини-логотип (только в свернутом состоянии) */}
{collapsed && (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%'
}}>
<MiniLogo style={{
height: '24px',
width: '24px'
}} />
</Box>
{!collapsed && (
<Typography variant="h6" sx={{ flexGrow: 1, textAlign: 'center' }}>
Меню
</Typography>
)}
{/* Кнопка сворачивания/разворачивания */}
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
<IconButton
onClick={handleToggleCollapse}
size="small"
sx={{
color: 'custom.sidebarText',
'&:hover': {
backgroundColor: 'custom.sidebarHover',
},
ml: collapsed ? 'auto' : 0 // В свернутом состоянии кнопка центрируется
'&:hover': { backgroundColor: 'custom.sidebarHover' },
ml: collapsed ? 'auto' : 0
}}
>
{collapsed ? <ChevronRight /> : <ChevronLeft />}
@ -156,56 +113,94 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
</Tooltip>
</Box>
{/* Содержимое меню */}
<Box sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<List sx={{
overflowY: 'auto',
overflowX: 'hidden',
flex: '1 1 auto'
}}>
{!collapsed && (
<Typography
variant="h6"
sx={{
p: 2,
fontWeight: 'bold',
textAlign: 'center'
}}
>
Меню
</Typography>
)}
{menuData && (
{/* Основное содержимое меню */}
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
{data && (
<MenuItem
item={menuData}
onSelectItem={handleSelectItem}
item={data}
collapsed={collapsed}
level={0}
onEdit={onEditItem}
onSelect={onSelectItem}
/>
)}
</List>
{/* Футер */}
<SidebarFooter
collapsed={collapsed}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
</Box>
{/* Ресайзер */}
{!collapsed && (
<SidebarResizer onMouseDown={startResizing} />
)}
</Drawer>
{/* Модальное окно редактирования */}
<EditMenuItemDialog
open={editModalOpen}
item={editingItem}
onClose={onCloseEditModal}
onSave={onSaveChanges}
/>
</Box>
);
};
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Редактирование элемента меню</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
label="Название"
name="title"
value={formData.title || ''}
onChange={handleChange}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="ID"
name="id"
value={formData.id || ''}
onChange={handleChange}
disabled
sx={{ mb: 2 }}
/>
{/* Дополнительные поля для редактирования */}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button onClick={handleSubmit} variant="contained" color="primary">
Сохранить
</Button>
</DialogActions>
</Dialog>
);
};
export default SidebarMenu;

View File

@ -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 (
<>
<StyledListItem
component="div"
onClick={hasChildren ? handleToggle : handleOpenTab}
onClick={handleItemClick}
onContextMenu={handleContextMenu}
level={level}
sx={{
pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{/* Индикатор статуса */}
{!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
<IconWrapper onClick={handleOpenTab}>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</IconWrapper>
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</ListItemIcon>
{!collapsed && (
@ -88,10 +96,39 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
}}
/>
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
{level > 0 && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick();
}}
sx={{ ml: 1 }}
>
<Edit fontSize="small" />
</IconButton>
)}
</>
)}
</StyledListItem>
{/* Контекстное меню */}
<Menu
open={contextMenu !== null}
onClose={handleCloseContextMenu}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
<MuiMenuItem onClick={handleEditClick}>
<Edit fontSize="small" sx={{ mr: 1 }} /> Редактировать
</MuiMenuItem>
</Menu>
{hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
@ -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;

View File

@ -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 (
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={2}>
<Typography color="error">Error loading menu: {error}</Typography>
</Box>
);
}
if (!menuData) {
return null;
}
return (
<SidebarMenu
data={menuData}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
onEditItem={(item) => {
setEditingItem(item);
setEditModalOpen(true);
}}
onSelectItem={onMenuSelect}
editModalOpen={editModalOpen}
editingItem={editingItem}
onCloseEditModal={() => setEditModalOpen(false)}
onSaveChanges={handleSaveChanges}
/>
);
};
export default SidebarMenuWrapper;

View File

@ -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",

View File

@ -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 = () => (
<Box sx={{ width: '100%' }}>
<Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
</Box>
);
// Компонент Skeleton для родительского контейнера
// Компонент Skeleton для контейнера
const ContainerSkeleton = () => (
<Box sx={{ width: '100%' }}>
<Skeleton variant="text" width="40%" height={40} /> {/* Заголовок */}
<Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="40%" height={40} />
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
<Box sx={{ mt: 2 }}>
{[...Array(3)].map((_, i) => (
@ -43,62 +26,130 @@ const ContainerSkeleton = () => (
</Box>
);
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 = (
<div>
<h2>{node.title}</h2>
<Suspense fallback={<ContainerSkeleton />}>
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
</Suspense>
<p>Контент для {node.title}.</p>
</div>
);
tabContent[node.id] = {
title: node.title,
content: content,
};
} else {
const metricName = getMetricName(node.id);
const content = (
<div key={node.id}>
<h3>{node.title}</h3>
<Suspense fallback={<ChartSkeleton />}>
<PrometheusChart metricName={metricName} />
</Suspense>
</div>
);
tabContent[node.id] = {
title: node.title,
content: content,
};
}
});
return (
<div>
{nodes.map((node) => (
<div key={node.id}>{tabContent[node.id].content}</div>
))}
</div>
);
};
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 = (
<div key={`${pathId}-container`}>
<h2>{node.title}</h2>
<Suspense fallback={<ContainerSkeleton />}>
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
</Suspense>
</div>
);
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 = (
<div key={chartKey}>
<h3>{node.title}</h3>
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
<Suspense fallback={<ChartSkeleton />}>
<PrometheusChart
metricInfo={{
name: node.metric,
filters: {
...(currentContext.device && { device: currentContext.device }),
...(currentContext.source_id && { source_id: currentContext.source_id }),
},
title: node.title,
description: node.description,
context: currentContext,
}}
key={chartKey}
/>
</Suspense>
</div>
);
tabContentMap[pathId] = {
title: node.title,
content,
context: currentContext,
};
return { content, context: currentContext };
}
// Узел без метрики и без вложенных просто заголовок
const content = (
<div key={pathId}>
<h3>{node.title}</h3>
</div>
);
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;

View File

@ -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 (
<Box sx={{
display: "flex",
alignItems: "center",
minWidth: 0 // Для корректного обрезания длинного текста
}}>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: 120,
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
{title}
</Typography>
<CloseIcon
fontSize="small"
sx={{
ml: 1,
cursor: "pointer",
flexShrink: 0,
'&:hover': {
color: 'error.main'
}
}}
onClick={onClose}
/>
</Box>
);
};
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 (
<Box sx={{
borderBottom: 1,
@ -39,42 +81,31 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="tabs"
>
{/* Статические вкладки */}
<StyledTab
label="Главная"
value="Главная"
onMouseDown={(e) => handleMouseDown(e, "Главная")}
/>
<StyledTab
label="Визуализация"
value="Визуализация"
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
/>
{staticTabs.map(tab => (
<StyledTab
key={tab.id}
label={tab.title}
value={tab.id}
onMouseDown={(e) => handleMouseDown(e, tab.id)}
/>
))}
{/* Динамические вкладки */}
{tabs.map((tab) => (
<StyledTab
key={tab.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
<span>{tab.title}</span>
<CloseIcon
fontSize="small"
sx={{
ml: 1,
cursor: "pointer",
'&:hover': {
color: 'error.main'
}
}}
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
/>
</Box>
<TabLabel
title={tab.title}
onClose={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
/>
}
value={tab.id}
onMouseDown={(e) => handleMouseDown(e, tab.id)}