Compare commits

..

6 Commits

Author SHA1 Message Date
Vladislav Drozdov c411142840 Merge pull request 'redisign' (#59) from redisign into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-frontend/pulls/59
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-09-01 16:23:55 +03:00
DmitriyA 06249fce3a sidebar redesign 2025-09-01 09:20:23 -04:00
DmitriyA 933ceb2547 added drag-and-drop 2025-09-01 08:16:59 -04:00
DmitriyA 34f2010cae added menu editor 2025-08-28 09:20:42 -04:00
SovietSpiderCat 205ddc71e0 added complex variables 2025-08-22 09:57:16 +03:00
SovietSpiderCat 421d95565c fixed WS 2025-08-20 00:17:20 +03:00
13 changed files with 1678 additions and 488 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.git
.gitignore
Dockerfile
.dockerignore
dist
npm-debug.log

View File

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

View File

@ -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
key={`line-${key}`}
type="monotone"
dataKey={key}
name={` ${key}`}
stroke={lineColors[key] || lineColors.default}
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}
isAnimationActive={false}
/>
))
) : (
<Line <Line
key={`line-${device}`}
type="monotone" type="monotone"
dataKey={lineKeys[0] || 'value'} dataKey={`device_${device}`}
name={title} name={`Устройство ${device}`}
stroke={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}
/> />
)} ))}
{/* Добавляем диапазоны если они есть */} {/* Добавляем диапазоны если они есть */}
{ranges.map((range, idx) => ( {ranges.map((range, idx) => (

View File

@ -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 => {
...item, if (item.timestamp === undefined || item.value === undefined) {
timestamp: item.timestamp, console.warn('Invalid metric item:', item);
value: parseFloat(item.value), return null;
name: item.__name__ || metricName, }
status: parseInt(item.status) || 0,
device: item.device?.trim() || null, return {
source_id: item.source_id || null, ...item,
description: item.description || description, timestamp: Number(item.timestamp),
lineId: item[lineKey] || 'default' value: parseFloat(item.value),
})); status: getStatusFromRanges(parseFloat(item.value), ranges),
name: item.__name__ || metricName,
device: item.device?.trim() || null,
source_id: item.source_id || null,
description: item.description || description
};
}).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] const merged = [...filteredPrev, ...formattedNew]
.filter((v, i, a) => .filter((v, i, a) =>
a.findIndex(t => a.findIndex(t => t.timestamp === v.timestamp) === i
t.timestamp === v.timestamp && )
t[lineKey] === v[lineKey] .sort((a, b) => a.timestamp - b.timestamp);
) === i
);
return merged; 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;
if (mode === 'realtime') { const init = async () => {
unsubscribe = startRealtimeUpdates(); if (mode === 'realtime') {
} else { unsubscribe = startRealtimeUpdates();
stopRealtimeUpdates(); } else {
fetchHistoricalData(startDate, endDate); await 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>

View File

@ -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 = {}) { // Запрос текущих данных (разовый)
this.connectWebSocket(); async fetchCurrentMetrics(metric, filters = {}) {
return new Promise((resolve, reject) => {
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 секунд таймаут
}
const callbacks = this.subscriptions.get(metricKey); this.pendingRequests.set(requestId, {
callbacks.push(callback); resolve: (data) => {
clearTimeout(timeout);
resolve(data);
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
}
});
return () => this.unsubscribeFromMetric(metricKey, callback); this.sendMessage('get-current', {
metric,
filters
}, requestId);
});
} }
unsubscribeFromMetric(metricKey, callback) { // Отписка от всех подписок
const callbacks = this.subscriptions.get(metricKey) || []; unsubscribeAll() {
const filtered = callbacks.filter(cb => cb !== callback); this.sendMessage('unsubscribe-all', {});
this.subscriptions.clear();
if (filtered.length === 0) {
this.subscriptions.delete(metricKey);
const [metric] = metricKey.split('?');
this.sendMessage('unsubscribe-metric', { metric });
} else {
this.subscriptions.set(metricKey, filtered);
}
} }
parseFiltersFromKey(metricKey) { // ============ ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ============
const parts = metricKey.split('?');
if (parts.length < 2) return {}; getMetricKey(metric, filters) {
return parts[1].split('&').reduce((acc, pair) => { const sortedKeys = Object.keys(filters).sort();
const [key, value] = pair.split('='); const filterString = sortedKeys
if (key && value) acc[key] = value; .map(key => `${key}=${encodeURIComponent(filters[key])}`)
return acc; .join('&');
}, {});
return filterString ? `${metric}?${filterString}` : metric;
}
parseMetricKey(metricKey) {
const [metric, query] = metricKey.split('?');
const filters = {};
if (query) {
query.split('&').forEach(pair => {
const [key, value] = pair.split('=');
if (key && value) {
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;
}

View File

@ -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}`,

View File

@ -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;

View File

@ -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}>

View File

@ -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 (
<Box
sx={{
position: 'absolute',
left: 0,
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 ( return (
<Box <Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{ sx={{
position: 'relative', position: "relative",
width: collapsed ? 64 : sidebarWidth, width: collapsed ? 72 : sidebarWidth,
transition: 'width 0.3s ease', 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",
}}
>
<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>
</SortableContext>
onSelectItem={onSelectItem} <DragOverlay>
/> {activeItem ? (
)} <Box
</List> 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 && (
<SidebarResizer onMouseDown={startResizing} /> <Tooltip title="Изменить ширину" placement="top">
<SidebarResizer onMouseDown={startResizing} />
</Tooltip>
)} )}
</Drawer> </Drawer>
</Box> </Box>

View File

@ -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;

View File

@ -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 <FooterListItem>
primary="Помощь"
primaryTypographyProps={{
color: 'custom.sidebarText',
variant: 'body2'
}}
/>
</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 />}
</IconButton>
</Tooltip>
<Switch
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
color="primary"
/>
</Box>
</FooterListItem>
<FooterListItem button>
<Button
startIcon={<Help />}
sx={{
color: 'text.secondary',
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': {
color: 'text.primary',
backgroundColor: 'transparent'
}
}}
> >
{isDarkMode ? <Brightness4 /> : <Brightness7 />} Помощь
</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> </IconButton>
</Tooltip> </Tooltip>
{!collapsed && ( </FooterListItem>
<Switch )}
checked={isDarkMode}
onChange={() => setIsDarkMode(!isDarkMode)}
size="small"
/>
)}
</Box>
</FooterListItem>
</FooterList> </FooterList>
{/* Используем RoleBasedRender для модального окна */}
<RoleBasedRender user={user} allowedRoles={['admin']}> <RoleBasedRender user={user} allowedRoles={['admin']}>
<SettingsModal <SettingsModal
open={settingsOpen} open={settingsOpen}

View File

@ -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;

View File

@ -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);