automatic formation of the side menu
parent
2b79159d35
commit
069cea21b0
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue