automatic formation of the side menu
parent
2b79159d35
commit
069cea21b0
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const LineChartComponent = ({
|
||||
data,
|
||||
title,
|
||||
description,
|
||||
metaInfo,
|
||||
dataKey = 'value',
|
||||
const LineChartComponent = ({
|
||||
data,
|
||||
title,
|
||||
description,
|
||||
metaInfo,
|
||||
dataKey = 'value',
|
||||
lineColor = '#8884d8',
|
||||
height = 400,
|
||||
showLegend = true,
|
||||
|
|
@ -17,7 +17,7 @@ const LineChartComponent = ({
|
|||
additionalLines = []
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '560' }}>
|
||||
<div style={{ width: '100%', height: `${height}px` }}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{description && (
|
||||
<p style={{ marginTop: -10, color: '#666' }}>{description}</p>
|
||||
|
|
@ -27,7 +27,7 @@ const LineChartComponent = ({
|
|||
{metaInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<ResponsiveContainer width="100%" height="80%">
|
||||
<LineChart
|
||||
data={data}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,19 @@ class MetricsService {
|
|||
this.socket = null;
|
||||
this.subscriptions = new Map();
|
||||
this.pendingRequests = new Map();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.cleanupAll();
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация WebSocket соединения
|
||||
connectWebSocket() {
|
||||
if (this.socket && this.socket.connected) return;
|
||||
if (this.socket) {
|
||||
console.log('WebSocket already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Connecting WebSocket...');
|
||||
this.socket = io(`${this.baseUrl.replace('http', 'ws')}/api/metrics-ws`, {
|
||||
transports: ['websocket'],
|
||||
withCredentials: true,
|
||||
|
|
@ -19,7 +26,6 @@ class MetricsService {
|
|||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
// Восстанавливаем подписки при переподключении
|
||||
this.subscriptions.forEach((_, metric) => {
|
||||
this.socket.emit('subscribe-metric', { metric });
|
||||
});
|
||||
|
|
@ -27,10 +33,10 @@ class MetricsService {
|
|||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.socket = null;
|
||||
});
|
||||
|
||||
this.socket.on('metrics-data', ({ metric, data, requestId }) => {
|
||||
// Обработка исторических данных
|
||||
if (requestId && this.pendingRequests.has(requestId)) {
|
||||
const { resolve } = this.pendingRequests.get(requestId);
|
||||
resolve(data);
|
||||
|
|
@ -38,7 +44,6 @@ class MetricsService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Обработка реального времени
|
||||
const callbacks = this.subscriptions.get(metric) || [];
|
||||
callbacks.forEach(cb => cb(data));
|
||||
});
|
||||
|
|
@ -52,12 +57,11 @@ class MetricsService {
|
|||
});
|
||||
}
|
||||
|
||||
// Запрос исторических данных через WebSocket
|
||||
async fetchMetricsRange(metric, start, end, step = 15) {
|
||||
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connectWebSocket();
|
||||
|
||||
const requestId = `range-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const requestId = `range-${Date.now()}`;
|
||||
this.pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
this.socket.emit('get-metrics', {
|
||||
|
|
@ -65,35 +69,35 @@ class MetricsService {
|
|||
start,
|
||||
end,
|
||||
step,
|
||||
filters,
|
||||
isRangeQuery: true,
|
||||
requestId
|
||||
});
|
||||
|
||||
// Таймаут запроса
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(requestId)) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
this.pendingRequests.delete(requestId);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Подписка на обновления в реальном времени
|
||||
subscribeToMetric(metric, callback, interval = 5000) {
|
||||
subscribeToMetric(metric, callback, interval = 5000, filters = {}) {
|
||||
this.connectWebSocket();
|
||||
|
||||
if (!this.subscriptions.has(metric)) {
|
||||
this.subscriptions.set(metric, []);
|
||||
this.socket.emit('subscribe-metric', { metric, interval });
|
||||
}
|
||||
const alreadySubscribed = this.subscriptions.has(metric);
|
||||
const callbacks = this.subscriptions.get(metric) || [];
|
||||
callbacks.push(callback);
|
||||
this.subscriptions.set(metric, callbacks);
|
||||
|
||||
this.subscriptions.get(metric).push(callback);
|
||||
if (!alreadySubscribed) {
|
||||
this.socket.emit('subscribe-metric', { metric, interval, filters });
|
||||
}
|
||||
|
||||
return () => this.unsubscribeFromMetric(metric, callback);
|
||||
}
|
||||
|
||||
// Отписка от метрики
|
||||
unsubscribeFromMetric(metric, callback) {
|
||||
const callbacks = this.subscriptions.get(metric) || [];
|
||||
const filtered = callbacks.filter(cb => cb !== callback);
|
||||
|
|
@ -108,12 +112,20 @@ class MetricsService {
|
|||
}
|
||||
}
|
||||
|
||||
// Закрытие соединения
|
||||
cleanupAll() {
|
||||
if (this.socket && this.socket.connected) {
|
||||
this.socket.emit('unsubscribe-all');
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
|
||||
disconnectWebSocket() {
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL);
|
||||
export const metricsService = new MetricsService(import.meta.env.VITE_BACK_URL);
|
||||
|
|
|
|||
|
|
@ -2,43 +2,79 @@ import React, { useState, useEffect } from 'react';
|
|||
import LineChartComponent from './Components/LineChartComponent';
|
||||
import DateRangeSelector from './Components/DateRangeSelector';
|
||||
import { metricsService } from './Components/metricsService';
|
||||
import { Button, Radio, message } from 'antd';
|
||||
import { Button, Radio, message, Tag } from 'antd';
|
||||
import moment from 'moment';
|
||||
|
||||
const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
||||
const PrometheusChart = ({ metricInfo, chartHeight = 560 }) => {
|
||||
const {
|
||||
name: metricName,
|
||||
filters = {},
|
||||
title = metricName,
|
||||
description,
|
||||
context = {} // Добавляем контекст из path
|
||||
} = metricInfo || {};
|
||||
|
||||
console.log("⚙️ PrometheusChart -> metricInfo:", metricInfo);
|
||||
console.log("📌 Контекст -> device:", context.device, "source_id:", context.source_id, "deviceId:", context.deviceId);
|
||||
|
||||
// Получаем полный контекст из родительских элементов
|
||||
const { device, source_id: module, deviceId, parent } = context;
|
||||
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [metricInfo, setMetricInfo] = useState({});
|
||||
const [metricMeta, setMetricMeta] = useState({});
|
||||
const [mode, setMode] = useState('realtime');
|
||||
const [startDate, setStartDate] = useState(moment().subtract(1, 'hour').toDate());
|
||||
const [endDate, setEndDate] = useState(moment().toDate());
|
||||
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
||||
|
||||
const fetchHistoricalData = async (start, end) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const startUnix = Math.floor(new Date(start).getTime() / 1000);
|
||||
const endUnix = Math.floor(new Date(end).getTime() / 1000);
|
||||
|
||||
const data = await metricsService.fetchMetricsRange(metricName, startUnix, endUnix, 15);
|
||||
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
const formattedData = dataArray.map(item => ({
|
||||
// Генерация уникального ключа для подписки
|
||||
const getSubscriptionKey = () => {
|
||||
return `${metricName}_${device || 'all'}_${module || 'all'}_${deviceId || 'all'}`;
|
||||
};
|
||||
|
||||
const formatMetricData = (dataArray) => {
|
||||
return dataArray
|
||||
.map(item => ({
|
||||
timestamp: item.timestamp,
|
||||
value: parseFloat(item.value),
|
||||
name: item.__name__ || metricName,
|
||||
status: item.status
|
||||
}));
|
||||
status: item.status,
|
||||
device: item.device?.trim() || null,
|
||||
source_id: item.source_id || null
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
};
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
setMetricInfo({
|
||||
type: dataArray[0].type,
|
||||
description: dataArray[0].description,
|
||||
instance: dataArray[0].instance,
|
||||
job: dataArray[0].job
|
||||
const fetchHistoricalData = async (start, end) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const extendedFilters = {
|
||||
...filters,
|
||||
...(device && { device: device.toString() }), // убедитесь, что device строка
|
||||
...(source_id && { source_id: source_id.toString() })
|
||||
};
|
||||
|
||||
console.log('Fetching with filters:', extendedFilters); // для отладки
|
||||
|
||||
const data = await metricsService.fetchMetricsRange(
|
||||
metricName,
|
||||
Math.floor(start.getTime() / 1000),
|
||||
Math.floor(end.getTime() / 1000),
|
||||
15,
|
||||
extendedFilters
|
||||
);
|
||||
|
||||
const formattedData = formatMetricData(data);
|
||||
if (formattedData.length > 0) {
|
||||
setMetricMeta({
|
||||
type: data[0]?.type,
|
||||
description: data[0]?.description || description,
|
||||
instance: data[0]?.instance,
|
||||
job: data[0]?.job
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -55,33 +91,43 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
const startRealtimeUpdates = () => {
|
||||
setIsLiveUpdating(true);
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 3600 * 1000);
|
||||
fetchHistoricalData(start, end).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||
|
||||
return metricsService.subscribeToMetric(
|
||||
metricName,
|
||||
getSubscriptionKey(), // Уникальный ключ для подписки
|
||||
(newData) => {
|
||||
const newDataArray = Array.isArray(newData) ? newData : [newData];
|
||||
const formattedNewData = newDataArray.map(item => ({
|
||||
timestamp: item.timestamp,
|
||||
value: parseFloat(item.value),
|
||||
name: item.__name__ || metricName,
|
||||
status: item.status
|
||||
}));
|
||||
const filteredData = newData.filter(item => {
|
||||
// Строгая проверка всех доступных фильтров
|
||||
if (device && item.device?.trim() !== device) return false;
|
||||
if (module && item.source_id !== module) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
setChartData(prevData => [...prevData, ...formattedNewData].slice(-200));
|
||||
if (filteredData.length > 0) {
|
||||
const formattedData = formatMetricData(filteredData);
|
||||
setChartData(prev => {
|
||||
const newChartData = [...prev, ...formattedData]
|
||||
.filter((v, i, a) => a.findIndex(t => t.timestamp === v.timestamp) === i)
|
||||
.slice(-200);
|
||||
return newChartData;
|
||||
});
|
||||
}
|
||||
},
|
||||
5000
|
||||
5000,
|
||||
{
|
||||
...filters,
|
||||
...(device && { device }),
|
||||
...(module && { source_id: module })
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const stopRealtimeUpdates = () => {
|
||||
setIsLiveUpdating(false);
|
||||
metricsService.unsubscribeFromMetric(metricName);
|
||||
metricsService.unsubscribeFromMetric(getSubscriptionKey());
|
||||
};
|
||||
|
||||
const handleCustomRangeApply = () => {
|
||||
|
|
@ -92,7 +138,6 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
|
||||
useEffect(() => {
|
||||
let unsubscribe;
|
||||
|
||||
if (mode === 'realtime') {
|
||||
unsubscribe = startRealtimeUpdates();
|
||||
} else {
|
||||
|
|
@ -104,19 +149,30 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
if (unsubscribe) unsubscribe();
|
||||
stopRealtimeUpdates();
|
||||
};
|
||||
}, [mode, metricName]);
|
||||
}, [mode, metricName, device, module]);
|
||||
|
||||
// Рекурсивно собираем путь для отображения
|
||||
const getFullPath = () => {
|
||||
const path = [];
|
||||
let current = parent;
|
||||
while (current) {
|
||||
path.unshift(current.title);
|
||||
current = current.parent;
|
||||
}
|
||||
return path.join(' > ');
|
||||
};
|
||||
|
||||
const metaInfo = [
|
||||
metricInfo.instance && `Instance: ${metricInfo.instance}`,
|
||||
metricInfo.job && `Job: ${metricInfo.job}`,
|
||||
metricInfo.type && `Type: ${metricInfo.type}`
|
||||
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
||||
metricMeta.job && `Job: ${metricMeta.job}`,
|
||||
metricMeta.type && `Type: ${metricMeta.type}`
|
||||
].filter(Boolean).join(' | ');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
style={{ marginBottom: 10 }}
|
||||
|
|
@ -124,7 +180,7 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
<Radio.Button value="realtime">Режим реального времени</Radio.Button>
|
||||
<Radio.Button value="historical">Исторические данные</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
|
||||
{mode === 'historical' && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
|
|
@ -134,10 +190,10 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
onApply={handleCustomRangeApply}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{mode === 'realtime' && isLiveUpdating && (
|
||||
<Button
|
||||
type="primary"
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => setMode('historical')}
|
||||
style={{ marginTop: 10 }}
|
||||
|
|
@ -146,20 +202,33 @@ const PrometheusChart = ({ metricName, chartHeight = 560 }) => {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Отображаем полный путь к метрике */}
|
||||
{parent && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color="blue">Путь: {getFullPath()}</Tag>
|
||||
{device && <Tag color="geekblue">Устройство: {device}</Tag>}
|
||||
{module && <Tag color="purple">Модуль: {module.split('$')[1]}</Tag>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div>Loading chart data...</div>
|
||||
<div>Загрузка графика...</div>
|
||||
) : error ? (
|
||||
<div>Error loading metric: {error}</div>
|
||||
<div>Ошибка: {error}</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div>No data available for {metricName}</div>
|
||||
<div>Нет данных для метрики: {metricName}</div>
|
||||
) : (
|
||||
<LineChartComponent
|
||||
data={chartData}
|
||||
title={metricName}
|
||||
description={metricInfo.description}
|
||||
title={title}
|
||||
description={description}
|
||||
metaInfo={metaInfo}
|
||||
height={chartHeight}
|
||||
additionalFilters={{
|
||||
device,
|
||||
module
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Box, styled } from "@mui/material";
|
||||
import SidebarMenu from "./SidebarMenu";
|
||||
import { statusManager1, statusManager2 } from "../TreeChart/dataUtils";
|
||||
import generateTabContent from "../TreeChart/tabContent";
|
||||
import CustomTabs from "../UI/MUItabs";
|
||||
|
|
@ -8,6 +7,7 @@ import useTabs from "../hooks/useTabs";
|
|||
import useSidebarResize from "../hooks/useSidebarResize";
|
||||
import TabContent from "../hooks/TabContent";
|
||||
import menuData from "../TreeChart/menuData.json";
|
||||
import SidebarMenuWrapper from "./SidebarMenuWrapper";
|
||||
|
||||
// Создаем стилизованные компоненты
|
||||
const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||
|
|
@ -19,24 +19,6 @@ const DashboardContainer = styled(Box)(({ theme }) => ({
|
|||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const SidebarWrapper = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
backgroundColor: theme.palette.custom.sidebar,
|
||||
color: theme.palette.custom.sidebarText,
|
||||
}));
|
||||
|
||||
const SidebarResizer = styled(Box)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
cursor: 'col-resize',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const MainContent = styled(Box)(({ theme }) => ({
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
|
|
@ -56,11 +38,9 @@ const Content = styled(Box)(({ theme }) => ({
|
|||
|
||||
const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
|
||||
const { tabs, activeTab, handleOpenTab, handleCloseTab, setActiveTab } = useTabs("Главная");
|
||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
||||
const [tabContent, setTabContent] = useState({});
|
||||
const [treeData1, setTreeData1] = useState(menuData);
|
||||
const [treeData2, setTreeData2] = useState(menuData);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [statusHistories, setStatusHistories] = useState({
|
||||
history1: [],
|
||||
history2: [],
|
||||
|
|
@ -99,22 +79,53 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
|
|||
return () => clearInterval(interval);
|
||||
}, [treeData1, treeData2]);
|
||||
|
||||
const handleMenuSelect = (item) => {
|
||||
const tabId = `tab_${item.id}`;
|
||||
const tabTitle = item.title || 'Новая вкладка';
|
||||
|
||||
const generateTabContentForItem = (item) => {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="h6">{item.title}</Typography>
|
||||
{item.description && (
|
||||
<Typography color="textSecondary">{item.description}</Typography>
|
||||
)}
|
||||
{item.metric && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle1">Метрика: {item.metric}</Typography>
|
||||
{/* Здесь можно добавить визуализацию метрики */}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Проверяем, существует ли уже такая вкладка
|
||||
const existingTab = tabs.find(tab => tab.id === tabId);
|
||||
|
||||
if (!existingTab) {
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
title: tabTitle,
|
||||
content: generateTabContentForItem(item),
|
||||
type: 'menuItem',
|
||||
itemData: item // Сохраняем данные элемента для возможного обновления
|
||||
};
|
||||
|
||||
handleOpenTab(newTab);
|
||||
} else {
|
||||
setActiveTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
{/* Сайдбар */}
|
||||
<SidebarWrapper sx={{ width: collapsed ? 64 : sidebarWidth }}>
|
||||
<SidebarMenu
|
||||
data={treeData1}
|
||||
onOpenTab={handleOpenTab}
|
||||
sidebarWidth={sidebarWidth}
|
||||
startResizing={startResizing}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
/>
|
||||
<SidebarResizer onMouseDown={startResizing} />
|
||||
</SidebarWrapper>
|
||||
{/* Сайдбар - теперь используется SidebarMenuWrapper */}
|
||||
<SidebarMenuWrapper
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
onSelectItem={handleMenuSelect}
|
||||
/>
|
||||
|
||||
{/* Основной контент */}
|
||||
<Box sx={{
|
||||
|
|
@ -123,7 +134,7 @@ const Dashboard = ({ isDarkMode, setIsDarkMode }) => {
|
|||
flexGrow: 1,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Вкладки*/}
|
||||
{/* Вкладки */}
|
||||
<Box sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import FullLogo from '../../assets/images/logo.svg?react';
|
||||
import MiniLogo from '../../assets/images/system_monitor_icon.svg?react';
|
||||
|
||||
// SidebarMenu.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
|
|
@ -9,63 +7,52 @@ import {
|
|||
styled,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Box
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu as MenuIcon
|
||||
} from "@mui/icons-material";
|
||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||
import { statusManager1 } from "../TreeChart/dataUtils";
|
||||
import tabContent from "../TreeChart/tabContent";
|
||||
import useSidebarResize from "../hooks/useSidebarResize";
|
||||
import ChevronLeft from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||
|
||||
|
||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
||||
width: "5px",
|
||||
cursor: "ew-resize",
|
||||
backgroundColor: 'transparent',
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
transition: 'background-color 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
zIndex: 2
|
||||
}));
|
||||
|
||||
const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed, setCollapsed, isDarkMode, setIsDarkMode }) => {
|
||||
const SidebarMenu = ({
|
||||
data,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
onEditItem,
|
||||
onSelectItem,
|
||||
editModalOpen,
|
||||
editingItem,
|
||||
onCloseEditModal,
|
||||
onSaveChanges
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [menuData, setMenuData] = useState(data);
|
||||
const contentCache = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const dataCopy = JSON.parse(JSON.stringify(data));
|
||||
statusManager1.updateStatuses(dataCopy);
|
||||
setMenuData(dataCopy);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
const handleSelectItem = (id, title, children) => {
|
||||
onOpenTab(id, title);
|
||||
|
||||
contentCache.current = tabContent({ items: children }, contentCache.current);
|
||||
if (contentCache.current[id]) {
|
||||
onOpenTab(id, title, contentCache.current[id].content);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const drawerWidth = collapsed ? 64 : sidebarWidth;
|
||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
||||
width: '4px',
|
||||
cursor: 'ew-resize',
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -73,17 +60,17 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
|
|||
onMouseLeave={() => setHovered(false)}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: drawerWidth,
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
width: collapsed ? 64 : sidebarWidth,
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
|
@ -95,7 +82,7 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
|
|||
},
|
||||
}}
|
||||
>
|
||||
{/* Верхняя часть с логотипом и кнопкой */}
|
||||
{/* Заголовок и кнопка сворачивания */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -105,50 +92,20 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
|
|||
borderColor: 'divider',
|
||||
backgroundColor: 'custom.sidebar'
|
||||
}}>
|
||||
{/* Логотип - центрируется в доступном пространстве */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
transition: 'opacity 0.3s ease',
|
||||
opacity: collapsed ? 0 : 1,
|
||||
width: collapsed ? 0 : 'auto'
|
||||
}}>
|
||||
<FullLogo style={{
|
||||
height: '32px',
|
||||
width: 'auto',
|
||||
maxWidth: '100%'
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
{/* Мини-логотип (только в свернутом состоянии) */}
|
||||
{collapsed && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
}}>
|
||||
<MiniLogo style={{
|
||||
height: '24px',
|
||||
width: '24px'
|
||||
}} />
|
||||
</Box>
|
||||
{!collapsed && (
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
Меню
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Кнопка сворачивания/разворачивания */}
|
||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
||||
<IconButton
|
||||
onClick={handleToggleCollapse}
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'custom.sidebarText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'custom.sidebarHover',
|
||||
},
|
||||
ml: collapsed ? 'auto' : 0 // В свернутом состоянии кнопка центрируется
|
||||
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
||||
ml: collapsed ? 'auto' : 0
|
||||
}}
|
||||
>
|
||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||
|
|
@ -156,56 +113,94 @@ const SidebarMenu = ({ data, onOpenTab, sidebarWidth, startResizing, collapsed,
|
|||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Содержимое меню */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<List sx={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flex: '1 1 auto'
|
||||
}}>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
p: 2,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
Меню
|
||||
</Typography>
|
||||
)}
|
||||
{menuData && (
|
||||
{/* Основное содержимое меню */}
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
|
||||
{data && (
|
||||
<MenuItem
|
||||
item={menuData}
|
||||
onSelectItem={handleSelectItem}
|
||||
item={data}
|
||||
collapsed={collapsed}
|
||||
level={0}
|
||||
onEdit={onEditItem}
|
||||
onSelect={onSelectItem}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* Футер */}
|
||||
|
||||
<SidebarFooter
|
||||
collapsed={collapsed}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Ресайзер */}
|
||||
{!collapsed && (
|
||||
<SidebarResizer onMouseDown={startResizing} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<EditMenuItemDialog
|
||||
open={editModalOpen}
|
||||
item={editingItem}
|
||||
onClose={onCloseEditModal}
|
||||
onSave={onSaveChanges}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const EditMenuItemDialog = ({ open, item, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState(item || {});
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(item || {});
|
||||
}, [item]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Редактирование элемента меню</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
name="title"
|
||||
value={formData.title || ''}
|
||||
onChange={handleChange}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="ID"
|
||||
name="id"
|
||||
value={formData.id || ''}
|
||||
onChange={handleChange}
|
||||
disabled
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
{/* Дополнительные поля для редактирования */}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" color="primary">
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import React from "react";
|
||||
// MenuItem.jsx
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
List,
|
||||
styled
|
||||
styled,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem as MuiMenuItem
|
||||
} from "@mui/material";
|
||||
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||
import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material";
|
||||
import { getStatusColor } from "../../TreeChart/dataUtils";
|
||||
|
||||
|
||||
import StatusIndicator from "./StatusIndicator"
|
||||
|
||||
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||
cursor: "pointer",
|
||||
paddingLeft: theme.spacing(2 + level * 2),
|
||||
position: 'relative',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
|
|
@ -24,59 +27,64 @@ const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const StatusIndicator = styled('div')(({ theme, status }) => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
backgroundColor: status ? getStatusColor(status) : 'transparent',
|
||||
borderTopRightRadius: '4px',
|
||||
borderBottomRightRadius: '4px',
|
||||
}));
|
||||
|
||||
const IconWrapper = styled('div')(({ theme }) => ({
|
||||
cursor: "pointer",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(0.5),
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
}));
|
||||
|
||||
const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||
|
||||
const handleContextMenu = (e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(
|
||||
contextMenu === null
|
||||
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
onEdit(item);
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleOpenTab = (e) => {
|
||||
const handleItemClick = (e) => {
|
||||
e.stopPropagation();
|
||||
const allChildren = getAllChildren(item);
|
||||
onSelectItem(item.id, item.title, allChildren);
|
||||
|
||||
// Если есть обработчик выбора и элемент можно выбрать
|
||||
if (onSelectItem && (!item.items || item.items.length === 0)) {
|
||||
onSelectItem(item);
|
||||
}
|
||||
|
||||
// Если есть подэлементы - переключаем раскрытие
|
||||
if (item.items && item.items.length > 0) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledListItem
|
||||
component="div"
|
||||
onClick={hasChildren ? handleToggle : handleOpenTab}
|
||||
onClick={handleItemClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
level={level}
|
||||
sx={{
|
||||
pl: collapsed ? 2 : 2 + level * 2,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{/* Индикатор статуса */}
|
||||
{!collapsed && <StatusIndicator status={item.status} />}
|
||||
|
||||
<ListItemIcon sx={{ minWidth: collapsed ? 'auto' : 56 }}>
|
||||
<IconWrapper onClick={handleOpenTab}>
|
||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||
</IconWrapper>
|
||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||
</ListItemIcon>
|
||||
|
||||
{!collapsed && (
|
||||
|
|
@ -88,10 +96,39 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
}}
|
||||
/>
|
||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||
|
||||
{level > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick();
|
||||
}}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StyledListItem>
|
||||
|
||||
{/* Контекстное меню */}
|
||||
<Menu
|
||||
open={contextMenu !== null}
|
||||
onClose={handleCloseContextMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
contextMenu !== null
|
||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MuiMenuItem onClick={handleEditClick}>
|
||||
<Edit fontSize="small" sx={{ mr: 1 }} /> Редактировать
|
||||
</MuiMenuItem>
|
||||
</Menu>
|
||||
|
||||
{hasChildren && !collapsed && (
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
|
|
@ -100,6 +137,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
key={index}
|
||||
item={child}
|
||||
onSelectItem={onSelectItem}
|
||||
onEdit={onEdit}
|
||||
level={level + 1}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
|
@ -111,15 +149,4 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const getAllChildren = (node) => {
|
||||
let children = [];
|
||||
if (node.items && node.items.length > 0) {
|
||||
node.items.forEach((child) => {
|
||||
children.push(child);
|
||||
children = children.concat(getAllChildren(child));
|
||||
});
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
|
|
@ -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",
|
||||
"items": [
|
||||
{
|
||||
"id": "18",
|
||||
"id": "device$18",
|
||||
"title": "Graviton S2082I (device$18)",
|
||||
"items": [
|
||||
{
|
||||
"id": "4",
|
||||
"title": "OS Linux (module$4) АО",
|
||||
"id": "module$11",
|
||||
"title": "OS Linux (module$11) АО",
|
||||
"items": [
|
||||
{
|
||||
"id": "190",
|
||||
"title": "Загрузка процессора за 1 минуту"
|
||||
"id": "zvks_cpu1min",
|
||||
"title": "Загрузка процессора за 1 минуту",
|
||||
"metric": "zvks_cpu1min",
|
||||
"description": "Загрузка процессора за 1 минуту"
|
||||
},
|
||||
{
|
||||
"id": "191",
|
||||
|
|
@ -399,16 +401,18 @@
|
|||
"id": "media_server_2",
|
||||
"items": [
|
||||
{
|
||||
"id": "182",
|
||||
"id": "device$19",
|
||||
"title": "Graviton S2082I (device$19)",
|
||||
"items": [
|
||||
{
|
||||
"id": "42",
|
||||
"title": "OS Linux (module$6) АО",
|
||||
"id": "module$13",
|
||||
"title": "OS Linux (module$13) АО",
|
||||
"items": [
|
||||
{
|
||||
"id": "371",
|
||||
"title": "Загрузка процессора за 1 минуту"
|
||||
"id": "zvks_cpu1min",
|
||||
"title": "Загрузка процессора за 1 минуту",
|
||||
"metric": "zvks_cpu1min",
|
||||
"description": "Загрузка процессора за 1 минуту"
|
||||
},
|
||||
{
|
||||
"id": "372",
|
||||
|
|
|
|||
|
|
@ -1,39 +1,22 @@
|
|||
import React, { lazy, Suspense } from "react";
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Box from '@mui/material/Box';
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
const PrometheusChart = lazy(() => import('../../Charts2/PrometheusChart'));
|
||||
const PrometheusChart = lazy(() => import("../../Charts2/PrometheusChart"));
|
||||
import LazyChartBatchRenderer from "../hooks/LazyChartBatchRender";
|
||||
|
||||
const getMetricName = (id) => {
|
||||
return `zvks_apiforsnmp_measure_${id}`;
|
||||
};
|
||||
|
||||
const getAllChildIds = (node) => {
|
||||
let ids = [];
|
||||
if (node.id) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.items && node.items.length > 0) {
|
||||
node.items.forEach((child) => {
|
||||
ids = ids.concat(getAllChildIds(child));
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
// Компонент Skeleton для графика
|
||||
const ChartSkeleton = () => (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Skeleton variant="text" width="60%" height={30} />
|
||||
<Skeleton variant="rectangular" width="100%" height={300} sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Компонент Skeleton для родительского контейнера
|
||||
// Компонент Skeleton для контейнера
|
||||
const ContainerSkeleton = () => (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Skeleton variant="text" width="40%" height={40} /> {/* Заголовок */}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Skeleton variant="text" width="40%" height={40} />
|
||||
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
|
|
@ -43,62 +26,130 @@ const ContainerSkeleton = () => (
|
|||
</Box>
|
||||
);
|
||||
|
||||
const tabContent = (data) => {
|
||||
const tabContent = {};
|
||||
// Утилита для извлечения контекста из пути
|
||||
const parseContextFromPath = (node) => {
|
||||
const context = {};
|
||||
let current = node;
|
||||
|
||||
// Функция для рекурсивного обхода и сбора данных
|
||||
const generateContent = (nodes) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.items && node.items.length > 0) {
|
||||
const childrenContent = generateContent(node.items);
|
||||
|
||||
const content = (
|
||||
<div>
|
||||
<h2>{node.title}</h2>
|
||||
<Suspense fallback={<ContainerSkeleton />}>
|
||||
<LazyChartBatchRenderer charts={node.items.map((child) => tabContent[child.id].content)} />
|
||||
</Suspense>
|
||||
<p>Контент для {node.title}.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
tabContent[node.id] = {
|
||||
title: node.title,
|
||||
content: content,
|
||||
};
|
||||
} else {
|
||||
const metricName = getMetricName(node.id);
|
||||
const content = (
|
||||
<div key={node.id}>
|
||||
<h3>{node.title}</h3>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<PrometheusChart metricName={metricName} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
tabContent[node.id] = {
|
||||
title: node.title,
|
||||
content: content,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id}>{tabContent[node.id].content}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (data.items && data.items.length > 0) {
|
||||
generateContent(data.items);
|
||||
} else {
|
||||
console.warn("Данные отсутствуют или массив items пуст");
|
||||
while (current) {
|
||||
if (current.id.startsWith("device$")) {
|
||||
context.device = current.id.split("$")[1];
|
||||
context.deviceId = current.id;
|
||||
}
|
||||
if (current.id.startsWith("module$")) {
|
||||
context.module = current.id;
|
||||
context.source_id = current.id;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return tabContent;
|
||||
return context;
|
||||
};
|
||||
|
||||
// Основная функция построения контента вкладок
|
||||
const tabContent = (data, cache = {}) => {
|
||||
const tabContentMap = { ...cache };
|
||||
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
console.warn("Данные отсутствуют или массив items пуст", data);
|
||||
return tabContentMap;
|
||||
}
|
||||
|
||||
const processNode = (node, parentContext = {}) => {
|
||||
// Получаем полный контекст из всей цепочки родителей
|
||||
const pathContext = parseContextFromPath(node);
|
||||
const currentContext = { ...parentContext, ...pathContext };
|
||||
|
||||
// Генерируем уникальный ключ на основе пути
|
||||
const path = [];
|
||||
let current = node;
|
||||
while (current) {
|
||||
path.unshift(current.id);
|
||||
current = current.parent;
|
||||
}
|
||||
const pathId = path.join('_');
|
||||
|
||||
if (Array.isArray(node.items) && node.items.length > 0) {
|
||||
const children = node.items
|
||||
.map((child) => processNode(child, currentContext))
|
||||
.filter(Boolean);
|
||||
|
||||
const content = (
|
||||
<div key={`${pathId}-container`}>
|
||||
<h2>{node.title}</h2>
|
||||
<Suspense fallback={<ContainerSkeleton />}>
|
||||
<LazyChartBatchRenderer charts={children.map((c) => c.content)} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
tabContentMap[pathId] = {
|
||||
title: node.title,
|
||||
content,
|
||||
context: currentContext,
|
||||
};
|
||||
|
||||
return { content, context: currentContext };
|
||||
}
|
||||
|
||||
if (node.metric) {
|
||||
const chartKey = `${node.metric}-${currentContext.device || "all"}-${currentContext.module || "all"}-${pathId}`;
|
||||
|
||||
const content = (
|
||||
<div key={chartKey}>
|
||||
<h3>{node.title}</h3>
|
||||
{currentContext.device && <p>Устройство: {currentContext.device}</p>}
|
||||
{currentContext.module && <p>Модуль: {currentContext.module}</p>}
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<PrometheusChart
|
||||
metricInfo={{
|
||||
name: node.metric,
|
||||
filters: {
|
||||
...(currentContext.device && { device: currentContext.device }),
|
||||
...(currentContext.source_id && { source_id: currentContext.source_id }),
|
||||
},
|
||||
title: node.title,
|
||||
description: node.description,
|
||||
context: currentContext,
|
||||
}}
|
||||
key={chartKey}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
tabContentMap[pathId] = {
|
||||
title: node.title,
|
||||
content,
|
||||
context: currentContext,
|
||||
};
|
||||
|
||||
return { content, context: currentContext };
|
||||
}
|
||||
|
||||
// Узел без метрики и без вложенных — просто заголовок
|
||||
const content = (
|
||||
<div key={pathId}>
|
||||
<h3>{node.title}</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
tabContentMap[pathId] = {
|
||||
title: node.title,
|
||||
content,
|
||||
context: currentContext,
|
||||
};
|
||||
|
||||
return { content, context: currentContext };
|
||||
};
|
||||
|
||||
try {
|
||||
processNode(data);
|
||||
} catch (error) {
|
||||
console.error("Ошибка обработки данных:", error);
|
||||
}
|
||||
|
||||
return tabContentMap;
|
||||
};
|
||||
|
||||
export default tabContent;
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import React from "react";
|
||||
import { Tabs, Tab, Box, styled } from "@mui/material";
|
||||
import { Tabs, Tab, Box, styled, Typography } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
minHeight: 48,
|
||||
padding: theme.spacing(1, 2),
|
||||
textTransform: 'none',
|
||||
'&.Mui-selected': {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
|
|
@ -14,9 +16,43 @@ const StyledTab = styled(Tab)(({ theme }) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const TabLabel = ({ title, onClose }) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
minWidth: 0 // Для корректного обрезания длинного текста
|
||||
}}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{
|
||||
maxWidth: 120,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<CloseIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
ml: 1,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
color: 'error.main'
|
||||
}
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
||||
const handleMouseDown = (e, id) => {
|
||||
if (e.button === 1) {
|
||||
if (e.button === 1) { // Средняя кнопка мыши
|
||||
e.preventDefault();
|
||||
onCloseTab(id);
|
||||
}
|
||||
|
|
@ -26,6 +62,12 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
|||
onTabClick(newValue);
|
||||
};
|
||||
|
||||
// Статические вкладки
|
||||
const staticTabs = [
|
||||
{ id: "Главная", title: "Главная" },
|
||||
{ id: "Визуализация", title: "Визуализация" }
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
borderBottom: 1,
|
||||
|
|
@ -39,42 +81,31 @@ const CustomTabs = ({ tabs, activeTab, onTabClick, onCloseTab }) => {
|
|||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
aria-label="tabs"
|
||||
>
|
||||
{/* Статические вкладки */}
|
||||
<StyledTab
|
||||
label="Главная"
|
||||
value="Главная"
|
||||
onMouseDown={(e) => handleMouseDown(e, "Главная")}
|
||||
/>
|
||||
<StyledTab
|
||||
label="Визуализация"
|
||||
value="Визуализация"
|
||||
onMouseDown={(e) => handleMouseDown(e, "Визуализация")}
|
||||
/>
|
||||
{staticTabs.map(tab => (
|
||||
<StyledTab
|
||||
key={tab.id}
|
||||
label={tab.title}
|
||||
value={tab.id}
|
||||
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Динамические вкладки */}
|
||||
{tabs.map((tab) => (
|
||||
<StyledTab
|
||||
key={tab.id}
|
||||
label={
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<span>{tab.title}</span>
|
||||
<CloseIcon
|
||||
fontSize="small"
|
||||
sx={{
|
||||
ml: 1,
|
||||
cursor: "pointer",
|
||||
'&:hover': {
|
||||
color: 'error.main'
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<TabLabel
|
||||
title={tab.title}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
value={tab.id}
|
||||
onMouseDown={(e) => handleMouseDown(e, tab.id)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue