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

@ -17,7 +17,7 @@ const LineChartComponent = ({
additionalLines = [] additionalLines = []
}) => { }) => {
return ( return (
<div style={{ width: '100%', height: '560' }}> <div style={{ width: '100%', height: `${height}px` }}>
{title && <h3>{title}</h3>} {title && <h3>{title}</h3>}
{description && ( {description && (
<p style={{ marginTop: -10, color: '#666' }}>{description}</p> <p style={{ marginTop: -10, color: '#666' }}>{description}</p>

View File

@ -6,12 +6,19 @@ class MetricsService {
this.socket = null; this.socket = null;
this.subscriptions = new Map(); this.subscriptions = new Map();
this.pendingRequests = new Map(); this.pendingRequests = new Map();
window.addEventListener('beforeunload', () => {
this.cleanupAll();
});
} }
// Инициализация WebSocket соединения
connectWebSocket() { 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`, { this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, {
transports: ['websocket'], transports: ['websocket'],
withCredentials: true, withCredentials: true,
@ -19,7 +26,6 @@ class MetricsService {
this.socket.on('connect', () => { this.socket.on('connect', () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
// Восстанавливаем подписки при переподключении
this.subscriptions.forEach((_, metric) => { this.subscriptions.forEach((_, metric) => {
this.socket.emit('subscribe-metric', { metric }); this.socket.emit('subscribe-metric', { metric });
}); });
@ -27,10 +33,10 @@ class MetricsService {
this.socket.on('disconnect', () => { this.socket.on('disconnect', () => {
console.log('WebSocket disconnected'); console.log('WebSocket disconnected');
this.socket = null;
}); });
this.socket.on('metrics-data', ({ metric, data, requestId }) => { this.socket.on('metrics-data', ({ metric, data, requestId }) => {
// Обработка исторических данных
if (requestId && this.pendingRequests.has(requestId)) { if (requestId && this.pendingRequests.has(requestId)) {
const { resolve } = this.pendingRequests.get(requestId); const { resolve } = this.pendingRequests.get(requestId);
resolve(data); resolve(data);
@ -38,7 +44,6 @@ class MetricsService {
return; return;
} }
// Обработка реального времени
const callbacks = this.subscriptions.get(metric) || []; const callbacks = this.subscriptions.get(metric) || [];
callbacks.forEach(cb => cb(data)); callbacks.forEach(cb => cb(data));
}); });
@ -52,12 +57,11 @@ class MetricsService {
}); });
} }
// Запрос исторических данных через WebSocket async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
async fetchMetricsRange(metric, start, end, step = 15) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.connectWebSocket(); 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.pendingRequests.set(requestId, { resolve, reject });
this.socket.emit('get-metrics', { this.socket.emit('get-metrics', {
@ -65,35 +69,35 @@ class MetricsService {
start, start,
end, end,
step, step,
filters,
isRangeQuery: true, isRangeQuery: true,
requestId requestId
}); });
// Таймаут запроса
setTimeout(() => { setTimeout(() => {
if (this.pendingRequests.has(requestId)) { if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout')); reject(new Error('Request timeout'));
this.pendingRequests.delete(requestId);
} }
}, 30000); }, 30000);
}); });
} }
// Подписка на обновления в реальном времени subscribeToMetric(metric, callback, interval = 5000, filters = {}) {
subscribeToMetric(metric, callback, interval = 5000) {
this.connectWebSocket(); this.connectWebSocket();
if (!this.subscriptions.has(metric)) { const alreadySubscribed = this.subscriptions.has(metric);
this.subscriptions.set(metric, []); const callbacks = this.subscriptions.get(metric) || [];
this.socket.emit('subscribe-metric', { metric, interval }); 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); return () => this.unsubscribeFromMetric(metric, callback);
} }
// Отписка от метрики
unsubscribeFromMetric(metric, callback) { unsubscribeFromMetric(metric, callback) {
const callbacks = this.subscriptions.get(metric) || []; const callbacks = this.subscriptions.get(metric) || [];
const filtered = callbacks.filter(cb => cb !== callback); const filtered = callbacks.filter(cb => cb !== callback);
@ -108,10 +112,18 @@ class MetricsService {
} }
} }
// Закрытие соединения cleanupAll() {
if (this.socket && this.socket.connected) {
this.socket.emit('unsubscribe-all');
}
this.subscriptions.clear();
this.disconnectWebSocket();
}
disconnectWebSocket() { disconnectWebSocket() {
if (this.socket) { if (this.socket) {
this.socket.close(); this.socket.disconnect();
this.socket = null;
} }
} }
} }

View File

@ -2,43 +2,79 @@ import React, { useState, useEffect } from 'react';
import LineChartComponent from './Components/LineChartComponent'; import LineChartComponent from './Components/LineChartComponent';
import DateRangeSelector from './Components/DateRangeSelector'; import DateRangeSelector from './Components/DateRangeSelector';
import { metricsService } from './Components/metricsService'; import { metricsService } from './Components/metricsService';
import { Button, Radio, message } from 'antd'; import { Button, Radio, message, Tag } from 'antd';
import moment from 'moment'; 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 [chartData, setChartData] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [metricInfo, setMetricInfo] = useState({}); const [metricMeta, setMetricMeta] = useState({});
const [mode, setMode] = useState('realtime'); const [mode, setMode] = useState('realtime');
const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate()); const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate());
const [endDate, setEndDate] = useState(moment().toDate()); const [endDate, setEndDate] = useState(moment().toDate());
const [isLiveUpdating, setIsLiveUpdating] = useState(false); const [isLiveUpdating, setIsLiveUpdating] = useState(false);
const fetchHistoricalData = async (start, end) => { // Генерация уникального ключа для подписки
setIsLoading(true); const getSubscriptionKey = () => {
setError(null); return `${metricName}_${device || 'all'}_${module || 'all'}_${deviceId || 'all'}`;
};
try { const formatMetricData = (dataArray) => {
const startUnix = Math.floor(new Date(start).getTime() / 1000); return dataArray
const endUnix = Math.floor(new Date(end).getTime() / 1000); .map(item => ({
const data = await metricsService.fetchMetricsRange(metricName, startUnix, endUnix, 15);
const dataArray = Array.isArray(data) ? data : [data];
const formattedData = dataArray.map(item => ({
timestamp: item.timestamp, timestamp: item.timestamp,
value: parseFloat(item.value), value: parseFloat(item.value),
name: item.__name__ || metricName, 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) { const fetchHistoricalData = async (start, end) => {
setMetricInfo({ setIsLoading(true);
type: dataArray[0].type, setError(null);
description: dataArray[0].description,
instance: dataArray[0].instance, try {
job: dataArray[0].job 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
}); });
} }
@ -58,30 +94,40 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
const end = new Date(); const end = new Date();
const start = new Date(end.getTime() - 3600 * 1000); const start = new Date(end.getTime() - 3600 * 1000);
fetchHistoricalData(start, end).finally(() => { fetchHistoricalData(start, end).finally(() => setIsLoading(false));
setIsLoading(false);
});
return metricsService.subscribeToMetric( return metricsService.subscribeToMetric(
metricName, getSubscriptionKey(), // Уникальный ключ для подписки
(newData) => { (newData) => {
const newDataArray = Array.isArray(newData) ? newData : [newData]; const filteredData = newData.filter(item => {
const formattedNewData = newDataArray.map(item => ({ // Строгая проверка всех доступных фильтров
timestamp: item.timestamp, if (device && item.device?.trim() !== device) return false;
value: parseFloat(item.value), if (module && item.source_id !== module) return false;
name: item.__name__ || metricName, return true;
status: item.status });
}));
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 = () => { const stopRealtimeUpdates = () => {
setIsLiveUpdating(false); setIsLiveUpdating(false);
metricsService.unsubscribeFromMetric(metricName); metricsService.unsubscribeFromMetric(getSubscriptionKey());
}; };
const handleCustomRangeApply = () => { const handleCustomRangeApply = () => {
@ -92,7 +138,6 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
useEffect(() => { useEffect(() => {
let unsubscribe; let unsubscribe;
if (mode === 'realtime') { if (mode === 'realtime') {
unsubscribe = startRealtimeUpdates(); unsubscribe = startRealtimeUpdates();
} else { } else {
@ -104,12 +149,23 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
if (unsubscribe) unsubscribe(); if (unsubscribe) unsubscribe();
stopRealtimeUpdates(); 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 = [ const metaInfo = [
metricInfo.instance && `Instance: ${metricInfo.instance}`, metricMeta.instance && `Instance: ${metricMeta.instance}`,
metricInfo.job && `Job: ${metricInfo.job}`, metricMeta.job && `Job: ${metricMeta.job}`,
metricInfo.type && `Type: ${metricInfo.type}` metricMeta.type && `Type: ${metricMeta.type}`
].filter(Boolean).join(' | '); ].filter(Boolean).join(' | ');
return ( return (
@ -147,19 +203,32 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
)} )}
</div> </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 ? ( {isLoading ? (
<div>Loading chart data...</div> <div>Загрузка графика...</div>
) : error ? ( ) : error ? (
<div>Error loading metric: {error}</div> <div>Ошибка: {error}</div>
) : chartData.length === 0 ? ( ) : chartData.length === 0 ? (
<div>No data available for {metricName}</div> <div>Нет данных для метрики: {metricName}</div>
) : ( ) : (
<LineChartComponent <LineChartComponent
data={chartData} data={chartData}
title={metricName} title={title}
description={metricInfo.description} description={description}
metaInfo={metaInfo} metaInfo={metaInfo}
height={chartHeight} height={chartHeight}
additionalFilters={{
device,
module
}}
/> />
)} )}
</div> </div>

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Box, styled } from "@mui/material"; import { Box, styled } from "@mui/material";
import SidebarMenu from "./SidebarMenu";
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils"; import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
import generateTabContent from "../TreeChart/tabContent"; import generateTabContent from "../TreeChart/tabContent";
import CustomTabs from "../UI/MUItabs"; import CustomTabs from "../UI/MUItabs";
@ -8,6 +7,7 @@ import useTabs from "../hooks/useTabs";
import useSidebarResize from "../hooks/useSidebarResize"; import useSidebarResize from "../hooks/useSidebarResize";
import TabContent from "../hooks/TabContent"; import TabContent from "../hooks/TabContent";
import menuData from "../TreeChart/menuData.json"; import menuData from "../TreeChart/menuData.json";
import SidebarMenuWrapper from "./SidebarMenuWrapper";
// Создаем стилизованные компоненты // Создаем стилизованные компоненты
const DashboardContainer = styled(Box)(({ theme }) => ({ const DashboardContainer = styled(Box)(({ theme }) => ({
@ -19,24 +19,6 @@ const DashboardContainer = styled(Box)(({ theme }) => ({
color: theme.palette.text.primary, 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 }) => ({ const MainContent = styled(Box)(({ theme }) => ({
flexGrow: 1, flexGrow: 1,
display: 'flex', display: 'flex',
@ -56,11 +38,9 @@ const Content = styled(Box)(({ theme }) => ({
const Dashboard = ({ isDarkMode, setIsDarkMode }) => { const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная"); const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
const { sidebarWidth, startResizing } = useSidebarResize(290);
const [tabContent, setTabContent] = useState({}); const [tabContent, setTabContent] = useState({});
const [treeData1, setTreeData1] = useState(menuData); const [treeData1, setTreeData1] = useState(menuData);
const [treeData2, setTreeData2] = useState(menuData); const [treeData2, setTreeData2] = useState(menuData);
const [collapsed, setCollapsed] = useState(false);
const [statusHistories, setStatusHistories] = useState({ const [statusHistories, setStatusHistories] = useState({
history1: [], history1: [],
history2: [], history2: [],
@ -99,22 +79,53 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [treeData1, treeData2]); }, [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 ( return (
<DashboardContainer> <DashboardContainer>
{/* Сайдбар */} {/* Сайдбар - теперь используется SidebarMenuWrapper */}
<SidebarWrapper sx={{ width: collapsed ? 64 : sidebarWidth }}> <SidebarMenuWrapper
<SidebarMenu isDarkMode={isDarkMode}
data={treeData1} setIsDarkMode={setIsDarkMode}
onOpenTab={handleOpenTab} onSelectItem={handleMenuSelect}
sidebarWidth={sidebarWidth} />
startResizing={startResizing}
collapsed={collapsed}
setCollapsed={setCollapsed}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
<SidebarResizer onMouseDown={startResizing} />
</SidebarWrapper>
{/* Основной контент */} {/* Основной контент */}
<Box sx={{ <Box sx={{
@ -123,7 +134,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
flexGrow: 1, flexGrow: 1,
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{/* Вкладки*/} {/* Вкладки */}
<Box sx={{ <Box sx={{
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: 'divider',

View File

@ -1,7 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; // SidebarMenu.jsx
import FullLogo from '../../assets/images/logo.svg?react'; import React, { useState, useEffect } from "react";
import MiniLogo from '../../assets/images/system_monitor_icon.svg?react';
import { import {
Drawer, Drawer,
List, List,
@ -9,63 +7,52 @@ import {
styled, styled,
IconButton, IconButton,
Tooltip, Tooltip,
Box Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField
} from "@mui/material"; } from "@mui/material";
import {
ChevronLeft,
ChevronRight,
Menu as MenuIcon
} from "@mui/icons-material";
import MenuItem from "./SidebarMenuComponents/MenuItem"; import MenuItem from "./SidebarMenuComponents/MenuItem";
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter"; import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
import { statusManager1 } from "../TreeChart/dataUtils"; import useSidebarResize from "../hooks/useSidebarResize";
import tabContent from "../TreeChart/tabContent"; import ChevronLeft from '@mui/icons-material/ChevronLeft';
import ChevronRight from '@mui/icons-material/ChevronRight';
const SidebarMenu = ({
const SidebarResizer = styled('div')(({ theme }) => ({ data,
width: "5px", isDarkMode,
cursor: "ew-resize", setIsDarkMode,
backgroundColor: 'transparent', onEditItem,
height: "100%", onSelectItem,
position: "absolute", editModalOpen,
right: 0, editingItem,
top: 0, onCloseEditModal,
transition: 'background-color 0.2s', onSaveChanges
'&:hover': { }) => {
backgroundColor: theme.palette.primary.main, const [collapsed, setCollapsed] = useState(false);
}, const { sidebarWidth, startResizing } = useSidebarResize(290);
zIndex: 2
}));
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed, isDarkMode, setIsDarkMode }) => {
const [hovered, setHovered] = useState(false); 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 = () => { const handleToggleCollapse = () => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
}; };
const handleSelectItem = (id, title, children) => { const SidebarResizer = styled('div')(({ theme }) => ({
onOpenTab(id, title); width: '4px',
cursor: 'ew-resize',
contentCache.current = tabContent({ items: children }, contentCache.current); backgroundColor: 'transparent',
if (contentCache.current[id]) { '&:hover': {
onOpenTab(id, title, contentCache.current[id].content); backgroundColor: theme.palette.action.hover,
} },
}; height: '100%',
position: 'absolute',
top: 0,
right: 0,
const drawerWidth = collapsed ? 64 : sidebarWidth; zIndex: 1000,
}));
return ( return (
<Box <Box
@ -73,17 +60,17 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
sx={{ sx={{
position: 'relative', position: 'relative',
width: drawerWidth, width: collapsed ? 64 : sidebarWidth,
transition: 'width 0.3s ease', transition: 'width 0.3s ease',
}} }}
> >
<Drawer <Drawer
variant="permanent" variant="permanent"
sx={{ sx={{
width: drawerWidth, width: collapsed ? 64 : sidebarWidth,
flexShrink: 0, flexShrink: 0,
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
width: drawerWidth, width: collapsed ? 64 : sidebarWidth,
boxSizing: "border-box", boxSizing: "border-box",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -95,7 +82,7 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
}, },
}} }}
> >
{/* Верхняя часть с логотипом и кнопкой */} {/* Заголовок и кнопка сворачивания */}
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -105,50 +92,20 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
borderColor: 'divider', borderColor: 'divider',
backgroundColor: 'custom.sidebar' backgroundColor: 'custom.sidebar'
}}> }}>
{/* Логотип - центрируется в доступном пространстве */} {!collapsed && (
<Box sx={{ <Typography variant="h6" sx={{ flexGrow: 1, textAlign: 'center' }}>
display: 'flex', Меню
alignItems: 'center', </Typography>
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>
)} )}
{/* Кнопка сворачивания/разворачивания */}
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}> <Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
<IconButton <IconButton
onClick={handleToggleCollapse} onClick={handleToggleCollapse}
size="small" size="small"
sx={{ sx={{
color: 'custom.sidebarText', color: 'custom.sidebarText',
'&:hover': { '&:hover': { backgroundColor: 'custom.sidebarHover' },
backgroundColor: 'custom.sidebarHover', ml: collapsed ? 'auto' : 0
},
ml: collapsed ? 'auto' : 0 // В свернутом состоянии кнопка центрируется
}} }}
> >
{collapsed ? <ChevronRight /> : <ChevronLeft />} {collapsed ? <ChevronRight /> : <ChevronLeft />}
@ -156,56 +113,94 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
</Tooltip> </Tooltip>
</Box> </Box>
{/* Содержимое меню */} {/* Основное содержимое меню */}
<Box sx={{ <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
flexGrow: 1, <List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
display: 'flex', {data && (
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 && (
<MenuItem <MenuItem
item={menuData} item={data}
onSelectItem={handleSelectItem}
collapsed={collapsed} collapsed={collapsed}
level={0}
onEdit={onEditItem}
onSelect={onSelectItem}
/> />
)} )}
</List> </List>
{/* Футер */}
<SidebarFooter <SidebarFooter
collapsed={collapsed} collapsed={collapsed}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode} setIsDarkMode={setIsDarkMode}
/> />
</Box> </Box>
{/* Ресайзер */}
{!collapsed && ( {!collapsed && (
<SidebarResizer onMouseDown={startResizing} /> <SidebarResizer onMouseDown={startResizing} />
)} )}
</Drawer> </Drawer>
{/* Модальное окно редактирования */}
<EditMenuItemDialog
open={editModalOpen}
item={editingItem}
onClose={onCloseEditModal}
onSave={onSaveChanges}
/>
</Box> </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; export default SidebarMenu;

View File

@ -1,16 +1,19 @@
import React from "react"; // MenuItem.jsx
import React, { useState } from "react";
import { import {
ListItem, ListItem,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Collapse, Collapse,
List, List,
styled styled,
IconButton,
Menu,
MenuItem as MuiMenuItem
} from "@mui/material"; } 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 { getStatusColor } from "../../TreeChart/dataUtils";
import StatusIndicator from "./StatusIndicator"
const StyledListItem = styled(ListItem)(({ theme, level }) => ({ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
cursor: "pointer", cursor: "pointer",
@ -24,59 +27,64 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
}, },
})); }));
const StatusIndicator = styled('div')(({ theme, status }) => ({ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
position: 'absolute', const [isOpen, setIsOpen] = useState(false);
left: 0, const [contextMenu, setContextMenu] = useState(null);
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 hasChildren = Array.isArray(item.items) && item.items.length > 0; 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) => { const handleToggle = (e) => {
e.stopPropagation(); e.stopPropagation();
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleOpenTab = (e) => { const handleItemClick = (e) => {
e.stopPropagation(); 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 ( return (
<> <>
<StyledListItem <StyledListItem
component="div" component="div"
onClick={hasChildren ? handleToggle : handleOpenTab} onClick={handleItemClick}
onContextMenu={handleContextMenu}
level={level} level={level}
sx={{ sx={{
pl: collapsed ? 2 : 2 + level * 2, pl: collapsed ? 2 : 2 + level * 2,
justifyContent: collapsed ? 'center' : 'flex-start', justifyContent: collapsed ? 'center' : 'flex-start',
}} }}
> >
{/* Индикатор статуса */}
{!collapsed && <StatusIndicator status={item.status} />} {!collapsed && <StatusIndicator status={item.status} />}
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}> <ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
<IconWrapper onClick={handleOpenTab}> {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
</IconWrapper>
</ListItemIcon> </ListItemIcon>
{!collapsed && ( {!collapsed && (
@ -88,10 +96,39 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
}} }}
/> />
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)} {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
{level > 0 && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick();
}}
sx={{ ml: 1 }}
>
<Edit fontSize="small" />
</IconButton>
)}
</> </>
)} )}
</StyledListItem> </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 && ( {hasChildren && !collapsed && (
<Collapse in={isOpen} timeout="auto" unmountOnExit> <Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding> <List component="div" disablePadding>
@ -100,6 +137,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
key={index} key={index}
item={child} item={child}
onSelectItem={onSelectItem} onSelectItem={onSelectItem}
onEdit={onEdit}
level={level + 1} level={level + 1}
collapsed={collapsed} 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; 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", "id": "media_server_1",
"items": [ "items": [
{ {
"id": "18", "id": "device$18",
"title": "Graviton S2082I (device$18)", "title": "Graviton S2082I (device$18)",
"items": [ "items": [
{ {
"id": "4", "id": "module$11",
"title": "OS Linux (module$4) АО", "title": "OS Linux (module$11) АО",
"items": [ "items": [
{ {
"id": "190", "id": "zvks_cpu1min",
"title": "Загрузка процессора за 1 минуту" "title": "Загрузка процессора за 1 минуту",
"metric": "zvks_cpu1min",
"description": "Загрузка процессора за 1 минуту"
}, },
{ {
"id": "191", "id": "191",
@ -399,16 +401,18 @@
"id": "media_server_2", "id": "media_server_2",
"items": [ "items": [
{ {
"id": "182", "id": "device$19",
"title": "Graviton S2082I (device$19)", "title": "Graviton S2082I (device$19)",
"items": [ "items": [
{ {
"id": "42", "id": "module$13",
"title": "OS Linux (module$6) АО", "title": "OS Linux (module$13) АО",
"items": [ "items": [
{ {
"id": "371", "id": "zvks_cpu1min",
"title": "Загрузка процессора за 1 минуту" "title": "Загрузка процессора за 1 минуту",
"metric": "zvks_cpu1min",
"description": "Загрузка процессора за 1 минуту"
}, },
{ {
"id": "372", "id": "372",

View File

@ -1,39 +1,22 @@
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import Skeleton from '@mui/material/Skeleton'; import Skeleton from "@mui/material/Skeleton";
import Box from '@mui/material/Box'; import Box from "@mui/material/Box";
const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart')); const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender"; 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 для графика // Компонент Skeleton для графика
const ChartSkeleton = () => ( const ChartSkeleton = () => (
<Box sx={{ width: '100%' }}> <Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="60%" height={30} /> <Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} /> <Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
</Box> </Box>
); );
// Компонент Skeleton для родительского контейнера // Компонент Skeleton для контейнера
const ContainerSkeleton = () => ( const ContainerSkeleton = () => (
<Box sx={{ width: '100%' }}> <Box sx={{ width: "100%" }}>
<Skeleton variant="text" width="40%" height={40} /> {/* Заголовок */} <Skeleton variant="text" width="40%" height={40} />
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} /> <Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
@ -43,62 +26,130 @@ const ContainerSkeleton = () => (
</Box> </Box>
); );
const tabContent = (data) => { // Утилита для извлечения контекста из пути
const tabContent = {}; const parseContextFromPath = (node) => {
const context = {};
let current = node;
// Функция для рекурсивного обхода и сбора данных while (current) {
const generateContent = (nodes) => { if (current.id.startsWith("device$")) {
nodes.forEach((node) => { context.device = current.id.split("$")[1];
if (node.items && node.items.length > 0) { context.deviceId = current.id;
const childrenContent = generateContent(node.items); }
if (current.id.startsWith("module$")) {
const content = ( context.module = current.id;
<div> context.source_id = current.id;
<h2>{node.title}</h2> }
<Suspense fallback={<ContainerSkeleton />}> current = current.parent;
<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 пуст");
} }
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; export default tabContent;

View File

@ -1,9 +1,11 @@
import React from "react"; 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"; import CloseIcon from "@mui/icons-material/Close";
const StyledTab = styled(Tab)(({ theme }) => ({ const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 48, minHeight: 48,
padding: theme.spacing(1, 2),
textTransform: 'none',
'&.Mui-selected': { '&.Mui-selected': {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontWeight: theme.typography.fontWeightMedium, 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 CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
const handleMouseDown = (e, id) => { const handleMouseDown = (e, id) => {
if (e.button === 1) { if (e.button === 1) { // Средняя кнопка мыши
e.preventDefault(); e.preventDefault();
onCloseTab(id); onCloseTab(id);
} }
@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onTabClick(newValue); onTabClick(newValue);
}; };
// Статические вкладки
const staticTabs = [
{ id: "Главная", title: "Главная" },
{ id: "Визуализация", title: "Визуализация" }
];
return ( return (
<Box sx={{ <Box sx={{
borderBottom: 1, borderBottom: 1,
@ -39,42 +81,31 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
onChange={handleChange} onChange={handleChange}
variant="scrollable" variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
allowScrollButtonsMobile
aria-label="tabs" aria-label="tabs"
> >
{/* Статические вкладки */} {/* Статические вкладки */}
<StyledTab {staticTabs.map(tab => (
label="Главная" <StyledTab
value="Главная" key={tab.id}
onMouseDown={(e) => handleMouseDown(e, "Главная")} label={tab.title}
/> value={tab.id}
<StyledTab onMouseDown={(e) => handleMouseDown(e, tab.id)}
label="Визуализация" />
value="Визуализация" ))}
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
/>
{/* Динамические вкладки */} {/* Динамические вкладки */}
{tabs.map((tab) => ( {tabs.map((tab) => (
<StyledTab <StyledTab
key={tab.id} key={tab.id}
label={ label={
<Box sx={{ display: "flex", alignItems: "center" }}> <TabLabel
<span>{tab.title}</span> title={tab.title}
<CloseIcon onClose={(e) => {
fontSize="small" e.stopPropagation();
sx={{ onCloseTab(tab.id);
ml: 1, }}
cursor: "pointer", />
'&:hover': {
color: 'error.main'
}
}}
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
/>
</Box>
} }
value={tab.id} value={tab.id}
onMouseDown={(e) => handleMouseDown(e, tab.id)} onMouseDown={(e) => handleMouseDown(e, tab.id)}