Compare commits
6 Commits
46cd1fa0fa
...
c411142840
| Author | SHA1 | Date |
|---|---|---|
|
|
c411142840 | |
|
|
06249fce3a | |
|
|
933ceb2547 | |
|
|
34f2010cae | |
|
|
205ddc71e0 | |
|
|
421d95565c |
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
|
@ -31,7 +31,10 @@
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"vite-plugin-svgr": "^4.3.0"
|
"vite-plugin-svgr": "^4.3.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,13 @@ const formatXAxis = (tickItem) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTooltip = (value, name, props) => {
|
const formatTooltip = (value, name, props) => {
|
||||||
return [`${value.toFixed(2)}`, ` ${name}`];
|
return [`${value.toFixed(2)}`, `Устройство ${name}`];
|
||||||
};
|
};
|
||||||
|
|
||||||
const LineChartComponent = ({
|
const LineChartComponent = ({
|
||||||
data = [],
|
data = [],
|
||||||
multipleLines = false,
|
multipleLines = true, // По умолчанию включаем множественные линии
|
||||||
lineKey = 'device',
|
lineKey = 'device', // Ключ для разделения линий
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
height = 400,
|
height = 400,
|
||||||
|
|
@ -30,27 +30,25 @@ const LineChartComponent = ({
|
||||||
}) => {
|
}) => {
|
||||||
if (!data || data.length === 0) return <div>Нет данных для отображения</div>;
|
if (!data || data.length === 0) return <div>Нет данных для отображения</div>;
|
||||||
|
|
||||||
// Создаем массив уникальных линий
|
// Создаем массив уникальных устройств
|
||||||
const lineKeys = [...new Set(data.map(item => item[lineKey] || 'default'))];
|
const devices = [...new Set(data.map(item => item.device))];
|
||||||
|
|
||||||
// Преобразуем данные в формат, удобный для Recharts
|
// Группируем данные по timestamp для правильного отображения
|
||||||
const chartData = data.reduce((acc, item) => {
|
const timestamps = [...new Set(data.map(item => item.timestamp))].sort();
|
||||||
const timestamp = item.timestamp;
|
|
||||||
const existingPoint = acc.find(p => p.timestamp === timestamp);
|
|
||||||
|
|
||||||
if (existingPoint) {
|
const chartData = timestamps.map(timestamp => {
|
||||||
return acc.map(p =>
|
const point = { timestamp };
|
||||||
p.timestamp === timestamp
|
|
||||||
? { ...p, [item[lineKey] || 'default']: item.value }
|
// Для каждого устройства находим значение в этот timestamp
|
||||||
: p
|
devices.forEach(device => {
|
||||||
|
const deviceData = data.find(item =>
|
||||||
|
item.timestamp === timestamp && item.device === device
|
||||||
);
|
);
|
||||||
}
|
point[`device_${device}`] = deviceData ? deviceData.value : null;
|
||||||
|
});
|
||||||
|
|
||||||
return [...acc, {
|
return point;
|
||||||
timestamp,
|
});
|
||||||
[item[lineKey] || 'default']: item.value
|
|
||||||
}];
|
|
||||||
}, []).sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: `${height}px` }}>
|
<div style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
|
@ -63,39 +61,27 @@ const LineChartComponent = ({
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={formatXAxis}
|
tickFormatter={formatXAxis}
|
||||||
/>
|
/>
|
||||||
<YAxis domain={[0, 25]} />
|
<YAxis />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={formatTooltip}
|
formatter={formatTooltip}
|
||||||
labelFormatter={(label) => format(new Date(label), 'yyyy-MM-dd HH:mm:ss')}
|
labelFormatter={(label) => format(new Date(label), 'yyyy-MM-dd HH:mm:ss')}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
|
|
||||||
{multipleLines ? (
|
{devices.map(device => (
|
||||||
lineKeys.map(key => (
|
|
||||||
<Line
|
<Line
|
||||||
key={`line-${key}`}
|
key={`line-${device}`}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={key}
|
dataKey={`device_${device}`}
|
||||||
name={` ${key}`}
|
name={`Устройство ${device}`}
|
||||||
stroke={lineColors[key] || lineColors.default}
|
stroke={lineColors[device] || lineColors.default}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
|
connectNulls={true}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
) : (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={lineKeys[0] || 'value'}
|
|
||||||
name={title}
|
|
||||||
stroke={lineColors.default}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Добавляем диапазоны если они есть */}
|
{/* Добавляем диапазоны если они есть */}
|
||||||
{ranges.map((range, idx) => (
|
{ranges.map((range, idx) => (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import LineChartComponent from './LineChartComponent';
|
import LineChartComponent from './LineChartComponent';
|
||||||
import DateRangeSelector from '../Charts2/Components/DateRangeSelector';
|
import DateRangeSelector from '../Charts2/Components/DateRangeSelector';
|
||||||
import metricsService from '../Charts2/Components/metricsService';
|
import metricsService from '../Charts2/Components/metricsService';
|
||||||
import { Button, Radio, message, Tag, Spin } from 'antd';
|
import { Button, Radio, message, Tag } from 'antd';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import StatusLogTable from '../Charts2/Components/StatusLogTable';
|
import StatusLogTable from '../Charts2/Components/StatusLogTable';
|
||||||
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
|
import { Box, IconButton, Tooltip as MuiTooltip } from '@mui/material';
|
||||||
|
|
@ -15,14 +15,12 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
title = metricName,
|
title = metricName,
|
||||||
description,
|
description,
|
||||||
context = {},
|
context = {},
|
||||||
ranges = [],
|
ranges = []
|
||||||
multipleLines = false,
|
|
||||||
lineKey = 'device'
|
|
||||||
} = metricInfo || {};
|
} = metricInfo || {};
|
||||||
|
|
||||||
const { device, source_id } = context;
|
const { device, source_id } = context;
|
||||||
|
|
||||||
const [rawData, setRawData] = 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 [metricMeta, setMetricMeta] = useState({});
|
const [metricMeta, setMetricMeta] = useState({});
|
||||||
|
|
@ -32,43 +30,96 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
const [isLiveUpdating, setIsLiveUpdating] = useState(false);
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
const [statusLogs, setStatusLogs] = useState([]);
|
const [statusLogs, setStatusLogs] = useState([]);
|
||||||
|
const MAX_POINTS = 50;
|
||||||
const MAX_POINTS = 1000;
|
|
||||||
const TIME_WINDOW_MS = 3600 * 1000;
|
const TIME_WINDOW_MS = 3600 * 1000;
|
||||||
|
|
||||||
const subscriptionKey = useMemo(() => {
|
|
||||||
|
// Эта функция может больше не понадобиться, так как
|
||||||
|
// сервис сам генерирует ключи, но оставьте для совместимости
|
||||||
|
const getSubscriptionKey = () => {
|
||||||
const filterParts = [];
|
const filterParts = [];
|
||||||
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
||||||
if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
|
if (source_id) filterParts.push(`source_id=${encodeURIComponent(source_id)}`);
|
||||||
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
||||||
}, [metricName, device, source_id]);
|
};
|
||||||
|
|
||||||
const formatMetricData = (responseData) => {
|
const getStatusFromRanges = (value, ranges) => {
|
||||||
const dataArray = Array.isArray(responseData) ? responseData : responseData.data;
|
if (!ranges || ranges.length === 0) return 1;
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (value >= r.min && value <= r.max) {
|
||||||
|
return r.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMetricData = (dataArray) => {
|
||||||
if (!Array.isArray(dataArray)) {
|
if (!Array.isArray(dataArray)) {
|
||||||
console.error('Expected array but got:', responseData);
|
console.error('Expected array in formatMetricData, got:', typeof dataArray);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataArray.map(item => ({
|
return dataArray.map(item => {
|
||||||
|
if (item.timestamp === undefined || item.value === undefined) {
|
||||||
|
console.warn('Invalid metric item:', item);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...item,
|
...item,
|
||||||
timestamp: item.timestamp,
|
timestamp: Number(item.timestamp),
|
||||||
value: parseFloat(item.value),
|
value: parseFloat(item.value),
|
||||||
|
status: getStatusFromRanges(parseFloat(item.value), ranges),
|
||||||
name: item.__name__ || metricName,
|
name: item.__name__ || metricName,
|
||||||
status: parseInt(item.status) || 0,
|
|
||||||
device: item.device?.trim() || null,
|
device: item.device?.trim() || null,
|
||||||
source_id: item.source_id || null,
|
source_id: item.source_id || null,
|
||||||
description: item.description || description,
|
description: item.description || description
|
||||||
lineId: item[lineKey] || 'default'
|
};
|
||||||
}));
|
}).filter(Boolean)
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateStep = (start, end) => {
|
const calculateStep = (startTime, endTime, maxPoints = 10000) => {
|
||||||
const duration = end.getTime() - start.getTime();
|
const durationSeconds = (endTime.getTime() - startTime.getTime()) / 1000;
|
||||||
return Math.max(Math.floor(duration / (MAX_POINTS * 1000)), 1);
|
return Math.max(Math.ceil(durationSeconds / maxPoints), 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downsampleData = (data, maxPoints = MAX_POINTS) => {
|
||||||
|
if (data.length <= maxPoints) return [...data];
|
||||||
|
|
||||||
|
const sortedData = [...data].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
const step = Math.max(1, Math.floor(sortedData.length / maxPoints));
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < sortedData.length; i += step) {
|
||||||
|
if (result.length >= maxPoints) break;
|
||||||
|
result.push(sortedData[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
const lastOriginalPoint = sortedData[sortedData.length - 1];
|
||||||
|
if (result[result.length - 1].timestamp !== lastOriginalPoint.timestamp) {
|
||||||
|
result[result.length - 1] = lastOriginalPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartData.length > 0) {
|
||||||
|
const newLogs = chartData.reduce((acc, point, index) => {
|
||||||
|
if (index === 0 || point.status !== chartData[index - 1].status) {
|
||||||
|
return [...acc, point];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
setStatusLogs(newLogs);
|
||||||
|
}
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
|
||||||
const fetchHistoricalData = async (start, end) => {
|
const fetchHistoricalData = async (start, end) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -82,19 +133,23 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
|
|
||||||
const step = calculateStep(start, end);
|
const step = calculateStep(start, end);
|
||||||
|
|
||||||
|
// Используем новый метод для исторических данных
|
||||||
const data = await metricsService.fetchMetricsRange(
|
const data = await metricsService.fetchMetricsRange(
|
||||||
metricName,
|
metricName,
|
||||||
Math.floor(start.getTime() / 1000),
|
start.getTime(), // Теперь передаем timestamp в миллисекундах
|
||||||
Math.floor(end.getTime() / 1000),
|
end.getTime(),
|
||||||
step,
|
step,
|
||||||
extendedFilters
|
extendedFilters
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseData = Array.isArray(data) ? data : data.data;
|
const formattedData = formatMetricData(data)
|
||||||
const formattedData = formatMetricData(responseData);
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
setRawData(formattedData);
|
|
||||||
|
|
||||||
if (formattedData.length > 0) {
|
const limitedData = formattedData.length > MAX_POINTS
|
||||||
|
? downsampleData(formattedData, MAX_POINTS)
|
||||||
|
: formattedData;
|
||||||
|
|
||||||
|
if (limitedData.length > 0) {
|
||||||
setMetricMeta({
|
setMetricMeta({
|
||||||
type: data[0]?.type,
|
type: data[0]?.type,
|
||||||
description: data[0]?.description || description,
|
description: data[0]?.description || description,
|
||||||
|
|
@ -102,6 +157,8 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
job: data[0]?.job
|
job: data[0]?.job
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setChartData(limitedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error loading historical data for ${metricName}:`, err);
|
console.error(`Error loading historical data for ${metricName}:`, err);
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -117,42 +174,55 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date(end.getTime() - TIME_WINDOW_MS);
|
const start = new Date(end.getTime() - TIME_WINDOW_MS);
|
||||||
const cutoffTime = Date.now() - TIME_WINDOW_MS;
|
|
||||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||||
|
|
||||||
|
// Изменяем параметры подписки
|
||||||
return metricsService.subscribeToMetric(
|
return metricsService.subscribeToMetric(
|
||||||
subscriptionKey,
|
metricName, // Теперь передаем просто имя метрики
|
||||||
(newData) => {
|
{ ...filters, device, source_id }, // Фильры отдельным параметром
|
||||||
setRawData(prev => {
|
(update) => { // Колбэк получает объект с данными
|
||||||
const actualData = Array.isArray(newData) ? newData : newData.data;
|
console.log('Received WS update:', update);
|
||||||
const formattedNewData = formatMetricData(actualData)
|
|
||||||
|
if (!update || !Array.isArray(update.data)) {
|
||||||
|
console.error('Invalid update format:', update);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChartData(prev => {
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoffTime = now - TIME_WINDOW_MS;
|
||||||
|
|
||||||
|
const formattedNew = formatMetricData(update.data)
|
||||||
.filter(point => point.timestamp >= cutoffTime);
|
.filter(point => point.timestamp >= cutoffTime);
|
||||||
|
|
||||||
const filteredPrev = prev.filter(point => point.timestamp >= cutoffTime);
|
const filteredPrev = prev.filter(point =>
|
||||||
|
point.timestamp >= cutoffTime
|
||||||
const merged = [...filteredPrev, ...formattedNewData]
|
|
||||||
.filter((v, i, a) =>
|
|
||||||
a.findIndex(t =>
|
|
||||||
t.timestamp === v.timestamp &&
|
|
||||||
t[lineKey] === v[lineKey]
|
|
||||||
) === i
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return merged;
|
const merged = [...filteredPrev, ...formattedNew]
|
||||||
|
.filter((v, i, a) =>
|
||||||
|
a.findIndex(t => t.timestamp === v.timestamp) === i
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
return merged.length > MAX_POINTS
|
||||||
|
? merged.slice(-MAX_POINTS)
|
||||||
|
: merged;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
5000,
|
5000 // Интервал обновления (можно настроить)
|
||||||
{
|
|
||||||
...filters,
|
|
||||||
...(device && { device }),
|
|
||||||
...(source_id && { source_id })
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const stopRealtimeUpdates = () => {
|
const stopRealtimeUpdates = () => {
|
||||||
setIsLiveUpdating(false);
|
setIsLiveUpdating(false);
|
||||||
metricsService.unsubscribeFromMetric(subscriptionKey);
|
// Теперь отписываемся по метрике и фильтрам
|
||||||
|
metricsService.unsubscribeFromMetric(
|
||||||
|
metricName,
|
||||||
|
{ ...filters, device, source_id }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomRangeApply = () => {
|
const handleCustomRangeApply = () => {
|
||||||
|
|
@ -162,45 +232,29 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rawData.length > 0) {
|
console.log('Metric changed:', { metricName, device, source_id, filters });
|
||||||
const logs = [];
|
|
||||||
const devices = [...new Set(rawData.map(item => item[lineKey]))];
|
|
||||||
|
|
||||||
devices.forEach(dev => {
|
|
||||||
const deviceData = rawData
|
|
||||||
.filter(item => item[lineKey] === dev)
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
if (deviceData.length > 0) {
|
|
||||||
logs.push(deviceData[0]); // Первая точка
|
|
||||||
|
|
||||||
for (let i = 1; i < deviceData.length; i++) {
|
|
||||||
if (deviceData[i].status !== deviceData[i - 1].status) {
|
|
||||||
logs.push(deviceData[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setStatusLogs(logs.sort((a, b) => b.timestamp - a.timestamp));
|
|
||||||
}
|
|
||||||
}, [rawData, lineKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unsubscribe;
|
let unsubscribe;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
if (mode === 'realtime') {
|
if (mode === 'realtime') {
|
||||||
unsubscribe = startRealtimeUpdates();
|
unsubscribe = startRealtimeUpdates();
|
||||||
} else {
|
} else {
|
||||||
stopRealtimeUpdates();
|
await fetchHistoricalData(startDate, endDate);
|
||||||
fetchHistoricalData(startDate, endDate);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unsubscribe) unsubscribe();
|
if (unsubscribe) {
|
||||||
stopRealtimeUpdates();
|
unsubscribe(); // Вызываем функцию отписки
|
||||||
|
}
|
||||||
|
if (mode === 'realtime') {
|
||||||
|
stopRealtimeUpdates(); // Дополнительная очистка
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [mode, metricName, device, source_id]);
|
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
|
||||||
|
|
||||||
const metaInfo = [
|
const metaInfo = [
|
||||||
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
||||||
|
|
@ -209,7 +263,7 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
].filter(Boolean).join(' | ');
|
].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={mode}
|
value={mode}
|
||||||
|
|
@ -231,10 +285,15 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'realtime' && (
|
{mode === 'realtime' && isLiveUpdating && (
|
||||||
<Tag color={isLiveUpdating ? 'green' : 'red'}>
|
<Button
|
||||||
{isLiveUpdating ? 'Обновление в реальном времени' : 'Режим реального времени остановлен'}
|
type="primary"
|
||||||
</Tag>
|
danger
|
||||||
|
onClick={() => setMode('historical')}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
>
|
||||||
|
Остановить обновление
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -259,26 +318,28 @@ const SystemChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
</MuiTooltip>
|
</MuiTooltip>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ height: chartHeight, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
<div>Загрузка графика...</div>
|
||||||
<Spin size="large" tip="Загрузка данных..." />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div style={{ color: 'red', padding: 20 }}>Ошибка: {error}</div>
|
<div>Ошибка: {error}</div>
|
||||||
) : rawData.length === 0 ? (
|
) : chartData.length === 0 ? (
|
||||||
<div style={{ padding: 20 }}>Нет данных для метрики: {metricName}</div>
|
<div>Нет данных для метрики: {metricName}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LineChartComponent
|
<LineChartComponent
|
||||||
data={rawData}
|
data={chartData}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
multipleLines={multipleLines}
|
|
||||||
lineKey={lineKey}
|
|
||||||
metaInfo={metaInfo}
|
metaInfo={metaInfo}
|
||||||
height={chartHeight}
|
height={chartHeight}
|
||||||
|
additionalFilters={{
|
||||||
|
device,
|
||||||
|
source_id
|
||||||
|
}}
|
||||||
ranges={ranges}
|
ranges={ranges}
|
||||||
/>
|
/>
|
||||||
{showLogs && <StatusLogTable logs={statusLogs} />}
|
{showLogs && (
|
||||||
|
<StatusLogTable logs={statusLogs} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,28 @@ class MetricsService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = '/metrics-ws';
|
this.baseUrl = '/metrics-ws';
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.subscriptions = new Map();
|
this.subscriptions = new Map(); // Хранит подписки на real-time данные
|
||||||
this.pendingRequests = new Map();
|
this.pendingRequests = new Map(); // Для разовых запросов
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = 5;
|
||||||
this.reconnectDelay = 5000;
|
this.reconnectDelay = 5000;
|
||||||
|
this.connectionCallbacks = new Set(); // Колбэки для событий подключения
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => this.cleanupAll());
|
window.addEventListener('beforeunload', () => this.cleanupAll());
|
||||||
window.addEventListener('pagehide', () => this.cleanupAll());
|
window.addEventListener('pagehide', () => this.cleanupAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новый метод для отслеживания состояния подключения
|
||||||
|
onConnectionChange(callback) {
|
||||||
|
this.connectionCallbacks.add(callback);
|
||||||
|
return () => this.connectionCallbacks.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уведомление всех подписчиков о изменении состояния
|
||||||
|
notifyConnectionChange(connected) {
|
||||||
|
this.connectionCallbacks.forEach(cb => cb(connected));
|
||||||
|
}
|
||||||
|
|
||||||
handleServerMessage(msg) {
|
handleServerMessage(msg) {
|
||||||
try {
|
try {
|
||||||
if (!msg || typeof msg !== 'object') {
|
if (!msg || typeof msg !== 'object') {
|
||||||
|
|
@ -22,25 +34,25 @@ class MetricsService {
|
||||||
const { event, data, requestId } = msg;
|
const { event, data, requestId } = msg;
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'metrics-data':
|
case 'connected':
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
console.log('Server connection confirmed:', data);
|
||||||
const { resolve } = this.pendingRequests.get(requestId);
|
this.notifyConnectionChange(true);
|
||||||
resolve(data);
|
|
||||||
this.pendingRequests.delete(requestId);
|
|
||||||
} else {
|
|
||||||
const metricKey = data.metric;
|
|
||||||
const callbacks = this.subscriptions.get(metricKey) || [];
|
|
||||||
callbacks.forEach(cb => cb(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'metrics-error':
|
case 'realtime-data':
|
||||||
if (requestId && this.pendingRequests.has(requestId)) {
|
this.handleRealtimeData(data, requestId);
|
||||||
const { reject } = this.pendingRequests.get(requestId);
|
break;
|
||||||
reject(new Error(data.error));
|
|
||||||
this.pendingRequests.delete(requestId);
|
case 'historical-data':
|
||||||
}
|
this.handleHistoricalData(data, requestId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'current-data':
|
||||||
|
this.handleCurrentData(data, requestId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
this.handleError(data, requestId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -51,6 +63,54 @@ class MetricsService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRealtimeData(data, requestId) {
|
||||||
|
const { metric, filters, data: metricsData, type } = data;
|
||||||
|
const metricKey = this.getMetricKey(metric, filters);
|
||||||
|
|
||||||
|
if (requestId && this.pendingRequests.has(requestId)) {
|
||||||
|
// Это ответ на разовый запрос
|
||||||
|
const { resolve } = this.pendingRequests.get(requestId);
|
||||||
|
resolve(metricsData);
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
} else {
|
||||||
|
// Это обновление по подписке
|
||||||
|
const callbacks = this.subscriptions.get(metricKey) || [];
|
||||||
|
callbacks.forEach(cb => cb({
|
||||||
|
data: metricsData,
|
||||||
|
type: type || 'update',
|
||||||
|
metric,
|
||||||
|
filters,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHistoricalData(data, requestId) {
|
||||||
|
if (requestId && this.pendingRequests.has(requestId)) {
|
||||||
|
const { resolve } = this.pendingRequests.get(requestId);
|
||||||
|
resolve(data.data || data);
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCurrentData(data, requestId) {
|
||||||
|
if (requestId && this.pendingRequests.has(requestId)) {
|
||||||
|
const { resolve } = this.pendingRequests.get(requestId);
|
||||||
|
resolve(data.data || data);
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError(data, requestId) {
|
||||||
|
if (requestId && this.pendingRequests.has(requestId)) {
|
||||||
|
const { reject } = this.pendingRequests.get(requestId);
|
||||||
|
reject(new Error(data.error || 'Unknown error'));
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
} else {
|
||||||
|
console.error('Server error:', data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
connectWebSocket() {
|
connectWebSocket() {
|
||||||
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -58,25 +118,27 @@ class MetricsService {
|
||||||
|
|
||||||
console.log('Connecting WebSocket...');
|
console.log('Connecting WebSocket...');
|
||||||
this.socket = new WebSocket(this.baseUrl);
|
this.socket = new WebSocket(this.baseUrl);
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
|
|
||||||
this.socket.addEventListener('open', () => {
|
this.socket.addEventListener('open', () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.subscriptions.forEach((_, metricKey) => {
|
this.notifyConnectionChange(true);
|
||||||
const filters = this.parseFiltersFromKey(metricKey);
|
|
||||||
const [metric] = metricKey.split('?');
|
// Переподписываемся на все активные подписки
|
||||||
this.sendMessage('subscribe-metric', { metric, filters });
|
this.resubscribeAll();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.addEventListener('close', () => {
|
this.socket.addEventListener('close', (event) => {
|
||||||
console.log('WebSocket disconnected');
|
console.log('WebSocket disconnected', event.code, event.reason);
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.addEventListener('error', (err) => {
|
this.socket.addEventListener('error', (err) => {
|
||||||
console.error('WebSocket error:', err);
|
console.error('WebSocket error:', err);
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.addEventListener('message', (event) => {
|
this.socket.addEventListener('message', (event) => {
|
||||||
|
|
@ -89,6 +151,18 @@ class MetricsService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Переподписка на все активные подписки после переподключения
|
||||||
|
resubscribeAll() {
|
||||||
|
this.subscriptions.forEach((_, metricKey) => {
|
||||||
|
const { metric, filters } = this.parseMetricKey(metricKey);
|
||||||
|
this.sendMessage('subscribe-realtime', {
|
||||||
|
metric,
|
||||||
|
filters,
|
||||||
|
interval: 10000 // Дефолтный интервал
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
scheduleReconnect() {
|
scheduleReconnect() {
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
console.warn('Max reconnect attempts reached');
|
console.warn('Max reconnect attempts reached');
|
||||||
|
|
@ -104,12 +178,13 @@ class MetricsService {
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(event, data) {
|
sendMessage(event, data, requestId) {
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
if (this.socket && this.socket.readyState === WebSocket.CONNECTING) {
|
if (this.socket && this.socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
// Ждем открытия соединения
|
||||||
const waitForOpen = () => {
|
const waitForOpen = () => {
|
||||||
if (this.socket.readyState === WebSocket.OPEN) {
|
if (this.socket.readyState === WebSocket.OPEN) {
|
||||||
this.socket.send(JSON.stringify({ event, data }));
|
this.doSendMessage(event, data, requestId);
|
||||||
} else if (this.socket.readyState === WebSocket.CONNECTING) {
|
} else if (this.socket.readyState === WebSocket.CONNECTING) {
|
||||||
setTimeout(waitForOpen, 100);
|
setTimeout(waitForOpen, 100);
|
||||||
}
|
}
|
||||||
|
|
@ -118,29 +193,77 @@ class MetricsService {
|
||||||
} else {
|
} else {
|
||||||
console.warn('WebSocket not connected, cannot send:', event);
|
console.warn('WebSocket not connected, cannot send:', event);
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
|
// Сохраняем сообщение для отправки после подключения
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
|
this.doSendMessage(event, data, requestId);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.send(JSON.stringify({ event, data }));
|
this.doSendMessage(event, data, requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMetricsRange(metric, start, end, step = 15, filters = {}) {
|
doSendMessage(event, data, requestId) {
|
||||||
|
const message = requestId ? { event, data, requestId } : { event, data };
|
||||||
|
this.socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ПУБЛИЧНЫЕ МЕТОДЫ ============
|
||||||
|
|
||||||
|
// Подписка на real-time данные
|
||||||
|
subscribeToMetric(metric, filters = {}, callback, interval = 10000) {
|
||||||
|
this.connectWebSocket();
|
||||||
|
|
||||||
|
const metricKey = this.getMetricKey(metric, filters);
|
||||||
|
|
||||||
|
if (!this.subscriptions.has(metricKey)) {
|
||||||
|
this.subscriptions.set(metricKey, []);
|
||||||
|
|
||||||
|
this.sendMessage('subscribe-realtime', {
|
||||||
|
metric,
|
||||||
|
filters,
|
||||||
|
interval
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbacks = this.subscriptions.get(metricKey);
|
||||||
|
callbacks.push(callback);
|
||||||
|
|
||||||
|
// Возвращаем функцию для отписки
|
||||||
|
return () => this.unsubscribeFromMetric(metric, filters, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отписка от real-time данных
|
||||||
|
unsubscribeFromMetric(metric, filters = {}, callback) {
|
||||||
|
const metricKey = this.getMetricKey(metric, filters);
|
||||||
|
const callbacks = this.subscriptions.get(metricKey) || [];
|
||||||
|
const filtered = callbacks.filter(cb => cb !== callback);
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
this.subscriptions.delete(metricKey);
|
||||||
|
this.sendMessage('unsubscribe-realtime', { metric, filters });
|
||||||
|
} else {
|
||||||
|
this.subscriptions.set(metricKey, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос исторических данных (разовый)
|
||||||
|
async fetchMetricsRange(metric, start, end, step = 60, filters = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
const requestId = `range-${Date.now()}`;
|
const requestId = `historical-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
// Таймаут для очистки
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
reject(new Error('Request timeout'));
|
reject(new Error('Historical data request timeout'));
|
||||||
this.pendingRequests.delete(requestId);
|
this.pendingRequests.delete(requestId);
|
||||||
}, 12000);
|
}, 30000); // 30 секунд таймаут для historical данных
|
||||||
|
|
||||||
this.pendingRequests.set(requestId, {
|
this.pendingRequests.set(requestId, {
|
||||||
resolve: (responseData) => {
|
resolve: (data) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const data = Array.isArray(responseData) ? responseData :
|
|
||||||
(responseData?.data || []);
|
|
||||||
resolve(data);
|
resolve(data);
|
||||||
},
|
},
|
||||||
reject: (err) => {
|
reject: (err) => {
|
||||||
|
|
@ -149,64 +272,109 @@ class MetricsService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sendMessage('get-metrics', {
|
this.sendMessage('get-historical', {
|
||||||
metric, start, end, step, filters, isRangeQuery: true, requestId
|
metric,
|
||||||
});
|
start: Math.floor(start / 1000) * 1000, // Ensure milliseconds
|
||||||
|
end: Math.floor(end / 1000) * 1000,
|
||||||
|
step,
|
||||||
|
filters
|
||||||
|
}, requestId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeToMetric(metricKey, callback, interval = 5000, filters = {}) {
|
// Запрос текущих данных (разовый)
|
||||||
|
async fetchCurrentMetrics(metric, filters = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
|
const requestId = `current-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
if (!this.subscriptions.has(metricKey)) {
|
const timeout = setTimeout(() => {
|
||||||
this.subscriptions.set(metricKey, []);
|
reject(new Error('Current data request timeout'));
|
||||||
const [metric] = metricKey.split('?');
|
this.pendingRequests.delete(requestId);
|
||||||
this.sendMessage('subscribe-metric', { metric, interval, filters });
|
}, 10000); // 10 секунд таймаут
|
||||||
|
|
||||||
|
this.pendingRequests.set(requestId, {
|
||||||
|
resolve: (data) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(data);
|
||||||
|
},
|
||||||
|
reject: (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sendMessage('get-current', {
|
||||||
|
metric,
|
||||||
|
filters
|
||||||
|
}, requestId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const callbacks = this.subscriptions.get(metricKey);
|
// Отписка от всех подписок
|
||||||
callbacks.push(callback);
|
unsubscribeAll() {
|
||||||
|
this.sendMessage('unsubscribe-all', {});
|
||||||
return () => this.unsubscribeFromMetric(metricKey, callback);
|
this.subscriptions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribeFromMetric(metricKey, callback) {
|
// ============ ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ============
|
||||||
const callbacks = this.subscriptions.get(metricKey) || [];
|
|
||||||
const filtered = callbacks.filter(cb => cb !== callback);
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
getMetricKey(metric, filters) {
|
||||||
this.subscriptions.delete(metricKey);
|
const sortedKeys = Object.keys(filters).sort();
|
||||||
const [metric] = metricKey.split('?');
|
const filterString = sortedKeys
|
||||||
this.sendMessage('unsubscribe-metric', { metric });
|
.map(key => `${key}=${encodeURIComponent(filters[key])}`)
|
||||||
} else {
|
.join('&');
|
||||||
this.subscriptions.set(metricKey, filtered);
|
|
||||||
}
|
return filterString ? `${metric}?${filterString}` : metric;
|
||||||
}
|
}
|
||||||
|
|
||||||
parseFiltersFromKey(metricKey) {
|
parseMetricKey(metricKey) {
|
||||||
const parts = metricKey.split('?');
|
const [metric, query] = metricKey.split('?');
|
||||||
if (parts.length < 2) return {};
|
const filters = {};
|
||||||
return parts[1].split('&').reduce((acc, pair) => {
|
|
||||||
|
if (query) {
|
||||||
|
query.split('&').forEach(pair => {
|
||||||
const [key, value] = pair.split('=');
|
const [key, value] = pair.split('=');
|
||||||
if (key && value) acc[key] = value;
|
if (key && value) {
|
||||||
return acc;
|
filters[decodeURIComponent(key)] = decodeURIComponent(value);
|
||||||
}, {});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metric, filters };
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupAll() {
|
cleanupAll() {
|
||||||
this.sendMessage('unsubscribe-all', {});
|
this.unsubscribeAll();
|
||||||
this.subscriptions.clear();
|
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectWebSocket() {
|
disconnectWebSocket() {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close();
|
this.socket.close(1000, 'Client disconnected');
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
this.notifyConnectionChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка состояния подключения
|
||||||
|
isConnected() {
|
||||||
|
return this.socket?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение текущего состояния
|
||||||
|
getConnectionState() {
|
||||||
|
return this.socket ? this.socket.readyState : WebSocket.CLOSED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Создаем глобальный экземпляр
|
||||||
const metricsService = new MetricsService();
|
const metricsService = new MetricsService();
|
||||||
|
|
||||||
|
// Экспорт для использования в модульной системе
|
||||||
export default metricsService;
|
export default metricsService;
|
||||||
|
|
||||||
|
// Глобальный экспорт для прямого использования в браузере
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.MetricsService = metricsService;
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,8 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
const TIME_WINDOW_MS = 3600 * 1000;
|
const TIME_WINDOW_MS = 3600 * 1000;
|
||||||
|
|
||||||
|
|
||||||
|
// Эта функция может больше не понадобиться, так как
|
||||||
|
// сервис сам генерирует ключи, но оставьте для совместимости
|
||||||
const getSubscriptionKey = () => {
|
const getSubscriptionKey = () => {
|
||||||
const filterParts = [];
|
const filterParts = [];
|
||||||
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
if (device) filterParts.push(`device=${encodeURIComponent(device)}`);
|
||||||
|
|
@ -41,6 +43,16 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
return `${metricName}${filterParts.length ? `?${filterParts.join('&')}` : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusFromRanges = (value, ranges) => {
|
||||||
|
if (!ranges || ranges.length === 0) return 1;
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (value >= r.min && value <= r.max) {
|
||||||
|
return r.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
const formatMetricData = (dataArray) => {
|
const formatMetricData = (dataArray) => {
|
||||||
if (!Array.isArray(dataArray)) {
|
if (!Array.isArray(dataArray)) {
|
||||||
console.error('Expected array in formatMetricData, got:', typeof dataArray);
|
console.error('Expected array in formatMetricData, got:', typeof dataArray);
|
||||||
|
|
@ -57,7 +69,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
...item,
|
...item,
|
||||||
timestamp: Number(item.timestamp),
|
timestamp: Number(item.timestamp),
|
||||||
value: parseFloat(item.value),
|
value: parseFloat(item.value),
|
||||||
status: parseInt(item.status || '0'),
|
status: getStatusFromRanges(parseFloat(item.value), ranges),
|
||||||
name: item.__name__ || metricName,
|
name: item.__name__ || metricName,
|
||||||
device: item.device?.trim() || null,
|
device: item.device?.trim() || null,
|
||||||
source_id: item.source_id || null,
|
source_id: item.source_id || null,
|
||||||
|
|
@ -120,10 +132,12 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const step = calculateStep(start, end);
|
const step = calculateStep(start, end);
|
||||||
|
|
||||||
|
// Используем новый метод для исторических данных
|
||||||
const data = await metricsService.fetchMetricsRange(
|
const data = await metricsService.fetchMetricsRange(
|
||||||
metricName,
|
metricName,
|
||||||
Math.floor(start.getTime() / 1000),
|
start.getTime(), // Теперь передаем timestamp в миллисекундах
|
||||||
Math.floor(end.getTime() / 1000),
|
end.getTime(),
|
||||||
step,
|
step,
|
||||||
extendedFilters
|
extendedFilters
|
||||||
);
|
);
|
||||||
|
|
@ -132,7 +146,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
const limitedData = formattedData.length > MAX_POINTS
|
const limitedData = formattedData.length > MAX_POINTS
|
||||||
? formattedData.slice(-MAX_POINTS)
|
? downsampleData(formattedData, MAX_POINTS)
|
||||||
: formattedData;
|
: formattedData;
|
||||||
|
|
||||||
if (limitedData.length > 0) {
|
if (limitedData.length > 0) {
|
||||||
|
|
@ -163,12 +177,15 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
|
|
||||||
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
fetchHistoricalData(start, end).finally(() => setIsLoading(false));
|
||||||
|
|
||||||
|
// Изменяем параметры подписки
|
||||||
return metricsService.subscribeToMetric(
|
return metricsService.subscribeToMetric(
|
||||||
getSubscriptionKey(),
|
metricName, // Теперь передаем просто имя метрики
|
||||||
(newData) => {
|
{ ...filters, device, source_id }, // Фильры отдельным параметром
|
||||||
console.log('Received WS update:', newData);
|
(update) => { // Колбэк получает объект с данными
|
||||||
if (!Array.isArray(newData)) {
|
console.log('Received WS update:', update);
|
||||||
console.error('Expected array in WS update, got:', typeof newData);
|
|
||||||
|
if (!update || !Array.isArray(update.data)) {
|
||||||
|
console.error('Invalid update format:', update);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +193,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cutoffTime = now - TIME_WINDOW_MS;
|
const cutoffTime = now - TIME_WINDOW_MS;
|
||||||
|
|
||||||
const formattedNew = formatMetricData(newData)
|
const formattedNew = formatMetricData(update.data)
|
||||||
.filter(point => point.timestamp >= cutoffTime);
|
.filter(point => point.timestamp >= cutoffTime);
|
||||||
|
|
||||||
const filteredPrev = prev.filter(point =>
|
const filteredPrev = prev.filter(point =>
|
||||||
|
|
@ -194,15 +211,18 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
: merged;
|
: merged;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
1000,
|
5000 // Интервал обновления (можно настроить)
|
||||||
{ ...filters, device, source_id }
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const stopRealtimeUpdates = () => {
|
const stopRealtimeUpdates = () => {
|
||||||
setIsLiveUpdating(false);
|
setIsLiveUpdating(false);
|
||||||
metricsService.unsubscribeFromMetric(getSubscriptionKey());
|
// Теперь отписываемся по метрике и фильтрам
|
||||||
|
metricsService.unsubscribeFromMetric(
|
||||||
|
metricName,
|
||||||
|
{ ...filters, device, source_id }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomRangeApply = () => {
|
const handleCustomRangeApply = () => {
|
||||||
|
|
@ -215,6 +235,7 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
console.log('Metric changed:', { metricName, device, source_id, filters });
|
console.log('Metric changed:', { metricName, device, source_id, filters });
|
||||||
|
|
||||||
let unsubscribe;
|
let unsubscribe;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (mode === 'realtime') {
|
if (mode === 'realtime') {
|
||||||
unsubscribe = startRealtimeUpdates();
|
unsubscribe = startRealtimeUpdates();
|
||||||
|
|
@ -226,10 +247,14 @@ const PrometheusChart = ({ metricInfo, chartHeight = 580 }) => {
|
||||||
init();
|
init();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unsubscribe) unsubscribe();
|
if (unsubscribe) {
|
||||||
stopRealtimeUpdates();
|
unsubscribe(); // Вызываем функцию отписки
|
||||||
|
}
|
||||||
|
if (mode === 'realtime') {
|
||||||
|
stopRealtimeUpdates(); // Дополнительная очистка
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [mode, metricName, device, source_id, filters]);
|
}, [mode, metricName, device, source_id, JSON.stringify(filters)]); // Добавляем JSON.stringify для фильтров
|
||||||
|
|
||||||
const metaInfo = [
|
const metaInfo = [
|
||||||
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
metricMeta.instance && `Instance: ${metricMeta.instance}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
ExpandLess as ExpandLessIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const MenuItemComponent = ({ item, level = 0, onEdit, onDelete }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasChildren = item.items && item.items.length > 0;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (hasChildren) {
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
sx={{
|
||||||
|
pl: level * 4,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body1">{item.title}</Typography>
|
||||||
|
{item.isDynamic && (
|
||||||
|
<Chip
|
||||||
|
label="Динамический"
|
||||||
|
size="small"
|
||||||
|
color="info"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={item.id}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
{/* */}
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label="edit"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label="delete"
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
{hasChildren && (
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
aria-label="expand"
|
||||||
|
onClick={handleToggle}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
{hasChildren && (
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<List component="div" disablePadding>
|
||||||
|
{item.items.map((child) => (
|
||||||
|
<MenuItemComponent
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
level={level + 1}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditDialog = ({ open, item, onClose, onSave }) => {
|
||||||
|
const [title, setTitle] = useState(item?.title || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(item?.title || '');
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(item.id, { title });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Редактировать элемент меню</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Название"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button onClick={handleSave} variant="contained">
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuEditor = ({ onSave }) => {
|
||||||
|
const [menuData, setMenuData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMenuData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMenuData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await axios.get('/api/menu/full');
|
||||||
|
setMenuData(response.data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Ошибка загрузки меню');
|
||||||
|
console.error('Error fetching menu:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (item) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (item) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (id, updates) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/menu/${id}`, updates);
|
||||||
|
setHasChanges(true);
|
||||||
|
fetchMenuData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating menu item:', err);
|
||||||
|
alert('Ошибка при сохранении изменений');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/menu/items/${selectedItem.id}`);
|
||||||
|
setHasChanges(true);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
fetchMenuData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting menu item:', err);
|
||||||
|
alert('Ошибка при удалении элемента');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
onSave({
|
||||||
|
hasChanges: true, saveChanges: async () => {
|
||||||
|
// Принудительно обновляем кэш
|
||||||
|
try {
|
||||||
|
await axios.post('/api/menu/invalidate-cache');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error invalidating cache:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography color="error">{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Редактирование меню
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Вы можете редактировать названия и удалять элементы меню. Динамические элементы (помечены синим) нельзя редактировать.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{menuData.items.map((item) => (
|
||||||
|
<MenuItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<EditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
item={selectedItem}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSave={handleEditSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Подтверждение удаления</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Вы уверены, что хотите удалить элемент "{selectedItem?.title}"?
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteDialogOpen(false)}>Отмена</Button>
|
||||||
|
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Применить изменения
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MenuEditor;
|
||||||
|
|
@ -21,6 +21,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
import MetricRangeEditor from './SettingsComponents/MetricRangeEditor';
|
import MetricRangeEditor from './SettingsComponents/MetricRangeEditor';
|
||||||
import UserManagement from './SettingsComponents/UserManagement';
|
import UserManagement from './SettingsComponents/UserManagement';
|
||||||
|
import MenuEditor from './SettingsComponents/MenuEditor'
|
||||||
|
|
||||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||||
return <Slide direction="up" ref={ref} {...props} />;
|
return <Slide direction="up" ref={ref} {...props} />;
|
||||||
|
|
@ -64,6 +65,10 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
hasChanges: false,
|
hasChanges: false,
|
||||||
save: () => { }
|
save: () => { }
|
||||||
});
|
});
|
||||||
|
const [menuEditorState, setMenuEditorState] = useState({
|
||||||
|
hasChanges: false,
|
||||||
|
save: () => Promise.resolve(true)
|
||||||
|
});
|
||||||
|
|
||||||
const handleTabChange = (event, newValue) => {
|
const handleTabChange = (event, newValue) => {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
|
|
@ -73,12 +78,22 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMenuEditorChange = ({ hasChanges, saveChanges }) => {
|
||||||
|
setMenuEditorState({ hasChanges, save: saveChanges });
|
||||||
|
setHasChanges(hasChanges);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
let success = true;
|
let success = true;
|
||||||
|
|
||||||
|
if (tabValue === 0 && menuEditorState.hasChanges) {
|
||||||
|
success = await menuEditorState.save();
|
||||||
|
}
|
||||||
|
|
||||||
if (tabValue === 1 && metricEditorState.hasChanges) {
|
if (tabValue === 1 && metricEditorState.hasChanges) {
|
||||||
success = await metricEditorState.save();
|
success = success && await metricEditorState.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
@ -113,7 +128,6 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Пример обработчика изменений
|
|
||||||
const handleSettingChange = () => {
|
const handleSettingChange = () => {
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
};
|
};
|
||||||
|
|
@ -149,14 +163,13 @@ const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
<Tab label="Меню" id="settings-tab-0" aria-controls="settings-tabpanel-0" />
|
<Tab label="Меню" id="settings-tab-0" aria-controls="settings-tabpanel-0" />
|
||||||
<Tab label="Границы метрик" id="settings-tab-1" aria-controls="settings-tabpanel-1" />
|
<Tab label="Границы метрик" id="settings-tab-1" aria-controls="settings-tabpanel-1" />
|
||||||
<Tab label="Управление пользователями" id="settings-tab-2" aria-controls="settings-tabpanel-2" />
|
<Tab label="Управление пользователями" id="settings-tab-2" aria-controls="settings-tabpanel-2" />
|
||||||
{/* Добавляйте новые вкладки здесь */}
|
{/* Добавить новые вкладки здесь */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<TabPanel value={tabValue} index={0}>
|
<TabPanel value={tabValue} index={0}>
|
||||||
<Typography variant="h6">Настройки меню</Typography>
|
<MenuEditor onSave={handleMenuEditorChange} />
|
||||||
{/* Добавьте содержимое для вкладки меню */}
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={1}>
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// SidebarMenu.jsx
|
import { useState, useEffect } from "react";
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
|
|
@ -7,14 +6,30 @@ import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box,
|
||||||
|
alpha
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
import useSidebarResize from "../hooks/useSidebarResize";
|
import useSidebarResize from "../hooks/useSidebarResize";
|
||||||
import ChevronLeft from '@mui/icons-material/ChevronLeft';
|
import ChevronLeft from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
import ChevronRight from "@mui/icons-material/ChevronRight";
|
||||||
import LogoFull from '../../assets/images/logo.svg?react';
|
import LogoFull from "../../assets/images/logo.svg?react";
|
||||||
import LogoSmall from '../../assets/images/system_monitor_icon.svg?react';
|
import LogoSmall from "../../assets/images/system_monitor_icon.svg?react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
MeasuringStrategy
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
import SortableMenuItem from "./SidebarMenuComponents/SortableMenuItem";
|
||||||
|
|
||||||
const SidebarMenu = ({
|
const SidebarMenu = ({
|
||||||
data,
|
data,
|
||||||
|
|
@ -22,107 +37,385 @@ const SidebarMenu = ({
|
||||||
setIsDarkMode,
|
setIsDarkMode,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
forceRefreshMenu,
|
forceRefreshMenu,
|
||||||
user
|
user,
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
const { sidebarWidth, startResizing } = useSidebarResize(320); // Увеличил минимальную ширину
|
||||||
const [hovered, setHovered] = useState(false);
|
const [menuItems, setMenuItems] = useState(data.items || []);
|
||||||
|
const [activeItem, setActiveItem] = useState(null);
|
||||||
|
const [hoveredItem, setHoveredItem] = useState(null);
|
||||||
|
const [dropIndicator, setDropIndicator] = useState({ show: false, position: null, targetId: null });
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = localStorage.getItem("menuTree");
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
setMenuItems(JSON.parse(cached));
|
||||||
|
} catch {
|
||||||
|
setMenuItems(data.items || []);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMenuItems(data.items || []);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
const handleToggleCollapse = () => {
|
||||||
setCollapsed(!collapsed);
|
setCollapsed(!collapsed);
|
||||||
|
setHoveredItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SidebarResizer = styled('div')(({ theme }) => ({
|
// Функции для работы с деревом (остаются без изменений)
|
||||||
width: '4px',
|
const findItemInTree = (items, id) => {
|
||||||
cursor: 'ew-resize',
|
for (const item of items) {
|
||||||
backgroundColor: 'transparent',
|
if (item.id === id) return item;
|
||||||
'&:hover': {
|
if (item.items) {
|
||||||
backgroundColor: theme.palette.action.hover,
|
const found = findItemInTree(item.items, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItemFromTree = (items, id) => {
|
||||||
|
return items.filter(item => {
|
||||||
|
if (item.id === id) return false;
|
||||||
|
if (item.items) {
|
||||||
|
item.items = removeItemFromTree(item.items, id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItemToFolder = (items, folderId, newItem) => {
|
||||||
|
return items.map(item => {
|
||||||
|
if (item.id === folderId) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
items: [...(item.items || []), newItem]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
items: addItemToFolder(item.items, folderId, newItem)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const findParent = (items, childId, parent = null) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === childId) return parent;
|
||||||
|
if (item.items) {
|
||||||
|
const found = findParent(item.items, childId, item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItemAtSameLevel = (items, parentId, newItem, afterId = null) => {
|
||||||
|
return items.map(item => {
|
||||||
|
if (item.id === parentId) {
|
||||||
|
const children = item.items || [];
|
||||||
|
const insertIndex = afterId ? children.findIndex(i => i.id === afterId) + 1 : children.length;
|
||||||
|
|
||||||
|
const newChildren = [
|
||||||
|
...children.slice(0, insertIndex),
|
||||||
|
newItem,
|
||||||
|
...children.slice(insertIndex)
|
||||||
|
];
|
||||||
|
|
||||||
|
return { ...item, items: newChildren };
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
return { ...item, items: addItemAtSameLevel(item.items, parentId, newItem, afterId) };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (event) => {
|
||||||
|
const { active } = event;
|
||||||
|
const item = findItemInTree(menuItems, active.id);
|
||||||
|
setActiveItem(item);
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
};
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveItem(null);
|
||||||
|
setHoveredItem(null);
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
if (active.id === over.id) return;
|
||||||
|
|
||||||
|
const draggedItem = findItemInTree(menuItems, active.id);
|
||||||
|
if (!draggedItem) return;
|
||||||
|
|
||||||
|
const overItem = findItemInTree(menuItems, over.id);
|
||||||
|
|
||||||
|
// Проверяем, не пытаемся ли переместить элемент в его же потомка
|
||||||
|
if (isDescendant(draggedItem, overItem)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newTree;
|
||||||
|
|
||||||
|
if (dropIndicator.position === 'inside' && overItem && Array.isArray(overItem.items)) {
|
||||||
|
// Вставка внутрь папки
|
||||||
|
newTree = removeItemFromTree([...menuItems], active.id);
|
||||||
|
newTree = addItemToFolder(newTree, over.id, draggedItem);
|
||||||
|
} else {
|
||||||
|
// Вставка на том же уровне
|
||||||
|
const overParent = findParent(menuItems, over.id);
|
||||||
|
if (!overParent) return;
|
||||||
|
|
||||||
|
newTree = removeItemFromTree([...menuItems], active.id);
|
||||||
|
|
||||||
|
// Определяем позицию для вставки
|
||||||
|
let insertAfterId = null;
|
||||||
|
if (dropIndicator.position === 'below') {
|
||||||
|
insertAfterId = over.id;
|
||||||
|
} else if (dropIndicator.position === 'above') {
|
||||||
|
const siblings = overParent.items || [];
|
||||||
|
const overIndex = siblings.findIndex(item => item.id === over.id);
|
||||||
|
if (overIndex > 0) {
|
||||||
|
insertAfterId = siblings[overIndex - 1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newTree = addItemAtSameLevel(newTree, overParent.id, draggedItem, insertAfterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuItems(newTree);
|
||||||
|
localStorage.setItem("menuTree", JSON.stringify(newTree));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overItem = findItemInTree(menuItems, over.id);
|
||||||
|
const activeItem = findItemInTree(menuItems, active.id);
|
||||||
|
|
||||||
|
if (!overItem || !activeItem || active.id === over.id) {
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, можно ли перемещать элемент
|
||||||
|
if (isDescendant(activeItem, overItem)) {
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overRect = over.rect.current;
|
||||||
|
if (!overRect) return;
|
||||||
|
|
||||||
|
const relativeY = event.delta.y;
|
||||||
|
const isOverFolder = overItem && Array.isArray(overItem.items);
|
||||||
|
const isTopHalf = relativeY < overRect.height * 0.4;
|
||||||
|
const isBottomHalf = relativeY > overRect.height * 0.6;
|
||||||
|
|
||||||
|
if (isOverFolder && !isTopHalf && !isBottomHalf) {
|
||||||
|
// Показываем индикатор для вставки в папку
|
||||||
|
setDropIndicator({
|
||||||
|
show: true,
|
||||||
|
position: 'inside',
|
||||||
|
targetId: over.id
|
||||||
|
});
|
||||||
|
setHoveredItem(over.id);
|
||||||
|
} else if (isTopHalf) {
|
||||||
|
// Показываем индикатор для вставки выше
|
||||||
|
setDropIndicator({
|
||||||
|
show: true,
|
||||||
|
position: 'above',
|
||||||
|
targetId: over.id
|
||||||
|
});
|
||||||
|
setHoveredItem(null);
|
||||||
|
} else if (isBottomHalf) {
|
||||||
|
// Показываем индикатор для вставки ниже
|
||||||
|
setDropIndicator({
|
||||||
|
show: true,
|
||||||
|
position: 'below',
|
||||||
|
targetId: over.id
|
||||||
|
});
|
||||||
|
setHoveredItem(null);
|
||||||
|
} else {
|
||||||
|
setDropIndicator({ show: false, position: null, targetId: null });
|
||||||
|
setHoveredItem(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDescendant = (parent, child) => {
|
||||||
|
if (!parent || !child || !parent.items) return false;
|
||||||
|
|
||||||
|
const checkChildren = (items, targetId) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === targetId) return true;
|
||||||
|
if (item.items && checkChildren(item.items, targetId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return checkChildren(parent.items, child.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarResizer = styled("div")(({ theme }) => ({
|
||||||
|
width: "3px",
|
||||||
|
cursor: "col-resize",
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.3),
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
},
|
},
|
||||||
height: '100%',
|
height: "100%",
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const DropIndicator = ({ position, targetId }) => {
|
||||||
|
if (!targetId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'absolute',
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
left: 0,
|
||||||
transition: 'width 0.3s ease',
|
right: 0,
|
||||||
|
height: '2px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
zIndex: 1001,
|
||||||
|
...(position === 'above' && { top: 0 }),
|
||||||
|
...(position === 'below' && { bottom: 0 }),
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-3px',
|
||||||
|
left: '10%',
|
||||||
|
width: '80%',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
borderRadius: '2px',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
|
transition: "width 0.2s ease",
|
||||||
|
height: "100vh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
"& .MuiDrawer-paper": {
|
||||||
width: collapsed ? 64 : sidebarWidth,
|
width: collapsed ? 72 : sidebarWidth,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: 'custom.sidebar',
|
backgroundColor: "background.paper",
|
||||||
color: 'custom.sidebarText',
|
color: "text.primary",
|
||||||
transition: 'width 0.3s ease',
|
transition: "width 0.2s ease, background-color 0.2s ease",
|
||||||
overflowX: 'hidden',
|
overflowX: "hidden",
|
||||||
borderRight: 'none'
|
borderRight: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
boxShadow: "0 2px 12px rgba(0, 0, 0, 0.08)",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Заголовок с логотипом */}
|
{/* Заголовок с логотипом */}
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
justifyContent: 'center', // Центрируем содержимое
|
alignItems: "center",
|
||||||
p: 1,
|
justifyContent: "center",
|
||||||
borderBottom: '1px solid',
|
p: 2,
|
||||||
borderColor: 'divider',
|
borderBottom: "1px solid",
|
||||||
backgroundColor: 'custom.sidebar',
|
borderColor: "divider",
|
||||||
height: 80, // Фиксированная высота
|
backgroundColor: "background.paper",
|
||||||
position: 'relative' // Для позиционирования кнопки
|
height: 80,
|
||||||
}}>
|
position: "relative",
|
||||||
{/* Логотип (занимает все пространство) */}
|
transition: "all 0.2s ease",
|
||||||
<Box sx={{
|
minHeight: 80,
|
||||||
display: 'flex',
|
}}
|
||||||
alignItems: 'center',
|
>
|
||||||
justifyContent: 'center',
|
<Box
|
||||||
width: '100%',
|
sx={{
|
||||||
height: '100%',
|
display: "flex",
|
||||||
'& svg': {
|
alignItems: "center",
|
||||||
width: '100%',
|
justifyContent: "center",
|
||||||
height: '100%',
|
width: "100%",
|
||||||
padding: collapsed ? '8px' : '12px',
|
height: "100%",
|
||||||
objectFit: 'contain'
|
transition: "all 0.2s ease",
|
||||||
}
|
"& svg": {
|
||||||
}}>
|
width: "auto",
|
||||||
|
height: "40px", // Фиксированная высота для лого
|
||||||
|
objectFit: "contain",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<LogoSmall style={{
|
<LogoSmall style={{
|
||||||
color: 'inherit' // Наследует цвет темы
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px"
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<LogoFull style={{
|
<LogoFull style={{
|
||||||
color: 'inherit' // Наследует цвет темы
|
color: "inherit",
|
||||||
|
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.1))",
|
||||||
|
maxWidth: "180px",
|
||||||
|
height: "40px"
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Кнопка сворачивания (абсолютное позиционирование) */}
|
<Tooltip
|
||||||
<Tooltip title={collapsed ? "Развернуть меню" : "Свернуть меню"}>
|
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleToggleCollapse}
|
onClick={handleToggleCollapse}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'custom.sidebarText',
|
color: "text.secondary",
|
||||||
'&:hover': { backgroundColor: 'custom.sidebarHover' },
|
"&:hover": {
|
||||||
position: 'absolute',
|
backgroundColor: "action.hover",
|
||||||
right: 8,
|
color: "text.primary"
|
||||||
top: '50%',
|
},
|
||||||
transform: 'translateY(-50%)'
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
{collapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||||
|
|
@ -131,18 +424,97 @@ const SidebarMenu = ({
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Основное содержимое меню */}
|
{/* Основное содержимое меню */}
|
||||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Box
|
||||||
<List sx={{ overflowY: 'auto', overflowX: 'hidden', flex: '1 1 auto' }}>
|
sx={{
|
||||||
{data && (
|
flexGrow: 1,
|
||||||
<MenuItem
|
display: "flex",
|
||||||
item={data}
|
flexDirection: "column",
|
||||||
collapsed={collapsed}
|
overflow: "hidden",
|
||||||
level={0}
|
position: "relative",
|
||||||
|
}}
|
||||||
onSelectItem={onSelectItem}
|
>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
measuring={{
|
||||||
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={menuItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
sx={{
|
||||||
|
overflowY: "auto",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
py: 1,
|
||||||
|
px: 1,
|
||||||
|
position: 'relative',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '6px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'text.disabled',
|
||||||
|
borderRadius: '3px',
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
|
background: 'text.secondary',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<Box key={item.id} position="relative">
|
||||||
|
{dropIndicator.show && dropIndicator.targetId === item.id &&
|
||||||
|
dropIndicator.position !== 'inside' && (
|
||||||
|
<DropIndicator
|
||||||
|
position={dropIndicator.position}
|
||||||
|
targetId={dropIndicator.targetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SortableMenuItem
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
isHovered={hoveredItem === item.id}
|
||||||
|
showDropIndicator={dropIndicator.show && dropIndicator.targetId === item.id && dropIndicator.position === 'inside'}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeItem ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
|
||||||
|
maxWidth: 250,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
transform: 'rotate(5deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.title}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
|
|
@ -152,8 +524,11 @@ const SidebarMenu = ({
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
<Tooltip title="Изменить ширину" placement="top">
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,121 @@
|
||||||
// MenuItem.jsx
|
// // MenuItem.jsx
|
||||||
import React, { useState } from "react";
|
// import React, { useState } from "react";
|
||||||
import {
|
// import {
|
||||||
ListItem,
|
// ListItem,
|
||||||
ListItemIcon,
|
// ListItemIcon,
|
||||||
ListItemText,
|
// ListItemText,
|
||||||
Collapse,
|
// Collapse,
|
||||||
List,
|
// List,
|
||||||
styled,
|
// styled,
|
||||||
Menu,
|
// Menu,
|
||||||
MenuItem as MuiMenuItem
|
// MenuItem as MuiMenuItem
|
||||||
} from "@mui/material";
|
// } from "@mui/material";
|
||||||
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
// import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
import StatusIndicator from "./StatusIndicator";
|
// import StatusIndicator from "./StatusIndicator";
|
||||||
|
|
||||||
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
// const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
cursor: "pointer",
|
// cursor: "pointer",
|
||||||
paddingLeft: theme.spacing(2 + level * 2),
|
// paddingLeft: theme.spacing(2 + level * 2),
|
||||||
position: 'relative',
|
// position: 'relative',
|
||||||
'&:hover': {
|
// '&:hover': {
|
||||||
backgroundColor: theme.palette.action.hover,
|
// backgroundColor: theme.palette.action.hover,
|
||||||
},
|
// },
|
||||||
'&.Mui-selected': {
|
// '&.Mui-selected': {
|
||||||
backgroundColor: theme.palette.custom.sidebarHover,
|
// backgroundColor: theme.palette.custom.sidebarHover,
|
||||||
},
|
// },
|
||||||
}));
|
// }));
|
||||||
|
|
||||||
const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
// const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
// const [isOpen, setIsOpen] = useState(false);
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
// const [contextMenu, setContextMenu] = useState(null);
|
||||||
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
// const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
|
|
||||||
const handleContextMenu = (e) => {
|
// const handleContextMenu = (e) => {
|
||||||
e.preventDefault();
|
// e.preventDefault();
|
||||||
setContextMenu(
|
// setContextMenu(
|
||||||
contextMenu === null
|
// contextMenu === null
|
||||||
? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
// ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 }
|
||||||
: null
|
// : null
|
||||||
);
|
// );
|
||||||
};
|
// };
|
||||||
|
|
||||||
const handleCloseContextMenu = () => {
|
// const handleCloseContextMenu = () => {
|
||||||
setContextMenu(null);
|
// setContextMenu(null);
|
||||||
};
|
// };
|
||||||
|
|
||||||
const handleToggle = (e) => {
|
// const handleToggle = (e) => {
|
||||||
e.stopPropagation();
|
// e.stopPropagation();
|
||||||
setIsOpen(!isOpen);
|
// setIsOpen(!isOpen);
|
||||||
};
|
// };
|
||||||
|
|
||||||
const handleClick = () => {
|
// const handleClick = () => {
|
||||||
if (onSelectItem) {
|
// if (onSelectItem) {
|
||||||
onSelectItem(item);
|
// onSelectItem(item);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<>
|
// <>
|
||||||
<StyledListItem
|
// <StyledListItem
|
||||||
component="div"
|
// component="div"
|
||||||
onClick={hasChildren ? handleToggle : handleClick}
|
// onClick={hasChildren ? handleToggle : handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
// 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 }}>
|
||||||
{hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
// {hasChildren ? (isOpen ? <FolderOpen /> : <Folder />) : <Folder />}
|
||||||
</ListItemIcon>
|
// </ListItemIcon>
|
||||||
|
|
||||||
{!collapsed && (
|
// {!collapsed && (
|
||||||
<>
|
// <>
|
||||||
<ListItemText
|
// <ListItemText
|
||||||
primary={item.title}
|
// primary={item.title}
|
||||||
primaryTypographyProps={{
|
// primaryTypographyProps={{
|
||||||
color: 'custom.sidebarText'
|
// color: 'custom.sidebarText'
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
// {hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
</>
|
// </>
|
||||||
)}
|
// )}
|
||||||
</StyledListItem>
|
// </StyledListItem>
|
||||||
|
|
||||||
<Menu
|
// <Menu
|
||||||
open={contextMenu !== null}
|
// open={contextMenu !== null}
|
||||||
onClose={handleCloseContextMenu}
|
// onClose={handleCloseContextMenu}
|
||||||
anchorReference="anchorPosition"
|
// anchorReference="anchorPosition"
|
||||||
anchorPosition={
|
// anchorPosition={
|
||||||
contextMenu !== null
|
// contextMenu !== null
|
||||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
// ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||||
: undefined
|
// : undefined
|
||||||
}
|
// }
|
||||||
>
|
// >
|
||||||
|
|
||||||
</Menu>
|
// </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>
|
||||||
{item.items.map((child, index) => (
|
// {item.items.map((child, index) => (
|
||||||
<MenuItem
|
// <MenuItem
|
||||||
key={child.id ?? index}
|
// key={child.id ?? index}
|
||||||
item={child}
|
// item={child}
|
||||||
onSelectItem={onSelectItem}
|
// onSelectItem={onSelectItem}
|
||||||
onEdit={onEdit}
|
// onEdit={onEdit}
|
||||||
level={level + 1}
|
// level={level + 1}
|
||||||
collapsed={collapsed}
|
// collapsed={collapsed}
|
||||||
/>
|
// />
|
||||||
))}
|
// ))}
|
||||||
</List>
|
// </List>
|
||||||
</Collapse>
|
// </Collapse>
|
||||||
)}
|
// )}
|
||||||
</>
|
// </>
|
||||||
);
|
// );
|
||||||
};
|
// };
|
||||||
|
|
||||||
export default MenuItem;
|
// export default MenuItem;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Brightness4, Brightness7 } from "@mui/icons-material";
|
import { Brightness4, Brightness7, Settings, Help } from "@mui/icons-material";
|
||||||
import { IconButton, Tooltip } from "@mui/material";
|
import {
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
alpha
|
||||||
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
styled,
|
styled,
|
||||||
Switch,
|
Switch,
|
||||||
Box,
|
|
||||||
Button
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import SettingsModal from "../SettingsModal";
|
import SettingsModal from "../SettingsModal";
|
||||||
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
import { RoleBasedRender } from "../../UI/RoleBasedRender";
|
||||||
|
|
||||||
const FooterList = styled(List)(({ theme }) => ({
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.sidebar,
|
backgroundColor: 'background.paper',
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
borderTop: `1px solid ${theme.palette.divider}`,
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
marginTop: 'auto'
|
marginTop: 'auto'
|
||||||
|
|
@ -22,12 +26,15 @@ const FooterList = styled(List)(({ theme }) => ({
|
||||||
|
|
||||||
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: theme.palette.custom.sidebarHover,
|
backgroundColor: alpha(theme.palette.action.hover, 0.4),
|
||||||
},
|
},
|
||||||
padding: theme.spacing(1, 2),
|
padding: theme.spacing(1, 2),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 8px 4px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarFooter = ({
|
const SidebarFooter = ({
|
||||||
|
|
@ -46,72 +53,93 @@ const SidebarFooter = ({
|
||||||
const handleSettingsClose = () => {
|
const handleSettingsClose = () => {
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
};
|
};
|
||||||
/*console.log('SidebarFooter user with role:', {
|
|
||||||
...user,
|
|
||||||
hasRole: 'role' in user,
|
|
||||||
roleValue: user?.role
|
|
||||||
}); */
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FooterList>
|
<FooterList>
|
||||||
{!collapsed && (
|
{!collapsed ? (
|
||||||
<FooterListItem button>
|
<>
|
||||||
<ListItemText
|
|
||||||
primary="Помощь"
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText',
|
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FooterListItem>
|
|
||||||
)}
|
|
||||||
<FooterListItem>
|
<FooterListItem>
|
||||||
{/* кнопка настроек */}
|
|
||||||
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
|
||||||
{!collapsed && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSettingsOpen}
|
onClick={handleSettingsOpen}
|
||||||
|
startIcon={<Settings />}
|
||||||
sx={{
|
sx={{
|
||||||
color: 'custom.sidebarText',
|
color: 'text.secondary',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
minWidth: 0,
|
fontSize: '0.875rem',
|
||||||
padding: 0,
|
fontWeight: 500,
|
||||||
marginRight: 'auto'
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemText
|
Настройки
|
||||||
primary="Настройки"
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText',
|
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</RoleBasedRender>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Tooltip title="Переключить тему">
|
<Tooltip title="Переключить тему">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||||
sx={{ color: 'custom.sidebarText' }}
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{!collapsed && (
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={isDarkMode}
|
checked={isDarkMode}
|
||||||
onChange={() => setIsDarkMode(!isDarkMode)}
|
onChange={() => setIsDarkMode(!isDarkMode)}
|
||||||
size="small"
|
size="small"
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</FooterListItem>
|
</FooterListItem>
|
||||||
|
|
||||||
|
<FooterListItem button>
|
||||||
|
<Button
|
||||||
|
startIcon={<Help />}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Помощь
|
||||||
|
</Button>
|
||||||
|
</FooterListItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FooterListItem sx={{ justifyContent: 'center' }}>
|
||||||
|
<Tooltip title="Настройки" placement="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: alpha('#000000', 0.1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</FooterListItem>
|
||||||
|
)}
|
||||||
</FooterList>
|
</FooterList>
|
||||||
|
|
||||||
{/* Используем RoleBasedRender для модального окна */}
|
|
||||||
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
<RoleBasedRender user={user} allowedRoles={['admin']}>
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Collapse,
|
||||||
|
List,
|
||||||
|
IconButton,
|
||||||
|
Box,
|
||||||
|
alpha,
|
||||||
|
Typography,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import { ChevronRight, DragIndicator, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
const SortableMenuItem = ({
|
||||||
|
item,
|
||||||
|
collapsed,
|
||||||
|
onSelectItem,
|
||||||
|
level = 0,
|
||||||
|
isHovered = false,
|
||||||
|
showDropIndicator = false,
|
||||||
|
sidebarWidth = 300
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLocalHovered, setIsLocalHovered] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
isOver
|
||||||
|
} = useSortable({
|
||||||
|
id: item.id,
|
||||||
|
data: {
|
||||||
|
type: 'menu-item',
|
||||||
|
item,
|
||||||
|
level
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition: transition || 'all 0.2s ease',
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
||||||
|
const isFolder = hasChildren;
|
||||||
|
const isHighlighted = isHovered || isOver;
|
||||||
|
|
||||||
|
// Рассчитываем максимальную ширину текста в зависимости от уровня вложенности
|
||||||
|
const calculateMaxTextWidth = () => {
|
||||||
|
const baseWidth = sidebarWidth - 40; // Отступы и иконки
|
||||||
|
const levelOffset = level * 24; // Отступ для каждого уровня
|
||||||
|
return baseWidth - levelOffset - 60; // Оставляем место для иконок и отступов
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (hasChildren) {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
} else {
|
||||||
|
onSelectItem?.(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsLocalHovered(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsLocalHovered(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundColor = (theme) => {
|
||||||
|
if (isDragging) return alpha(theme.palette.primary.main, 0.1);
|
||||||
|
if (isHighlighted) return alpha(theme.palette.primary.main, 0.08);
|
||||||
|
if (isLocalHovered) return alpha(theme.palette.action.hover, 0.4);
|
||||||
|
return 'transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
'&::before': isHighlighted ? {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
width: 3,
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
borderRadius: '0 2px 2px 0',
|
||||||
|
} : {},
|
||||||
|
...(showDropIndicator && {
|
||||||
|
backgroundColor: (theme) => alpha(theme.palette.primary.main, 0.1),
|
||||||
|
border: (theme) => `2px dashed ${theme.palette.primary.main}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
sx={{
|
||||||
|
pl: collapsed ? 1 : Math.max(0.1, 0.1 + level * 0.1),
|
||||||
|
pr: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
minHeight: 32,
|
||||||
|
justifyContent: collapsed ? "center" : "flex-start",
|
||||||
|
backgroundColor: (theme) => getBackgroundColor(theme),
|
||||||
|
borderRadius: '6px',
|
||||||
|
margin: '1px 4px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<IconButton
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
cursor: isDragging ? "grabbing" : "grab",
|
||||||
|
mr: 1,
|
||||||
|
opacity: isLocalHovered || isDragging ? 1 : 0.4,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'text.primary',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
},
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIndicator fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<Tooltip title={item.title} placement="right" enterDelay={400} arrow>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: isFolder ? 600 : 400,
|
||||||
|
color: isFolder ? 'text.primary' : 'text.secondary',
|
||||||
|
maxWidth: calculateMaxTextWidth(),
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2, // максимум 2 строки
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontSize: "0.85rem", // компактнее текст
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ mr: 0.5, flex: '1 1 auto', minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{hasChildren && (
|
||||||
|
<ChevronRight
|
||||||
|
sx={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: 'text.disabled',
|
||||||
|
transform: isOpen ? 'rotate(90deg)' : 'none',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{hasChildren && !collapsed && (
|
||||||
|
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||||
|
<List
|
||||||
|
disablePadding
|
||||||
|
sx={{
|
||||||
|
pl: 1.5,
|
||||||
|
borderLeft: (theme) => `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||||
|
marginLeft: 2,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.items.map((child) => (
|
||||||
|
<Box key={child.id} position="relative">
|
||||||
|
<SortableMenuItem
|
||||||
|
item={child}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onSelectItem={onSelectItem}
|
||||||
|
level={level + 1}
|
||||||
|
isHovered={isHovered}
|
||||||
|
showDropIndicator={showDropIndicator}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableMenuItem;
|
||||||
|
|
@ -14,13 +14,12 @@ export default defineConfig({
|
||||||
rewrite: (path) => path.replace(/^\/ai-api/, ''),
|
rewrite: (path) => path.replace(/^\/ai-api/, ''),
|
||||||
},
|
},
|
||||||
'/metrics-ws': {
|
'/metrics-ws': {
|
||||||
target: 'ws://localhost:3001',
|
target: 'ws://192.168.2.39:3001',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://192.168.2.39:3000',
|
||||||
ws: true,
|
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
bypass(req, res, options) {
|
bypass(req, res, options) {
|
||||||
console.log('Proxying request:', req.url);
|
console.log('Proxying request:', req.url);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue